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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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"
}
}

View 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;
}

View 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);
}

View 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);
});
});

View 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);
}

View 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");
});
});

View 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;
}

View 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";

View 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");
});
});

View 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"]);
});
});

View 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("───");
});
});
});

View 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");
});
});

View 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",
]);
});
});

View 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");
});
});

View 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");
});
});

View 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);
}
});
});

File diff suppressed because it is too large Load Diff

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"),
});
}
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);
});
});

View 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;
}

View 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;
}

View 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```");
});
});

View 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})` };
},
});
}

View File

@@ -0,0 +1,2 @@
/** Table rendering modes used when markdown tables need plaintext-safe output. */
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";