Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
62
packages/markdown-core/package.json
Normal file
62
packages/markdown-core/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@openclaw/markdown-core",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./code-spans": {
|
||||
"types": "./dist/code-spans.d.mts",
|
||||
"import": "./dist/code-spans.mjs",
|
||||
"default": "./dist/code-spans.mjs"
|
||||
},
|
||||
"./fences": {
|
||||
"types": "./dist/fences.d.mts",
|
||||
"import": "./dist/fences.mjs",
|
||||
"default": "./dist/fences.mjs"
|
||||
},
|
||||
"./frontmatter": {
|
||||
"types": "./dist/frontmatter.d.mts",
|
||||
"import": "./dist/frontmatter.mjs",
|
||||
"default": "./dist/frontmatter.mjs"
|
||||
},
|
||||
"./ir": {
|
||||
"types": "./dist/ir.d.mts",
|
||||
"import": "./dist/ir.mjs",
|
||||
"default": "./dist/ir.mjs"
|
||||
},
|
||||
"./render": {
|
||||
"types": "./dist/render.d.mts",
|
||||
"import": "./dist/render.mjs",
|
||||
"default": "./dist/render.mjs"
|
||||
},
|
||||
"./render-aware-chunking": {
|
||||
"types": "./dist/render-aware-chunking.d.mts",
|
||||
"import": "./dist/render-aware-chunking.mjs",
|
||||
"default": "./dist/render-aware-chunking.mjs"
|
||||
},
|
||||
"./tables": {
|
||||
"types": "./dist/tables.d.mts",
|
||||
"import": "./dist/tables.mjs",
|
||||
"default": "./dist/tables.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.mts",
|
||||
"import": "./dist/types.mjs",
|
||||
"default": "./dist/types.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "14.3.0",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
}
|
||||
98
packages/markdown-core/src/chunk-text.ts
Normal file
98
packages/markdown-core/src/chunk-text.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Markdown Core module implements chunk text behavior.
|
||||
function resolveChunkEarlyReturn(text: string, limit: number): string[] | undefined {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
if (limit <= 0) {
|
||||
return [text];
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return [text];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function scanParenAwareBreakpoints(text: string): { lastNewline: number; lastWhitespace: number } {
|
||||
let lastNewline = -1;
|
||||
let lastWhitespace = -1;
|
||||
let depth = 0;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
// Parenthesized spans often contain rewritten links or file references;
|
||||
// avoid splitting them unless the window has no safer outside break.
|
||||
if (char === "(") {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (depth !== 0) {
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
lastNewline = i;
|
||||
} else if (/\s/.test(char)) {
|
||||
lastWhitespace = i;
|
||||
}
|
||||
}
|
||||
|
||||
return { lastNewline, lastWhitespace };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps UTF-16 chunk boundaries from separating a supplementary-plane character.
|
||||
* A one-unit positive limit still needs to emit an entire surrogate pair.
|
||||
*/
|
||||
export function avoidTrailingHighSurrogateBreak(text: string, start: number, end: number): number {
|
||||
if (
|
||||
end >= text.length ||
|
||||
text.charCodeAt(end - 1) < 0xd800 ||
|
||||
text.charCodeAt(end - 1) > 0xdbff ||
|
||||
text.charCodeAt(end) < 0xdc00 ||
|
||||
text.charCodeAt(end) > 0xdfff
|
||||
) {
|
||||
return end;
|
||||
}
|
||||
return end - 1 > start ? end - 1 : end + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits plain text into size-bounded chunks at readable boundaries.
|
||||
*
|
||||
* Returns the original text as one chunk when the limit is non-positive.
|
||||
*/
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
const early = resolveChunkEarlyReturn(text, limit);
|
||||
if (early) {
|
||||
return early;
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
if (text.length - cursor <= limit) {
|
||||
chunks.push(text.slice(cursor));
|
||||
break;
|
||||
}
|
||||
const windowEnd = Math.min(text.length, cursor + limit);
|
||||
const window = text.slice(cursor, windowEnd);
|
||||
const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
|
||||
// Prefer block boundaries, then spaces, then a hard size cut when no
|
||||
// readable breakpoint exists inside this window.
|
||||
const breakOffset = lastNewline > 0 ? lastNewline : lastWhitespace;
|
||||
const end = avoidTrailingHighSurrogateBreak(
|
||||
text,
|
||||
cursor,
|
||||
breakOffset > 0 ? cursor + breakOffset : windowEnd,
|
||||
);
|
||||
chunks.push(text.slice(cursor, end));
|
||||
cursor = end;
|
||||
while (cursor < text.length && /\s/.test(text[cursor] ?? "")) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
120
packages/markdown-core/src/code-spans.ts
Normal file
120
packages/markdown-core/src/code-spans.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Markdown Core module implements code spans behavior.
|
||||
import { scanFenceSpans, type FenceScanState, type FenceSpan } from "./fences.js";
|
||||
|
||||
/** Incremental inline-code scanner state carried across chunk boundaries. */
|
||||
export type InlineCodeState = {
|
||||
/** Whether the current scan is inside an unterminated inline code span. */
|
||||
open: boolean;
|
||||
/** Backtick run length required to close the current inline code span. */
|
||||
ticks: number;
|
||||
};
|
||||
|
||||
/** Creates the carry-forward state used when scanning inline code across chunks. */
|
||||
export function createInlineCodeState(): InlineCodeState {
|
||||
return { open: false, ticks: 0 };
|
||||
}
|
||||
|
||||
type InlineCodeSpansResult = {
|
||||
spans: Array<[number, number]>;
|
||||
state: InlineCodeState;
|
||||
};
|
||||
|
||||
type CodeSpanIndex = {
|
||||
/** Inline-code state to carry into the next streamed chunk. */
|
||||
inlineState: InlineCodeState;
|
||||
/** Fenced-code state to carry into the next streamed chunk. */
|
||||
fenceState: FenceScanState;
|
||||
/** True when an offset is inside fenced code or inline code. */
|
||||
isInside: (index: number) => boolean;
|
||||
};
|
||||
|
||||
/** Builds a lookup for fenced and inline code spans while preserving scanner state. */
|
||||
export function buildCodeSpanIndex(
|
||||
text: string,
|
||||
inlineState?: InlineCodeState,
|
||||
fenceState?: FenceScanState,
|
||||
): CodeSpanIndex {
|
||||
const { spans: fenceSpans, state: nextFenceState } = scanFenceSpans(text, fenceState);
|
||||
const startState = inlineState
|
||||
? { open: inlineState.open, ticks: inlineState.ticks }
|
||||
: createInlineCodeState();
|
||||
const { spans: inlineSpans, state: nextInlineState } = parseInlineCodeSpans(
|
||||
text,
|
||||
fenceSpans,
|
||||
startState,
|
||||
);
|
||||
|
||||
return {
|
||||
inlineState: nextInlineState,
|
||||
fenceState: nextFenceState,
|
||||
isInside: (index: number) =>
|
||||
isInsideFenceSpan(index, fenceSpans) || isInsideInlineSpan(index, inlineSpans),
|
||||
};
|
||||
}
|
||||
|
||||
function parseInlineCodeSpans(
|
||||
text: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
initialState: InlineCodeState,
|
||||
): InlineCodeSpansResult {
|
||||
const spans: Array<[number, number]> = [];
|
||||
let open = initialState.open;
|
||||
let ticks = initialState.ticks;
|
||||
let openStart = open ? 0 : -1;
|
||||
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const fence = findFenceSpanAtInclusive(fenceSpans, i);
|
||||
if (fence) {
|
||||
i = fence.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (text[i] !== "`") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const runStart = i;
|
||||
let runLength = 0;
|
||||
while (i < text.length && text[i] === "`") {
|
||||
runLength += 1;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
open = true;
|
||||
ticks = runLength;
|
||||
openStart = runStart;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (runLength === ticks) {
|
||||
spans.push([openStart, i]);
|
||||
open = false;
|
||||
ticks = 0;
|
||||
openStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
spans.push([openStart, text.length]);
|
||||
}
|
||||
|
||||
return {
|
||||
spans,
|
||||
state: { open, ticks },
|
||||
};
|
||||
}
|
||||
|
||||
function findFenceSpanAtInclusive(spans: FenceSpan[], index: number): FenceSpan | undefined {
|
||||
return spans.find((span) => index >= span.start && index < span.end);
|
||||
}
|
||||
|
||||
function isInsideFenceSpan(index: number, spans: FenceSpan[]): boolean {
|
||||
return spans.some((span) => index >= span.start && index < span.end);
|
||||
}
|
||||
|
||||
function isInsideInlineSpan(index: number, spans: Array<[number, number]>): boolean {
|
||||
return spans.some(([start, end]) => index >= start && index < end);
|
||||
}
|
||||
45
packages/markdown-core/src/fences.test.ts
Normal file
45
packages/markdown-core/src/fences.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Tests fenced-code-block span scanning used to keep chunk breaks out of code blocks.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSafeFenceBreak, parseFenceSpans } from "./fences.js";
|
||||
|
||||
describe("parseFenceSpans closing-fence rules", () => {
|
||||
it("treats a marker line with trailing text as code content, not a closing fence", () => {
|
||||
// CommonMark: a closing fence may be followed only by whitespace, so "``` not a close" is code
|
||||
// content and the block stays open until the real closing fence. Reporting an interior offset
|
||||
// as a safe break would let a chunker split inside the code block.
|
||||
const text = "```\ncode\n``` not a close\nmore code\n```\n";
|
||||
const spans = parseFenceSpans(text);
|
||||
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not close on non-space/tab whitespace", () => {
|
||||
for (const suffix of ["\u00a0", "\v", "\f"]) {
|
||||
const text = `\`\`\`\ncode\n\`\`\`${suffix}\nmore code\n`;
|
||||
const spans = parseFenceSpans(text);
|
||||
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(spans[0]?.end).toBe(text.length);
|
||||
expect(isSafeFenceBreak(spans, text.indexOf("more code") + 1)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes fences with CRLF line endings", () => {
|
||||
const text = "```\r\ncode\r\n```\r\nafter\r\n";
|
||||
const spans = parseFenceSpans(text);
|
||||
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(isSafeFenceBreak(spans, text.indexOf("after") + 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("still closes on a bare fence, a longer same-marker fence, and keeps an opener info string", () => {
|
||||
expect(parseFenceSpans("```\ncode\n```\nafter\n")).toHaveLength(1);
|
||||
expect(parseFenceSpans("```\ncode\n````` \nafter\n")).toHaveLength(1);
|
||||
expect(parseFenceSpans("```python\nx = 1\n```\n")).toHaveLength(1);
|
||||
|
||||
const closed = "```\ncode\n```\nafter\n";
|
||||
const spans = parseFenceSpans(closed);
|
||||
expect(isSafeFenceBreak(spans, closed.indexOf("after") + 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
147
packages/markdown-core/src/fences.ts
Normal file
147
packages/markdown-core/src/fences.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/** Markdown fenced-code block span with the opener data needed to reopen it. */
|
||||
export type FenceSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
openLine: string;
|
||||
marker: string;
|
||||
indent: string;
|
||||
};
|
||||
|
||||
/** Streaming fence scanner state carried across partial markdown chunks. */
|
||||
export type FenceScanState = {
|
||||
atLineStart?: boolean;
|
||||
open?: {
|
||||
markerChar: string;
|
||||
markerLen: number;
|
||||
openLine: string;
|
||||
marker: string;
|
||||
indent: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Scans fenced-code spans incrementally so chunking can carry an open fence forward. */
|
||||
export function scanFenceSpans(
|
||||
buffer: string,
|
||||
state?: FenceScanState,
|
||||
): { spans: FenceSpan[]; state: FenceScanState } {
|
||||
const spans: FenceSpan[] = [];
|
||||
const startsAtLineStart = state?.atLineStart ?? true;
|
||||
let open:
|
||||
| {
|
||||
start: number;
|
||||
markerChar: string;
|
||||
markerLen: number;
|
||||
openLine: string;
|
||||
marker: string;
|
||||
indent: string;
|
||||
}
|
||||
| undefined = state?.open ? { ...state.open, start: 0 } : undefined;
|
||||
|
||||
let offset = 0;
|
||||
while (offset <= buffer.length) {
|
||||
const nextNewline = buffer.indexOf("\n", offset);
|
||||
const lineEnd = nextNewline === -1 ? buffer.length : nextNewline;
|
||||
const line = buffer.slice(offset, lineEnd).replace(/\r$/, "");
|
||||
|
||||
const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (match && (offset > 0 || startsAtLineStart)) {
|
||||
const indent = match[1];
|
||||
const marker = match[2];
|
||||
const markerChar = marker[0];
|
||||
const markerLen = marker.length;
|
||||
if (!open) {
|
||||
open = {
|
||||
start: offset,
|
||||
markerChar,
|
||||
markerLen,
|
||||
openLine: line,
|
||||
marker,
|
||||
indent,
|
||||
};
|
||||
} else if (
|
||||
open.markerChar === markerChar &&
|
||||
markerLen >= open.markerLen &&
|
||||
/^[ \t]*$/.test(match[3])
|
||||
) {
|
||||
// CommonMark permits only spaces or tabs after a closing fence. A marker line carrying
|
||||
// other trailing text is code content, not a close, so it must not end the block.
|
||||
const end = lineEnd;
|
||||
spans.push({
|
||||
start: open.start,
|
||||
end,
|
||||
openLine: open.openLine,
|
||||
marker: open.marker,
|
||||
indent: open.indent,
|
||||
});
|
||||
open = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextNewline === -1) {
|
||||
break;
|
||||
}
|
||||
offset = nextNewline + 1;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
spans.push({
|
||||
start: open.start,
|
||||
end: buffer.length,
|
||||
openLine: open.openLine,
|
||||
marker: open.marker,
|
||||
indent: open.indent,
|
||||
});
|
||||
}
|
||||
|
||||
const atLineStart = buffer.length === 0 ? startsAtLineStart : buffer.endsWith("\n");
|
||||
const nextState: FenceScanState = {
|
||||
atLineStart,
|
||||
...(open
|
||||
? {
|
||||
open: {
|
||||
markerChar: open.markerChar,
|
||||
markerLen: open.markerLen,
|
||||
openLine: open.openLine,
|
||||
marker: open.marker,
|
||||
indent: open.indent,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return { spans, state: nextState };
|
||||
}
|
||||
|
||||
/** Parses all fenced-code spans in a complete markdown buffer. */
|
||||
export function parseFenceSpans(buffer: string): FenceSpan[] {
|
||||
return scanFenceSpans(buffer).spans;
|
||||
}
|
||||
|
||||
/** Looks up the fence containing an offset; spans must be sorted by start offset. */
|
||||
export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined {
|
||||
let low = 0;
|
||||
let high = spans.length - 1;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const span = spans[mid];
|
||||
if (!span) {
|
||||
break;
|
||||
}
|
||||
if (index <= span.start) {
|
||||
high = mid - 1;
|
||||
continue;
|
||||
}
|
||||
if (index >= span.end) {
|
||||
low = mid + 1;
|
||||
continue;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when a chunk boundary would not split a fenced-code block. */
|
||||
export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean {
|
||||
return !findFenceSpanAt(spans, index);
|
||||
}
|
||||
135
packages/markdown-core/src/frontmatter.test.ts
Normal file
135
packages/markdown-core/src/frontmatter.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Markdown Core tests cover frontmatter behavior.
|
||||
import JSON5 from "json5";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontmatterBlock } from "./frontmatter.js";
|
||||
|
||||
describe("parseFrontmatterBlock", () => {
|
||||
it("parses YAML block scalars", () => {
|
||||
const content = `---
|
||||
name: yaml-hook
|
||||
description: |
|
||||
line one
|
||||
line two
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.name).toBe("yaml-hook");
|
||||
expect(result.description).toBe("line one\nline two");
|
||||
});
|
||||
|
||||
it("handles JSON5-style multi-line metadata", () => {
|
||||
const content = `---
|
||||
name: session-memory
|
||||
metadata:
|
||||
{
|
||||
"openclaw":
|
||||
{
|
||||
"emoji": "disk",
|
||||
"events": ["command:new"],
|
||||
},
|
||||
}
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.metadata).toBe('{"openclaw":{"emoji":"disk","events":["command:new"]}}');
|
||||
|
||||
const parsed = JSON5.parse(result.metadata);
|
||||
expect(parsed.openclaw?.emoji).toBe("disk");
|
||||
});
|
||||
|
||||
it("preserves inline JSON values", () => {
|
||||
const content = `---
|
||||
name: inline-json
|
||||
metadata: {"openclaw": {"events": ["test"]}}
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.metadata).toBe('{"openclaw": {"events": ["test"]}}');
|
||||
});
|
||||
|
||||
it("stringifies YAML objects and arrays", () => {
|
||||
const content = `---
|
||||
name: yaml-objects
|
||||
enabled: true
|
||||
retries: 3
|
||||
tags:
|
||||
- alpha
|
||||
- beta
|
||||
metadata:
|
||||
openclaw:
|
||||
events:
|
||||
- command:new
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.enabled).toBe("true");
|
||||
expect(result.retries).toBe("3");
|
||||
expect(JSON.parse(result.tags ?? "[]")).toEqual(["alpha", "beta"]);
|
||||
const parsed = JSON5.parse(result.metadata ?? "");
|
||||
expect(parsed.openclaw?.events).toEqual(["command:new"]);
|
||||
});
|
||||
|
||||
it("preserves inline description values containing colons", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
description: Use anime style IMPORTANT: Must be kawaii
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.description).toBe("Use anime style IMPORTANT: Must be kawaii");
|
||||
});
|
||||
|
||||
it("does not replace YAML block scalars with block indicators", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
description: |-
|
||||
{json-like text}
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.description).toBe("{json-like text}");
|
||||
});
|
||||
|
||||
it("keeps nested YAML mappings as structured JSON", () => {
|
||||
const content = `---
|
||||
name: sample-skill
|
||||
metadata:
|
||||
openclaw: true
|
||||
---`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(result.metadata).toBe('{"openclaw":true}');
|
||||
});
|
||||
|
||||
it("returns empty when frontmatter is missing", () => {
|
||||
const content = "# No frontmatter";
|
||||
expect(parseFrontmatterBlock(content)).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("preserves prototype-named keys when YAML value is null", () => {
|
||||
const content = `---
|
||||
title: Hello
|
||||
toString: null
|
||||
constructor: null
|
||||
valueOf: null
|
||||
hasOwnProperty: null
|
||||
---
|
||||
Body text`;
|
||||
const result = parseFrontmatterBlock(content);
|
||||
expect(Object.hasOwn(result, "toString")).toBe(true);
|
||||
expect(result["toString"]).toBe("null");
|
||||
expect(Object.hasOwn(result, "constructor")).toBe(true);
|
||||
expect(result["constructor"]).toBe("null");
|
||||
expect(Object.hasOwn(result, "valueOf")).toBe(true);
|
||||
expect(result["valueOf"]).toBe("null");
|
||||
expect(Object.hasOwn(result, "hasOwnProperty")).toBe(true);
|
||||
expect(result["hasOwnProperty"]).toBe("null");
|
||||
// normal key unaffected
|
||||
expect(result.title).toBe("Hello");
|
||||
});
|
||||
|
||||
it("parses frontmatter after a leading UTF-8 BOM", () => {
|
||||
const content = "\uFEFF---\nname: windows-skill\ndescription: Written by PowerShell\n---\n";
|
||||
const result = parseFrontmatterBlock(content);
|
||||
|
||||
expect(result.name).toBe("windows-skill");
|
||||
expect(result.description).toBe("Written by PowerShell");
|
||||
});
|
||||
});
|
||||
231
packages/markdown-core/src/frontmatter.ts
Normal file
231
packages/markdown-core/src/frontmatter.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Markdown Core module implements frontmatter behavior.
|
||||
import YAML from "yaml";
|
||||
|
||||
type ParsedFrontmatter = Record<string, string>;
|
||||
|
||||
type ParsedFrontmatterLineEntry = {
|
||||
value: string;
|
||||
kind: "inline" | "multiline";
|
||||
rawInline: string;
|
||||
};
|
||||
|
||||
type ParsedYamlValue = {
|
||||
value: string;
|
||||
kind: "scalar" | "structured";
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function coerceYamlFrontmatterValue(value: unknown): ParsedYamlValue | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
value: value.trim(),
|
||||
kind: "scalar",
|
||||
};
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return {
|
||||
value: String(value),
|
||||
kind: "scalar",
|
||||
};
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return {
|
||||
value: JSON.stringify(value),
|
||||
kind: "structured",
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseYamlFrontmatter(block: string): Record<string, ParsedYamlValue> | null {
|
||||
try {
|
||||
const parsed = YAML.parse(block, { schema: "core" }) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const result: Record<string, ParsedYamlValue> = {};
|
||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const key = rawKey.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const coerced = coerceYamlFrontmatterValue(value);
|
||||
if (!coerced) {
|
||||
continue;
|
||||
}
|
||||
result[key] = coerced;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMultiLineValue(
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
): {
|
||||
value: string;
|
||||
linesConsumed: number;
|
||||
} {
|
||||
const valueLines: string[] = [];
|
||||
let i = startIndex + 1;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t")) {
|
||||
break;
|
||||
}
|
||||
valueLines.push(line);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const combined = valueLines.join("\n").trim();
|
||||
return { value: combined, linesConsumed: i - startIndex };
|
||||
}
|
||||
|
||||
function parseLineFrontmatter(block: string): Record<string, ParsedFrontmatterLineEntry> {
|
||||
const result: Record<string, ParsedFrontmatterLineEntry> = {};
|
||||
const lines = block.split("\n");
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const inlineValue = match[2].trim();
|
||||
if (!key) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inlineValue && i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) {
|
||||
const { value, linesConsumed } = extractMultiLineValue(lines, i);
|
||||
if (value) {
|
||||
result[key] = {
|
||||
value,
|
||||
kind: "multiline",
|
||||
rawInline: inlineValue,
|
||||
};
|
||||
}
|
||||
i += linesConsumed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const value = stripQuotes(inlineValue);
|
||||
if (value) {
|
||||
result[key] = {
|
||||
value,
|
||||
kind: "inline",
|
||||
rawInline: inlineValue,
|
||||
};
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function lineFrontmatterToPlain(
|
||||
parsed: Record<string, ParsedFrontmatterLineEntry>,
|
||||
): ParsedFrontmatter {
|
||||
const result: ParsedFrontmatter = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
result[key] = entry.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isYamlBlockScalarIndicator(value: string): boolean {
|
||||
return /^[|>][+-]?(\d+)?[+-]?$/.test(value);
|
||||
}
|
||||
|
||||
function shouldPreferInlineLineValue(params: {
|
||||
lineEntry: ParsedFrontmatterLineEntry;
|
||||
yamlValue: ParsedYamlValue;
|
||||
}): boolean {
|
||||
const { lineEntry, yamlValue } = params;
|
||||
if (yamlValue.kind !== "structured") {
|
||||
return false;
|
||||
}
|
||||
if (lineEntry.kind !== "inline") {
|
||||
return false;
|
||||
}
|
||||
if (isYamlBlockScalarIndicator(lineEntry.rawInline)) {
|
||||
return false;
|
||||
}
|
||||
return lineEntry.value.includes(":");
|
||||
}
|
||||
|
||||
function extractFrontmatterBlock(content: string): string | undefined {
|
||||
const normalized = content
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) {
|
||||
return undefined;
|
||||
}
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized.slice(4, endIndex);
|
||||
}
|
||||
|
||||
/** Parses leading YAML frontmatter into string values used by skill and metadata loaders. */
|
||||
export function parseFrontmatterBlock(content: string): ParsedFrontmatter {
|
||||
const block = extractFrontmatterBlock(content);
|
||||
if (!block) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const lineParsed = parseLineFrontmatter(block);
|
||||
const yamlParsed = parseYamlFrontmatter(block);
|
||||
if (yamlParsed === null) {
|
||||
return lineFrontmatterToPlain(lineParsed);
|
||||
}
|
||||
|
||||
const merged: ParsedFrontmatter = {};
|
||||
for (const [key, yamlValue] of Object.entries(yamlParsed)) {
|
||||
merged[key] = yamlValue.value;
|
||||
const lineEntry = lineParsed[key];
|
||||
if (!lineEntry) {
|
||||
continue;
|
||||
}
|
||||
if (shouldPreferInlineLineValue({ lineEntry, yamlValue })) {
|
||||
merged[key] = lineEntry.value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, lineEntry] of Object.entries(lineParsed)) {
|
||||
if (!Object.hasOwn(merged, key)) {
|
||||
merged[key] = lineEntry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
9
packages/markdown-core/src/index.ts
Normal file
9
packages/markdown-core/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/** Public Markdown parsing, rendering, chunking, and table-conversion utilities. */
|
||||
export * from "./code-spans.js";
|
||||
export * from "./fences.js";
|
||||
export * from "./frontmatter.js";
|
||||
export * from "./ir.js";
|
||||
export * from "./render-aware-chunking.js";
|
||||
export * from "./render.js";
|
||||
export * from "./tables.js";
|
||||
export * from "./types.js";
|
||||
210
packages/markdown-core/src/ir.blockquote-spacing.test.ts
Normal file
210
packages/markdown-core/src/ir.blockquote-spacing.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Blockquote Spacing Tests
|
||||
*
|
||||
* Per CommonMark spec (§5.1 Block quotes), blockquotes are "container blocks" that
|
||||
* contain other block-level elements (paragraphs, code blocks, etc.).
|
||||
*
|
||||
* In plaintext rendering, the expected spacing between block-level elements is
|
||||
* a single blank line (double newline `\n\n`). This is the standard paragraph
|
||||
* separation used throughout markdown.
|
||||
*
|
||||
* CORRECT behavior:
|
||||
* - Blockquote content followed by paragraph: "quote\n\nparagraph" (double \n)
|
||||
* - Two consecutive blockquotes: "first\n\nsecond" (double \n)
|
||||
*
|
||||
* BUG (current behavior):
|
||||
* - Produces triple newlines: "quote\n\n\nparagraph"
|
||||
*
|
||||
* Root cause:
|
||||
* 1. `paragraph_close` inside blockquote adds `\n\n` (correct)
|
||||
* 2. `blockquote_close` adds another `\n` (incorrect)
|
||||
* 3. Result: `\n\n\n` (triple newlines - incorrect)
|
||||
*
|
||||
* The fix: `blockquote_close` should NOT add `\n` because:
|
||||
* - Blockquotes are container blocks, not leaf blocks
|
||||
* - The inner content (paragraph, heading, etc.) already provides block separation
|
||||
* - Container closings shouldn't add their own spacing
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
describe("blockquote spacing", () => {
|
||||
describe("blockquote followed by paragraph", () => {
|
||||
it("should have double newline (one blank line) between blockquote and paragraph", () => {
|
||||
const input = "> quote\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// CORRECT: "quote\n\nparagraph" (double newline)
|
||||
// BUG: "quote\n\n\nparagraph" (triple newline)
|
||||
expect(result.text).toBe("quote\n\nparagraph");
|
||||
});
|
||||
|
||||
it("should not produce triple newlines", () => {
|
||||
const input = "> quote\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("excludes the trailing paragraph separator from the blockquote span", () => {
|
||||
const result = markdownToIR("> `gpt`\n\nbody");
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "gpt\n\nbody",
|
||||
styles: [
|
||||
{ start: 0, end: 3, style: "blockquote" },
|
||||
{ start: 0, end: 3, style: "code" },
|
||||
],
|
||||
links: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("consecutive blockquotes", () => {
|
||||
it("should have double newline between two blockquotes", () => {
|
||||
const input = "> first\n\n> second";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("first\n\nsecond");
|
||||
});
|
||||
|
||||
it("should not produce triple newlines between blockquotes", () => {
|
||||
const input = "> first\n\n> second";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested blockquotes", () => {
|
||||
it("should handle nested blockquotes correctly", () => {
|
||||
const input = "> outer\n>> inner";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Inner blockquote becomes separate paragraph
|
||||
expect(result.text).toBe("outer\n\ninner");
|
||||
});
|
||||
|
||||
it("should not produce triple newlines in nested blockquotes", () => {
|
||||
const input = "> outer\n>> inner\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("should handle deeply nested blockquotes", () => {
|
||||
const input = "> level 1\n>> level 2\n>>> level 3";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Each nested level is a new paragraph
|
||||
expect(result.text).toBe("level 1\n\nlevel 2\n\nlevel 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockquote followed by other block elements", () => {
|
||||
it("should have double newline between blockquote and heading", () => {
|
||||
const input = "> quote\n\n# Heading";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("quote\n\nHeading");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("should have double newline between blockquote and list", () => {
|
||||
const input = "> quote\n\n- item";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// The list item becomes "• item"
|
||||
expect(result.text).toBe("quote\n\n• item");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("should have double newline between blockquote and code block", () => {
|
||||
const input = "> quote\n\n```\ncode\n```";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Code blocks preserve their trailing newline
|
||||
expect(result.text).toBe("quote\n\ncode\n");
|
||||
});
|
||||
|
||||
it("should have double newline between blockquote and horizontal rule", () => {
|
||||
const input = "> quote\n\n---\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("quote\n\n───\n\nparagraph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockquote with multi-paragraph content", () => {
|
||||
it("should handle multi-paragraph blockquote followed by paragraph", () => {
|
||||
const input = "> first paragraph\n>\n> second paragraph\n\nfollowing paragraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Multi-paragraph blockquote should have proper internal spacing
|
||||
// AND proper spacing with following content
|
||||
expect(result.text).toBe("first paragraph\n\nsecond paragraph\n\nfollowing paragraph");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blockquote prefix option", () => {
|
||||
it("should include prefix and maintain proper spacing", () => {
|
||||
const input = "> quote\n\nparagraph";
|
||||
const result = markdownToIR(input, { blockquotePrefix: "> " });
|
||||
|
||||
// With prefix, should still have proper spacing
|
||||
expect(result.text).toBe("> quote\n\nparagraph");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty blockquote followed by paragraph", () => {
|
||||
const input = ">\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("paragraph");
|
||||
});
|
||||
|
||||
it("should handle blockquote at end of document", () => {
|
||||
const input = "paragraph\n\n> quote";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("paragraph\n\nquote");
|
||||
});
|
||||
|
||||
it("should handle multiple blockquotes with paragraphs between", () => {
|
||||
const input = "> first\n\nparagraph\n\n> second";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("first\n\nparagraph\n\nsecond");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("comparison with other block elements (control group)", () => {
|
||||
it("paragraphs should have double newline separation", () => {
|
||||
const input = "paragraph 1\n\nparagraph 2";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("paragraph 1\n\nparagraph 2");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("list followed by paragraph should have double newline", () => {
|
||||
const input = "- item 1\n- item 2\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Lists already work correctly
|
||||
expect(result.text).toBe("• item 1\n• item 2\n\nparagraph");
|
||||
});
|
||||
|
||||
it("heading followed by paragraph should have double newline", () => {
|
||||
const input = "# Heading\n\nparagraph";
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe("Heading\n\nparagraph");
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
15
packages/markdown-core/src/ir.chunking.test.ts
Normal file
15
packages/markdown-core/src/ir.chunking.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Markdown Core tests cover ir.chunking behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chunkMarkdownIR, type MarkdownIR } from "./ir.js";
|
||||
|
||||
describe("chunkMarkdownIR", () => {
|
||||
it("keeps the final in-limit remainder together after a soft break", () => {
|
||||
const ir: MarkdownIR = {
|
||||
text: "abcdefgh ij kl",
|
||||
styles: [],
|
||||
links: [],
|
||||
};
|
||||
|
||||
expect(chunkMarkdownIR(ir, 10).map((chunk) => chunk.text)).toEqual(["abcdefgh", "ij kl"]);
|
||||
});
|
||||
});
|
||||
164
packages/markdown-core/src/ir.hr-spacing.test.ts
Normal file
164
packages/markdown-core/src/ir.hr-spacing.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// Markdown Core tests cover ir.hr spacing behavior.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
/**
|
||||
* HR (Thematic Break) Spacing Analysis
|
||||
* =====================================
|
||||
*
|
||||
* CommonMark Spec (0.31.2) Section 4.1 - Thematic Breaks:
|
||||
* - Thematic breaks (---, ***, ___) produce <hr /> in HTML
|
||||
* - "Thematic breaks do not need blank lines before or after"
|
||||
* - A thematic break can interrupt a paragraph
|
||||
*
|
||||
* HTML Output per spec:
|
||||
* Input: "Foo\n***\nbar"
|
||||
* HTML: "<p>Foo</p>\n<hr />\n<p>bar</p>"
|
||||
*
|
||||
* PLAIN TEXT OUTPUT DECISION:
|
||||
*
|
||||
* The HR element is a block-level thematic separator. In plain text output,
|
||||
* we render HRs as a visible separator "───" to maintain visual distinction.
|
||||
*/
|
||||
|
||||
describe("hr (thematic break) spacing", () => {
|
||||
describe("current behavior documentation", () => {
|
||||
it("just hr alone renders as separator", () => {
|
||||
const result = markdownToIR("---");
|
||||
expect(result.text).toBe("───");
|
||||
});
|
||||
|
||||
it("hr interrupting paragraph (setext heading case)", () => {
|
||||
// Note: "Para\n---" is a setext heading in CommonMark!
|
||||
// Using *** to test actual HR behavior
|
||||
const input = `Para 1
|
||||
***
|
||||
Para 2`;
|
||||
const result = markdownToIR(input);
|
||||
// HR interrupts para, renders visibly
|
||||
expect(result.text).toContain("───");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expected behavior (tests assert CORRECT behavior)", () => {
|
||||
it("hr between paragraphs should render with separator", () => {
|
||||
const input = `Para 1
|
||||
|
||||
---
|
||||
|
||||
Para 2`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
|
||||
});
|
||||
|
||||
it("hr between paragraphs using *** should render with separator", () => {
|
||||
const input = `Para 1
|
||||
|
||||
***
|
||||
|
||||
Para 2`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
|
||||
});
|
||||
|
||||
it("hr between paragraphs using ___ should render with separator", () => {
|
||||
const input = `Para 1
|
||||
|
||||
___
|
||||
|
||||
Para 2`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("Para 1\n\n───\n\nPara 2");
|
||||
});
|
||||
|
||||
it("consecutive hrs should produce multiple separators", () => {
|
||||
const input = `---
|
||||
---
|
||||
---`;
|
||||
const result = markdownToIR(input);
|
||||
// Each HR renders as a separator
|
||||
expect(result.text).toBe("───\n\n───\n\n───");
|
||||
});
|
||||
|
||||
it("hr at document end renders separator", () => {
|
||||
const input = `Para
|
||||
|
||||
---`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("Para\n\n───");
|
||||
});
|
||||
|
||||
it("hr at document start renders separator", () => {
|
||||
const input = `---
|
||||
|
||||
Para`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("───\n\nPara");
|
||||
});
|
||||
|
||||
it("should not produce triple newlines regardless of hr placement", () => {
|
||||
const inputs = [
|
||||
"Para 1\n\n---\n\nPara 2",
|
||||
"Para 1\n---\nPara 2",
|
||||
"---\nPara",
|
||||
"Para\n---",
|
||||
"Para 1\n\n---\n\n---\n\nPara 2",
|
||||
"Para 1\n\n***\n\n---\n\n___\n\nPara 2",
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text, `Input: ${JSON.stringify(input)}`).not.toMatch(/\n{3,}/);
|
||||
}
|
||||
});
|
||||
|
||||
it("multiple consecutive hrs between paragraphs should each render as separator", () => {
|
||||
const input = `Para 1
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
Para 2`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("Para 1\n\n───\n\n───\n\n───\n\nPara 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("hr between list items renders as separator without extra spacing", () => {
|
||||
const input = `- Item 1
|
||||
- ---
|
||||
- Item 2`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toBe("• Item 1\n\n───\n\n• Item 2");
|
||||
expect(result.text).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
it("hr followed immediately by heading", () => {
|
||||
const input = `---
|
||||
|
||||
# Heading
|
||||
|
||||
Para`;
|
||||
const result = markdownToIR(input);
|
||||
// HR renders as separator, heading renders, para follows
|
||||
expect(result.text).not.toMatch(/\n{3,}/);
|
||||
expect(result.text).toContain("───");
|
||||
});
|
||||
|
||||
it("heading followed by hr", () => {
|
||||
const input = `# Heading
|
||||
|
||||
---
|
||||
|
||||
Para`;
|
||||
const result = markdownToIR(input);
|
||||
// Heading ends, HR renders, para follows
|
||||
expect(result.text).not.toMatch(/\n{3,}/);
|
||||
expect(result.text).toContain("───");
|
||||
});
|
||||
});
|
||||
});
|
||||
429
packages/markdown-core/src/ir.nested-lists.test.ts
Normal file
429
packages/markdown-core/src/ir.nested-lists.test.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Nested List Rendering Tests
|
||||
*
|
||||
* This test file documents and validates the expected behavior for nested lists
|
||||
* when rendering Markdown to plain text.
|
||||
*
|
||||
* ## Expected Plain Text Behavior
|
||||
*
|
||||
* Per CommonMark spec, nested lists create a hierarchical structure. When rendering
|
||||
* to plain text for messaging platforms, we expect:
|
||||
*
|
||||
* 1. **Indentation**: Each nesting level adds 2 spaces of indentation
|
||||
* 2. **Bullet markers**: Bullet lists use "•" (Unicode bullet)
|
||||
* 3. **Ordered markers**: Ordered lists use "N. " format
|
||||
* 4. **Line endings**: Each list item ends with a single newline
|
||||
* 5. **List termination**: A trailing newline after the entire list (for top-level only)
|
||||
*
|
||||
* ## markdown-it Token Sequence
|
||||
*
|
||||
* For nested lists, markdown-it emits tokens in this order:
|
||||
* - bullet_list_open (outer)
|
||||
* - list_item_open
|
||||
* - paragraph_open (hidden=true for tight lists)
|
||||
* - inline (with text children)
|
||||
* - paragraph_close
|
||||
* - bullet_list_open (nested)
|
||||
* - list_item_open
|
||||
* - paragraph_open
|
||||
* - inline
|
||||
* - paragraph_close
|
||||
* - list_item_close
|
||||
* - bullet_list_close
|
||||
* - list_item_close
|
||||
* - bullet_list_close
|
||||
*
|
||||
* The key insight is that nested lists appear INSIDE the parent list_item,
|
||||
* between the paragraph and the list_item_close.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
describe("Nested Lists - 2 Level Nesting", () => {
|
||||
it("renders bullet items nested inside bullet items with proper indentation", () => {
|
||||
const input = `- Item 1
|
||||
- Nested 1.1
|
||||
- Nested 1.2
|
||||
- Item 2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Expected output:
|
||||
// • Item 1
|
||||
// • Nested 1.1
|
||||
// • Nested 1.2
|
||||
// • Item 2
|
||||
// Note: markdownToIR trims trailing whitespace, so no final newline
|
||||
const expected = `• Item 1
|
||||
• Nested 1.1
|
||||
• Nested 1.2
|
||||
• Item 2`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders ordered items nested inside bullet items", () => {
|
||||
const input = `- Bullet item
|
||||
1. Ordered sub-item 1
|
||||
2. Ordered sub-item 2
|
||||
- Another bullet`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Expected output:
|
||||
// • Bullet item
|
||||
// 1. Ordered sub-item 1
|
||||
// 2. Ordered sub-item 2
|
||||
// • Another bullet
|
||||
const expected = `• Bullet item
|
||||
1. Ordered sub-item 1
|
||||
2. Ordered sub-item 2
|
||||
• Another bullet`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders bullet items nested inside ordered items", () => {
|
||||
const input = `1. Ordered 1
|
||||
- Bullet sub 1
|
||||
- Bullet sub 2
|
||||
2. Ordered 2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Expected output:
|
||||
// 1. Ordered 1
|
||||
// • Bullet sub 1
|
||||
// • Bullet sub 2
|
||||
// 2. Ordered 2
|
||||
const expected = `1. Ordered 1
|
||||
• Bullet sub 1
|
||||
• Bullet sub 2
|
||||
2. Ordered 2`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders ordered items nested inside ordered items", () => {
|
||||
const input = `1. First
|
||||
1. Sub-first
|
||||
2. Sub-second
|
||||
2. Second`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `1. First
|
||||
1. Sub-first
|
||||
2. Sub-second
|
||||
2. Second`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested Lists - 3+ Level Deep Nesting", () => {
|
||||
it("renders 3 levels of bullet nesting", () => {
|
||||
const input = `- Level 1
|
||||
- Level 2
|
||||
- Level 3
|
||||
- Back to 1`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Expected output with progressive indentation:
|
||||
// • Level 1
|
||||
// • Level 2
|
||||
// • Level 3
|
||||
// • Back to 1
|
||||
const expected = `• Level 1
|
||||
• Level 2
|
||||
• Level 3
|
||||
• Back to 1`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders 4 levels of bullet nesting", () => {
|
||||
const input = `- L1
|
||||
- L2
|
||||
- L3
|
||||
- L4
|
||||
- Back`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `• L1
|
||||
• L2
|
||||
• L3
|
||||
• L4
|
||||
• Back`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders 3 levels with multiple items at each level", () => {
|
||||
const input = `- A1
|
||||
- B1
|
||||
- C1
|
||||
- C2
|
||||
- B2
|
||||
- A2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `• A1
|
||||
• B1
|
||||
• C1
|
||||
• C2
|
||||
• B2
|
||||
• A2`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested Lists - Mixed Nesting", () => {
|
||||
it("renders complex mixed nesting (bullet > ordered > bullet)", () => {
|
||||
const input = `- Bullet 1
|
||||
1. Ordered 1.1
|
||||
- Deep bullet
|
||||
2. Ordered 1.2
|
||||
- Bullet 2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `• Bullet 1
|
||||
1. Ordered 1.1
|
||||
• Deep bullet
|
||||
2. Ordered 1.2
|
||||
• Bullet 2`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
|
||||
it("renders ordered > bullet > ordered nesting", () => {
|
||||
const input = `1. First
|
||||
- Sub bullet
|
||||
1. Deep ordered
|
||||
- Another bullet
|
||||
2. Second`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `1. First
|
||||
• Sub bullet
|
||||
1. Deep ordered
|
||||
• Another bullet
|
||||
2. Second`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested Lists - Newline Handling", () => {
|
||||
it("does not produce triple newlines in nested lists", () => {
|
||||
const input = `- Item 1
|
||||
- Nested
|
||||
- Item 2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("does not produce double newlines between nested items", () => {
|
||||
const input = `- A
|
||||
- B
|
||||
- C
|
||||
- D`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Between B and C there should be exactly one newline
|
||||
expect(result.text).toContain(" • B\n • C");
|
||||
expect(result.text).not.toContain(" • B\n\n • C");
|
||||
});
|
||||
|
||||
it("properly terminates top-level list (trimmed output)", () => {
|
||||
const input = `- Item 1
|
||||
- Nested
|
||||
- Item 2`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// markdownToIR trims trailing whitespace, so output should end with Item 2
|
||||
// (no trailing newline after trimming)
|
||||
expect(result.text).toMatch(/Item 2$/);
|
||||
// Should not have excessive newlines before Item 2
|
||||
expect(result.text).not.toContain("\n\n• Item 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested Lists - Edge Cases", () => {
|
||||
it("handles empty parent with nested items", () => {
|
||||
// This is a bit of an edge case - a list item that's just a marker followed by nested content
|
||||
const input = `-
|
||||
- Nested only
|
||||
- Normal`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// Should still render the nested item with proper indentation
|
||||
expect(result.text).toContain(" • Nested only");
|
||||
});
|
||||
|
||||
it("handles nested list as first child of parent item", () => {
|
||||
const input = `- Parent text
|
||||
- Child
|
||||
- Another parent`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
// The child should appear indented under the parent
|
||||
expect(result.text).toContain("• Parent text\n • Child");
|
||||
});
|
||||
|
||||
it("handles sibling nested lists at same level", () => {
|
||||
const input = `- A
|
||||
- A1
|
||||
- B
|
||||
- B1`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
const expected = `• A
|
||||
• A1
|
||||
• B
|
||||
• B1`;
|
||||
|
||||
expect(result.text).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list paragraph spacing", () => {
|
||||
it("preserves paragraph breaks inside loose bullet list items", () => {
|
||||
const input = `- first paragraph
|
||||
|
||||
second paragraph
|
||||
- next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`• first paragraph
|
||||
|
||||
second paragraph
|
||||
|
||||
• next`);
|
||||
});
|
||||
|
||||
it("preserves paragraph breaks inside loose ordered list items", () => {
|
||||
const input = `1. first paragraph
|
||||
|
||||
second paragraph
|
||||
2. next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`1. first paragraph
|
||||
|
||||
second paragraph
|
||||
|
||||
2. next`);
|
||||
});
|
||||
|
||||
it("preserves paragraph breaks inside loose blockquoted list items", () => {
|
||||
const input = `> - first paragraph
|
||||
>
|
||||
> second paragraph
|
||||
> - next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`• first paragraph
|
||||
|
||||
second paragraph
|
||||
|
||||
• next`);
|
||||
});
|
||||
|
||||
it("does not add triple newlines before loose nested bullet lists", () => {
|
||||
const input = `- parent
|
||||
|
||||
- child
|
||||
|
||||
- next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`• parent
|
||||
|
||||
• child
|
||||
• next`);
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("does not add triple newlines before loose nested ordered lists", () => {
|
||||
const input = `1. parent
|
||||
|
||||
1. child
|
||||
|
||||
2. next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`1. parent
|
||||
|
||||
1. child
|
||||
2. next`);
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
|
||||
it("keeps tight heading list items single-spaced", () => {
|
||||
const input = `- # A
|
||||
- # B`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`• A
|
||||
• B`);
|
||||
});
|
||||
|
||||
it("keeps tight blockquote list items single-spaced", () => {
|
||||
const input = `- > quote
|
||||
- next`;
|
||||
|
||||
const result = markdownToIR(input);
|
||||
|
||||
expect(result.text).toBe(`• quote
|
||||
• next`);
|
||||
});
|
||||
|
||||
it("adds blank line between bullet list and following paragraph", () => {
|
||||
const input = `- item 1
|
||||
- item 2
|
||||
|
||||
Paragraph after`;
|
||||
const result = markdownToIR(input);
|
||||
// Should have two newlines between "item 2" and "Paragraph"
|
||||
expect(result.text).toContain("item 2\n\nParagraph");
|
||||
});
|
||||
|
||||
it("adds blank line between ordered list and following paragraph", () => {
|
||||
const input = `1. item 1
|
||||
2. item 2
|
||||
|
||||
Paragraph after`;
|
||||
const result = markdownToIR(input);
|
||||
expect(result.text).toContain("item 2\n\nParagraph");
|
||||
});
|
||||
|
||||
it("does not produce triple newlines", () => {
|
||||
const input = `- item 1
|
||||
- item 2
|
||||
|
||||
Paragraph after`;
|
||||
const result = markdownToIR(input);
|
||||
// Should NOT have three consecutive newlines
|
||||
expect(result.text).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
25
packages/markdown-core/src/ir.raw-html.test.ts
Normal file
25
packages/markdown-core/src/ir.raw-html.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
describe("markdownToIR raw HTML", () => {
|
||||
it("does not linkify URLs inside raw HTML tag attributes", () => {
|
||||
const ir = markdownToIR(
|
||||
'<img src="https://example.com/diagram.png" alt="Diagram"> https://example.com/page',
|
||||
);
|
||||
|
||||
expect(ir.text).toBe(
|
||||
'<img src="https://example.com/diagram.png" alt="Diagram"> https://example.com/page',
|
||||
);
|
||||
expect(ir.links.map((link) => ir.text.slice(link.start, link.end))).toEqual([
|
||||
"https://example.com/page",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat comparison text as a raw HTML tag", () => {
|
||||
const ir = markdownToIR("x < y https://example.com/page");
|
||||
|
||||
expect(ir.links.map((link) => ir.text.slice(link.start, link.end))).toEqual([
|
||||
"https://example.com/page",
|
||||
]);
|
||||
});
|
||||
});
|
||||
32
packages/markdown-core/src/ir.table-block.test.ts
Normal file
32
packages/markdown-core/src/ir.table-block.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Markdown Core tests cover ir.table block behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToIRWithMeta } from "./ir.js";
|
||||
|
||||
describe("markdownToIRWithMeta tableMode block", () => {
|
||||
it("collects table metadata without inlining table text", () => {
|
||||
const { ir, hasTables, tables } = markdownToIRWithMeta(
|
||||
"Before\n\n| Name | Age |\n|---|---|\n| Alice | 30 |\n\nAfter",
|
||||
{ tableMode: "block" },
|
||||
);
|
||||
|
||||
expect(hasTables).toBe(true);
|
||||
expect(tables).toEqual([
|
||||
{
|
||||
headers: ["Name", "Age"],
|
||||
rows: [["Alice", "30"]],
|
||||
headerCells: [
|
||||
{ text: "Name", styles: [], links: [] },
|
||||
{ text: "Age", styles: [], links: [] },
|
||||
],
|
||||
rowCells: [
|
||||
[
|
||||
{ text: "Alice", styles: [], links: [] },
|
||||
{ text: "30", styles: [], links: [] },
|
||||
],
|
||||
],
|
||||
placeholderOffset: ir.text.indexOf("After"),
|
||||
},
|
||||
]);
|
||||
expect(ir.text).toBe("Before\n\nAfter");
|
||||
});
|
||||
});
|
||||
122
packages/markdown-core/src/ir.table-bullets.test.ts
Normal file
122
packages/markdown-core/src/ir.table-bullets.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Markdown Core tests cover ir.table bullets behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
describe("markdownToIR tableMode bullets", () => {
|
||||
it("converts simple table to bullets", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| A | 1 |
|
||||
| B | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
// Should contain bullet points with header:value format
|
||||
expect(ir.text).toContain("• Value: 1");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
// Should use first column as labels
|
||||
expect(ir.text).toContain("A");
|
||||
expect(ir.text).toContain("B");
|
||||
});
|
||||
|
||||
it("handles table with multiple columns", () => {
|
||||
const md = `
|
||||
| Feature | SQLite | Postgres |
|
||||
|---------|--------|----------|
|
||||
| Speed | Fast | Medium |
|
||||
| Scale | Small | Large |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
// First column becomes row label
|
||||
expect(ir.text).toContain("Speed");
|
||||
expect(ir.text).toContain("Scale");
|
||||
// Other columns become bullet points
|
||||
expect(ir.text).toContain("• SQLite: Fast");
|
||||
expect(ir.text).toContain("• Postgres: Medium");
|
||||
expect(ir.text).toContain("• SQLite: Small");
|
||||
expect(ir.text).toContain("• Postgres: Large");
|
||||
});
|
||||
|
||||
it("leaves table syntax untouched by default", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md);
|
||||
|
||||
// No table conversion by default
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.text).not.toContain("•");
|
||||
expect(ir.styles.map((style) => style.style)).not.toContain("code_block");
|
||||
});
|
||||
|
||||
it("handles empty cells gracefully", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| A | |
|
||||
| B | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
// Should handle empty cell without crashing
|
||||
expect(ir.text).toContain("B");
|
||||
expect(ir.text).toContain("• Value: 2");
|
||||
});
|
||||
|
||||
it("bolds row labels in bullets mode", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| Row1 | Data1 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
// Should have bold style for row label
|
||||
expect(
|
||||
ir.styles
|
||||
.filter((style) => style.style === "bold")
|
||||
.map((style) => ir.text.slice(style.start, style.end)),
|
||||
).toContain("Row1");
|
||||
});
|
||||
|
||||
it("renders tables as code blocks in code mode", () => {
|
||||
const md = `
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
expect(ir.text).toContain("| A | B |");
|
||||
expect(ir.text).toContain("| 1 | 2 |");
|
||||
expect(ir.styles.map((style) => style.style)).toContain("code_block");
|
||||
});
|
||||
|
||||
it("preserves inline styles and links in bullets mode", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| _Row_ | [Link](https://example.com) |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "bullets" });
|
||||
|
||||
expect(
|
||||
ir.styles
|
||||
.filter((style) => style.style === "italic")
|
||||
.map((style) => ir.text.slice(style.start, style.end)),
|
||||
).toContain("Row");
|
||||
expect(ir.links.map((link) => link.href)).toContain("https://example.com");
|
||||
});
|
||||
});
|
||||
90
packages/markdown-core/src/ir.table-code.test.ts
Normal file
90
packages/markdown-core/src/ir.table-code.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// Markdown Core tests cover ir.table code behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
|
||||
describe("markdownToIR tableMode code - style overlap", () => {
|
||||
it("should not have overlapping styles when cell has bold text", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| **Bold** | Normal |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
// Check for overlapping styles
|
||||
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
|
||||
const boldSpan = ir.styles.find((s) => s.style === "bold");
|
||||
|
||||
// Either:
|
||||
// 1. There should be no bold spans in code mode (inner styles stripped), OR
|
||||
// 2. If bold spans exist, they should not overlap with code_block span
|
||||
if (codeBlockSpan && boldSpan) {
|
||||
// Check for overlap
|
||||
const overlaps = boldSpan.start < codeBlockSpan.end && boldSpan.end > codeBlockSpan.start;
|
||||
// Overlapping styles are the bug - this should fail until fixed
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not have overlapping styles when cell has italic text", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| *Italic* | Normal |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
|
||||
const italicSpan = ir.styles.find((s) => s.style === "italic");
|
||||
|
||||
if (codeBlockSpan && italicSpan) {
|
||||
const overlaps = italicSpan.start < codeBlockSpan.end && italicSpan.end > codeBlockSpan.start;
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not have overlapping styles when cell has inline code", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| \`code\` | Normal |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
|
||||
const codeSpan = ir.styles.find((s) => s.style === "code");
|
||||
|
||||
if (codeBlockSpan && codeSpan) {
|
||||
const overlaps = codeSpan.start < codeBlockSpan.end && codeSpan.end > codeBlockSpan.start;
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not have overlapping styles with multiple styled cells", () => {
|
||||
const md = `
|
||||
| Name | Value |
|
||||
|------|-------|
|
||||
| **A** | *B* |
|
||||
| _C_ | ~~D~~ |
|
||||
`.trim();
|
||||
|
||||
const ir = markdownToIR(md, { tableMode: "code" });
|
||||
|
||||
const codeBlockSpan = ir.styles.find((s) => s.style === "code_block");
|
||||
if (!codeBlockSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that no non-code_block style overlaps with code_block
|
||||
for (const style of ir.styles) {
|
||||
if (style.style === "code_block") {
|
||||
continue;
|
||||
}
|
||||
const overlaps = style.start < codeBlockSpan.end && style.end > codeBlockSpan.start;
|
||||
expect(overlaps).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
1158
packages/markdown-core/src/ir.ts
Normal file
1158
packages/markdown-core/src/ir.ts
Normal file
File diff suppressed because it is too large
Load Diff
136
packages/markdown-core/src/render-aware-chunking.test.ts
Normal file
136
packages/markdown-core/src/render-aware-chunking.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Markdown Core tests cover render aware chunking behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownIR } from "./ir.js";
|
||||
import { markdownToIR } from "./ir.js";
|
||||
import { renderMarkdownIRChunksWithinLimit } from "./render-aware-chunking.js";
|
||||
import { renderMarkdownWithMarkers } from "./render.js";
|
||||
|
||||
function renderEscapedHtml(ir: MarkdownIR): string {
|
||||
return renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: {
|
||||
bold: { open: "<b>", close: "</b>" },
|
||||
italic: { open: "<i>", close: "</i>" },
|
||||
strikethrough: { open: "<s>", close: "</s>" },
|
||||
code: { open: "<code>", close: "</code>" },
|
||||
code_block: { open: "<pre><code>", close: "</code></pre>" },
|
||||
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
|
||||
blockquote: { open: "<blockquote>", close: "</blockquote>" },
|
||||
},
|
||||
escapeText: (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("renderMarkdownIRChunksWithinLimit", () => {
|
||||
it("prefers word boundaries when escaping shrinks the render budget", () => {
|
||||
const ir = markdownToIR("alpha <<");
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit: 8,
|
||||
renderChunk: renderEscapedHtml,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["alpha ", "<<"]);
|
||||
expect(chunks.map((chunk) => chunk.source.text).join("")).toBe("alpha <<");
|
||||
expect(chunks.every((chunk) => chunk.rendered.length <= 8)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves formatting when a rendered chunk is re-split", () => {
|
||||
const ir = markdownToIR("**Which of these**", {
|
||||
headingStyle: "none",
|
||||
});
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit: 16,
|
||||
renderChunk: renderEscapedHtml,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["Which of ", "these"]);
|
||||
expect(chunks.every((chunk) => chunk.rendered.startsWith("<b>"))).toBe(true);
|
||||
expect(chunks.every((chunk) => chunk.rendered.endsWith("</b>"))).toBe(true);
|
||||
});
|
||||
|
||||
it("checks exact candidates instead of assuming rendered length is monotonic", () => {
|
||||
const ir: MarkdownIR = {
|
||||
text: "README.md<",
|
||||
styles: [],
|
||||
links: [],
|
||||
};
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit: 10,
|
||||
renderChunk: (chunk) =>
|
||||
chunk.text === "README.md"
|
||||
? "fits-here"
|
||||
: chunk.text.startsWith("README.md")
|
||||
? "this-rendering-is-too-long"
|
||||
: chunk.text,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["README.md", "<"]);
|
||||
});
|
||||
|
||||
it("normalizes non-finite limits before chunking", () => {
|
||||
const ir = markdownToIR("abc");
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit: Number.NaN,
|
||||
renderChunk: renderEscapedHtml,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["a", "b", "c"]);
|
||||
expect(chunks.every((chunk) => chunk.rendered.length <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps astral characters whole when a positive limit reaches their pair", () => {
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir: markdownToIR("A😀B"),
|
||||
limit: 1,
|
||||
renderChunk: (chunk) => chunk.text,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["A", "😀", "B"]);
|
||||
});
|
||||
|
||||
it("keeps astral characters whole when rendered size requires a retry split", () => {
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir: markdownToIR("A😀"),
|
||||
limit: 3,
|
||||
renderChunk: (chunk) => (chunk.text === "A😀" ? "too long" : chunk.text),
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text)).toEqual(["A", "😀"]);
|
||||
});
|
||||
|
||||
it("keeps split order while processing the worklist as a stack", () => {
|
||||
const text = "abcdefghijklmnopqrstuvwx";
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir: markdownToIR(text),
|
||||
limit: 5,
|
||||
renderChunk: (chunk) => chunk.text,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks.map((chunk) => chunk.source.text).join("")).toBe(text);
|
||||
expect(chunks.every((chunk) => chunk.rendered.length <= 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats Infinity as no size cap and returns a single chunk", () => {
|
||||
const text = "one two three four five six seven eight nine ten";
|
||||
const ir = markdownToIR(text);
|
||||
const chunks = renderMarkdownIRChunksWithinLimit({
|
||||
ir,
|
||||
limit: Number.POSITIVE_INFINITY,
|
||||
renderChunk: renderEscapedHtml,
|
||||
measureRendered: (rendered) => rendered.length,
|
||||
});
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]?.source.text).toBe(text);
|
||||
});
|
||||
});
|
||||
370
packages/markdown-core/src/render-aware-chunking.ts
Normal file
370
packages/markdown-core/src/render-aware-chunking.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js";
|
||||
// Markdown Core module implements render aware chunking behavior.
|
||||
import {
|
||||
chunkMarkdownIR,
|
||||
sliceMarkdownIR,
|
||||
type MarkdownIR,
|
||||
type MarkdownLinkSpan,
|
||||
type MarkdownStyleSpan,
|
||||
} from "./ir.js";
|
||||
|
||||
/** A rendered chunk paired with the Markdown IR slice that produced it. */
|
||||
export type RenderedMarkdownChunk<TRendered> = {
|
||||
/** Rendered payload for this chunk after caller-specific escaping/link rewriting. */
|
||||
rendered: TRendered;
|
||||
/** Source IR slice used to produce the rendered payload. */
|
||||
source: MarkdownIR;
|
||||
};
|
||||
|
||||
/** Inputs for chunking Markdown IR against the final rendered payload size. */
|
||||
export type RenderMarkdownIRChunksWithinLimitOptions<TRendered> = {
|
||||
/** Parsed Markdown IR to split. */
|
||||
ir: MarkdownIR;
|
||||
/** Maximum measured size for each rendered chunk. */
|
||||
limit: number;
|
||||
/** Returns the size unit enforced by the target transport. */
|
||||
measureRendered: (rendered: TRendered) => number;
|
||||
/** Renders a candidate IR slice for measuring and final output. */
|
||||
renderChunk: (ir: MarkdownIR) => TRendered;
|
||||
};
|
||||
|
||||
type RenderResolver<TRendered> = Pick<
|
||||
RenderMarkdownIRChunksWithinLimitOptions<TRendered>,
|
||||
"measureRendered" | "renderChunk"
|
||||
>;
|
||||
|
||||
function resolveIntegerOption(value: number, fallback: number, opts: { min: number }): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(opts.min, Math.trunc(value));
|
||||
}
|
||||
|
||||
/** Chunks Markdown IR by rendered size while preserving styles, links, and whitespace. */
|
||||
export function renderMarkdownIRChunksWithinLimit<TRendered>(
|
||||
options: RenderMarkdownIRChunksWithinLimitOptions<TRendered>,
|
||||
): RenderedMarkdownChunk<TRendered>[] {
|
||||
if (!options.ir.text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Callers pass Infinity to mean "no size cap" (e.g. a media caption that must not be
|
||||
// split). resolveIntegerOption rejects non-finite values and would fall back to 1,
|
||||
// shattering the text into one chunk per character; emit the whole IR as one chunk.
|
||||
if (options.limit === Number.POSITIVE_INFINITY) {
|
||||
return [{ source: options.ir, rendered: options.renderChunk(options.ir) }];
|
||||
}
|
||||
|
||||
const normalizedLimit = resolveIntegerOption(options.limit, 1, { min: 1 });
|
||||
// Treat the pending worklist as a stack so each dequeue/enqueue stays O(1).
|
||||
// The initial reverse keeps the final order stable while avoiding shift/unshift
|
||||
// moving every remaining chunk for long messages.
|
||||
const pending = chunkMarkdownIR(options.ir, normalizedLimit).toReversed();
|
||||
const finalized: MarkdownIR[] = [];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const chunk = pending.pop();
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rendered = options.renderChunk(chunk);
|
||||
if (options.measureRendered(rendered) <= normalizedLimit || chunk.text.length <= 1) {
|
||||
finalized.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = splitMarkdownIRByRenderedLimit(chunk, normalizedLimit, options);
|
||||
if (split.length <= 1) {
|
||||
// Worst-case safety: avoid retry loops and keep the original chunk.
|
||||
finalized.push(chunk);
|
||||
continue;
|
||||
}
|
||||
for (let index = split.length - 1; index >= 0; index -= 1) {
|
||||
const next = split[index];
|
||||
if (next) {
|
||||
pending.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit, options).map(
|
||||
(source) => ({
|
||||
source,
|
||||
rendered: options.renderChunk(source),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function splitMarkdownIRByRenderedLimit<TRendered>(
|
||||
chunk: MarkdownIR,
|
||||
renderedLimit: number,
|
||||
options: RenderResolver<TRendered>,
|
||||
): MarkdownIR[] {
|
||||
const currentTextLength = chunk.text.length;
|
||||
if (currentTextLength <= 1) {
|
||||
return [chunk];
|
||||
}
|
||||
|
||||
const splitLimit = findLargestChunkTextLengthWithinRenderedLimit(chunk, renderedLimit, options);
|
||||
if (splitLimit <= 0) {
|
||||
return [chunk];
|
||||
}
|
||||
|
||||
const split = splitMarkdownIRPreserveWhitespace(chunk, splitLimit);
|
||||
const firstChunk = split[0];
|
||||
if (firstChunk && options.measureRendered(options.renderChunk(firstChunk)) <= renderedLimit) {
|
||||
return split;
|
||||
}
|
||||
|
||||
return [
|
||||
sliceMarkdownIR(chunk, 0, splitLimit),
|
||||
sliceMarkdownIR(chunk, splitLimit, currentTextLength),
|
||||
];
|
||||
}
|
||||
|
||||
function findLargestChunkTextLengthWithinRenderedLimit<TRendered>(
|
||||
chunk: MarkdownIR,
|
||||
renderedLimit: number,
|
||||
options: RenderResolver<TRendered>,
|
||||
): number {
|
||||
const currentTextLength = chunk.text.length;
|
||||
if (currentTextLength <= 1) {
|
||||
return currentTextLength;
|
||||
}
|
||||
|
||||
// Rendered length is not guaranteed to be monotonic after escaping/link or
|
||||
// file-reference rewriting, so test exact candidates from longest to shortest.
|
||||
for (let candidateLength = currentTextLength - 1; candidateLength >= 1; candidateLength -= 1) {
|
||||
const safeCandidateLength = avoidTrailingHighSurrogateBreak(chunk.text, 0, candidateLength);
|
||||
const candidate = sliceMarkdownIR(chunk, 0, safeCandidateLength);
|
||||
const rendered = options.renderChunk(candidate);
|
||||
if (options.measureRendered(rendered) <= renderedLimit) {
|
||||
return safeCandidateLength;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: number): number {
|
||||
const maxEnd = Math.min(text.length, start + limit);
|
||||
if (maxEnd >= text.length) {
|
||||
return text.length;
|
||||
}
|
||||
|
||||
let lastOutsideParenNewlineBreak = -1;
|
||||
let lastOutsideParenWhitespaceBreak = -1;
|
||||
let lastOutsideParenWhitespaceRunStart = -1;
|
||||
let lastAnyNewlineBreak = -1;
|
||||
let lastAnyWhitespaceBreak = -1;
|
||||
let lastAnyWhitespaceRunStart = -1;
|
||||
let parenDepth = 0;
|
||||
let sawNonWhitespace = false;
|
||||
|
||||
for (let index = start; index < maxEnd; index += 1) {
|
||||
const char = text[index];
|
||||
// Parenthesized text often carries rewritten file/link references; prefer
|
||||
// keeping it intact unless no outside break exists in the current window.
|
||||
if (char === "(") {
|
||||
sawNonWhitespace = true;
|
||||
parenDepth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ")" && parenDepth > 0) {
|
||||
sawNonWhitespace = true;
|
||||
parenDepth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (!/\s/.test(char)) {
|
||||
sawNonWhitespace = true;
|
||||
continue;
|
||||
}
|
||||
if (!sawNonWhitespace) {
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
// Newlines preserve markdown block structure better than other spaces.
|
||||
lastAnyNewlineBreak = index + 1;
|
||||
if (parenDepth === 0) {
|
||||
lastOutsideParenNewlineBreak = index + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const whitespaceRunStart =
|
||||
index === start || !/\s/.test(text[index - 1] ?? "") ? index : lastAnyWhitespaceRunStart;
|
||||
lastAnyWhitespaceBreak = index + 1;
|
||||
lastAnyWhitespaceRunStart = whitespaceRunStart;
|
||||
if (parenDepth === 0) {
|
||||
lastOutsideParenWhitespaceBreak = index + 1;
|
||||
lastOutsideParenWhitespaceRunStart = whitespaceRunStart;
|
||||
}
|
||||
}
|
||||
|
||||
const resolveWhitespaceBreak = (breakIndex: number, runStart: number): number => {
|
||||
if (breakIndex <= start) {
|
||||
return breakIndex;
|
||||
}
|
||||
if (runStart <= start) {
|
||||
return breakIndex;
|
||||
}
|
||||
return /\s/.test(text[breakIndex] ?? "") ? runStart : breakIndex;
|
||||
};
|
||||
|
||||
if (lastOutsideParenNewlineBreak > start) {
|
||||
return lastOutsideParenNewlineBreak;
|
||||
}
|
||||
if (lastOutsideParenWhitespaceBreak > start) {
|
||||
return resolveWhitespaceBreak(
|
||||
lastOutsideParenWhitespaceBreak,
|
||||
lastOutsideParenWhitespaceRunStart,
|
||||
);
|
||||
}
|
||||
if (lastAnyNewlineBreak > start) {
|
||||
return lastAnyNewlineBreak;
|
||||
}
|
||||
if (lastAnyWhitespaceBreak > start) {
|
||||
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
|
||||
}
|
||||
return avoidTrailingHighSurrogateBreak(text, start, maxEnd);
|
||||
}
|
||||
|
||||
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
|
||||
if (!ir.text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedLimit = resolveIntegerOption(limit, 1, { min: 1 });
|
||||
if (normalizedLimit <= 0 || ir.text.length <= normalizedLimit) {
|
||||
return [ir];
|
||||
}
|
||||
|
||||
const chunks: MarkdownIR[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < ir.text.length) {
|
||||
const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
|
||||
chunks.push(sliceMarkdownIR(ir, cursor, end));
|
||||
cursor = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function mergeAdjacentStyleSpans(styles: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
||||
const merged: MarkdownStyleSpan[] = [];
|
||||
for (const span of styles) {
|
||||
const last = merged.at(-1);
|
||||
if (
|
||||
last &&
|
||||
last.style === span.style &&
|
||||
last.language === span.language &&
|
||||
span.start <= last.end
|
||||
) {
|
||||
last.end = Math.max(last.end, span.end);
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...span });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeAdjacentLinkSpans(links: MarkdownLinkSpan[]): MarkdownLinkSpan[] {
|
||||
const merged: MarkdownLinkSpan[] = [];
|
||||
for (const link of links) {
|
||||
const last = merged.at(-1);
|
||||
if (last && last.href === link.href && link.start <= last.end) {
|
||||
last.end = Math.max(last.end, link.end);
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...link });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR {
|
||||
const offset = left.text.length;
|
||||
return {
|
||||
text: left.text + right.text,
|
||||
styles: mergeAdjacentStyleSpans([
|
||||
...left.styles,
|
||||
...right.styles.map((span) => ({
|
||||
...span,
|
||||
start: span.start + offset,
|
||||
end: span.end + offset,
|
||||
})),
|
||||
]),
|
||||
links: mergeAdjacentLinkSpans([
|
||||
...left.links,
|
||||
...right.links.map((link) => ({
|
||||
...link,
|
||||
start: link.start + offset,
|
||||
end: link.end + offset,
|
||||
})),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
function coalesceWhitespaceOnlyMarkdownIRChunks<TRendered>(
|
||||
chunks: MarkdownIR[],
|
||||
renderedLimit: number,
|
||||
options: RenderResolver<TRendered>,
|
||||
): MarkdownIR[] {
|
||||
const coalesced: MarkdownIR[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < chunks.length) {
|
||||
const chunk = chunks[index];
|
||||
if (!chunk) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (chunk.text.trim().length > 0) {
|
||||
coalesced.push(chunk);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const prev = coalesced.at(-1);
|
||||
const next = chunks[index + 1];
|
||||
const chunkLength = chunk.text.length;
|
||||
|
||||
const canMerge = (candidate: MarkdownIR) =>
|
||||
options.measureRendered(options.renderChunk(candidate)) <= renderedLimit;
|
||||
|
||||
if (prev) {
|
||||
const mergedPrev = mergeMarkdownIRChunks(prev, chunk);
|
||||
if (canMerge(mergedPrev)) {
|
||||
coalesced[coalesced.length - 1] = mergedPrev;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (next) {
|
||||
const mergedNext = mergeMarkdownIRChunks(chunk, next);
|
||||
if (canMerge(mergedNext)) {
|
||||
chunks[index + 1] = mergedNext;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (prev && next) {
|
||||
// Split pure whitespace between neighbors before dropping it so list,
|
||||
// paragraph, and quote spacing survives when both sides still fit.
|
||||
for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) {
|
||||
const prefix = sliceMarkdownIR(chunk, 0, prefixLength);
|
||||
const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength);
|
||||
const mergedPrev = mergeMarkdownIRChunks(prev, prefix);
|
||||
const mergedNext = mergeMarkdownIRChunks(suffix, next);
|
||||
if (canMerge(mergedPrev) && canMerge(mergedNext)) {
|
||||
coalesced[coalesced.length - 1] = mergedPrev;
|
||||
chunks[index + 1] = mergedNext;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return coalesced;
|
||||
}
|
||||
208
packages/markdown-core/src/render.ts
Normal file
208
packages/markdown-core/src/render.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Markdown Core module implements render behavior.
|
||||
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
|
||||
|
||||
/** Marker pair used to wrap a styled Markdown span in the target renderer. */
|
||||
export type RenderStyleMarker = {
|
||||
open: string | ((span: MarkdownStyleSpan) => string);
|
||||
close: string;
|
||||
};
|
||||
|
||||
/** Optional marker map; omitted styles are emitted as plain escaped text. */
|
||||
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
|
||||
|
||||
/** Link wrapper boundaries after a renderer has accepted or rewritten a link span. */
|
||||
export type RenderLink = {
|
||||
start: number;
|
||||
end: number;
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
|
||||
/** Renderer hooks for converting Markdown IR into a marker-based target format. */
|
||||
export type RenderOptions = {
|
||||
styleMarkers: RenderStyleMap;
|
||||
escapeText: (text: string) => string;
|
||||
buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
|
||||
};
|
||||
|
||||
const STYLE_ORDER: MarkdownStyle[] = [
|
||||
"blockquote",
|
||||
"code_block",
|
||||
"code",
|
||||
"heading_1",
|
||||
"heading_2",
|
||||
"heading_3",
|
||||
"heading_4",
|
||||
"heading_5",
|
||||
"heading_6",
|
||||
"bold",
|
||||
"italic",
|
||||
"strikethrough",
|
||||
"spoiler",
|
||||
];
|
||||
|
||||
const STYLE_RANK = new Map<MarkdownStyle, number>(
|
||||
STYLE_ORDER.map((style, index) => [style, index]),
|
||||
);
|
||||
|
||||
function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
||||
return [...spans].toSorted((a, b) => {
|
||||
if (a.start !== b.start) {
|
||||
return a.start - b.start;
|
||||
}
|
||||
if (a.end !== b.end) {
|
||||
return b.end - a.end;
|
||||
}
|
||||
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders Markdown IR by nesting configured style markers and optional link markers. */
|
||||
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
|
||||
const text = ir.text ?? "";
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const styleMarkers = options.styleMarkers;
|
||||
const styled = sortStyleSpans(ir.styles.filter((span) => Boolean(styleMarkers[span.style])));
|
||||
|
||||
const boundaries = new Set<number>();
|
||||
boundaries.add(0);
|
||||
boundaries.add(text.length);
|
||||
|
||||
const startsAt = new Map<number, MarkdownStyleSpan[]>();
|
||||
for (const span of styled) {
|
||||
if (span.start === span.end) {
|
||||
continue;
|
||||
}
|
||||
boundaries.add(span.start);
|
||||
boundaries.add(span.end);
|
||||
const bucket = startsAt.get(span.start);
|
||||
if (bucket) {
|
||||
bucket.push(span);
|
||||
} else {
|
||||
startsAt.set(span.start, [span]);
|
||||
}
|
||||
}
|
||||
for (const spans of startsAt.values()) {
|
||||
spans.sort((a, b) => {
|
||||
if (a.end !== b.end) {
|
||||
return b.end - a.end;
|
||||
}
|
||||
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
const linkStarts = new Map<number, RenderLink[]>();
|
||||
if (options.buildLink) {
|
||||
for (const link of ir.links) {
|
||||
if (link.start === link.end) {
|
||||
continue;
|
||||
}
|
||||
const rendered = options.buildLink(link, text);
|
||||
if (!rendered) {
|
||||
continue;
|
||||
}
|
||||
boundaries.add(rendered.start);
|
||||
boundaries.add(rendered.end);
|
||||
const openBucket = linkStarts.get(rendered.start);
|
||||
if (openBucket) {
|
||||
openBucket.push(rendered);
|
||||
} else {
|
||||
linkStarts.set(rendered.start, [rendered]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const points = [...boundaries].toSorted((a, b) => a - b);
|
||||
// Links and styles share one stack so equal-end spans close in exact reverse open order.
|
||||
const stack: { close: string; end: number }[] = [];
|
||||
type OpeningItem =
|
||||
| { end: number; open: string; close: string; kind: "link"; index: number }
|
||||
| {
|
||||
end: number;
|
||||
open: string;
|
||||
close: string;
|
||||
kind: "style";
|
||||
style: MarkdownStyle;
|
||||
index: number;
|
||||
};
|
||||
let out = "";
|
||||
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
const pos = points[i];
|
||||
|
||||
// Close all elements at this boundary before opening replacements at the same offset.
|
||||
while (stack.length && stack[stack.length - 1]?.end === pos) {
|
||||
const item = stack.pop();
|
||||
if (item) {
|
||||
out += item.close;
|
||||
}
|
||||
}
|
||||
|
||||
const openingItems: OpeningItem[] = [];
|
||||
|
||||
const openingLinks = linkStarts.get(pos);
|
||||
if (openingLinks && openingLinks.length > 0) {
|
||||
for (const [index, link] of openingLinks.entries()) {
|
||||
openingItems.push({
|
||||
end: link.end,
|
||||
open: link.open,
|
||||
close: link.close,
|
||||
kind: "link",
|
||||
index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const openingStyles = startsAt.get(pos);
|
||||
if (openingStyles) {
|
||||
for (const [index, span] of openingStyles.entries()) {
|
||||
const marker = styleMarkers[span.style];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
openingItems.push({
|
||||
end: span.end,
|
||||
open: typeof marker.open === "function" ? marker.open(span) : marker.open,
|
||||
close: marker.close,
|
||||
kind: "style",
|
||||
style: span.style,
|
||||
index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (openingItems.length > 0) {
|
||||
openingItems.sort((a, b) => {
|
||||
if (a.end !== b.end) {
|
||||
return b.end - a.end;
|
||||
}
|
||||
if (a.kind !== b.kind) {
|
||||
return a.kind === "link" ? -1 : 1;
|
||||
}
|
||||
if (a.kind === "style" && b.kind === "style") {
|
||||
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
||||
}
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
// Open outer spans first (larger end) so LIFO closes stay valid for same-start overlaps.
|
||||
for (const item of openingItems) {
|
||||
out += item.open;
|
||||
stack.push({ close: item.close, end: item.end });
|
||||
}
|
||||
}
|
||||
|
||||
const next = points[i + 1];
|
||||
if (next === undefined) {
|
||||
break;
|
||||
}
|
||||
if (next > pos) {
|
||||
out += options.escapeText(text.slice(pos, next));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
11
packages/markdown-core/src/tables.test.ts
Normal file
11
packages/markdown-core/src/tables.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Markdown Core tests cover tables behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertMarkdownTables } from "./tables.js";
|
||||
|
||||
describe("convertMarkdownTables", () => {
|
||||
it("falls back to code rendering for block mode", () => {
|
||||
const rendered = convertMarkdownTables("| A | B |\n|---|---|\n| 1 | 2 |", "block");
|
||||
|
||||
expect(rendered).toBe("```\n| A | B |\n| --- | --- |\n| 1 | 2 |\n```");
|
||||
});
|
||||
});
|
||||
45
packages/markdown-core/src/tables.ts
Normal file
45
packages/markdown-core/src/tables.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Markdown Core module implements tables behavior.
|
||||
import { markdownToIRWithMeta } from "./ir.js";
|
||||
import { renderMarkdownWithMarkers } from "./render.js";
|
||||
import type { MarkdownTableMode } from "./types.js";
|
||||
|
||||
const MARKDOWN_STYLE_MARKERS = {
|
||||
bold: { open: "**", close: "**" },
|
||||
italic: { open: "_", close: "_" },
|
||||
strikethrough: { open: "~~", close: "~~" },
|
||||
code: { open: "`", close: "`" },
|
||||
code_block: { open: "```\n", close: "```" },
|
||||
} as const;
|
||||
|
||||
/** Converts markdown tables into the configured plaintext/code rendering mode. */
|
||||
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
|
||||
if (!markdown || mode === "off") {
|
||||
return markdown;
|
||||
}
|
||||
const effectiveMode = mode === "block" ? "code" : mode;
|
||||
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
|
||||
linkify: false,
|
||||
autolink: false,
|
||||
headingStyle: "none",
|
||||
blockquotePrefix: "",
|
||||
tableMode: effectiveMode,
|
||||
});
|
||||
if (!hasTables) {
|
||||
return markdown;
|
||||
}
|
||||
return renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: MARKDOWN_STYLE_MARKERS,
|
||||
escapeText: (text) => text,
|
||||
buildLink: (link, text) => {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
return null;
|
||||
}
|
||||
const label = text.slice(link.start, link.end);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
return { start: link.start, end: link.end, open: "[", close: `](${href})` };
|
||||
},
|
||||
});
|
||||
}
|
||||
2
packages/markdown-core/src/types.ts
Normal file
2
packages/markdown-core/src/types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Table rendering modes used when markdown tables need plaintext-safe output. */
|
||||
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
|
||||
Reference in New Issue
Block a user