import type { BlockHandler } from "./handler.type.js"; /** Block chunk identified during first-pass line scanning. */ export interface BlockChunk { type: string; raw: string; attrs?: Record; } export const ITEM_SEPARATOR = "\x1E"; /** * Handler registry reference — set once by registry.ts to avoid circular imports. * identifyBlocks() consults this to find handlers with identify(). */ let handlerRegistry: Record | null = null; /** Called by registry.ts to wire up the shared handler map. */ export function setHandlerRegistry(registry: Record): void { handlerRegistry = registry; } /** * Handler resolver — set once by registry.ts. * Provides the full resolveHandler logic (incl. lazy widget/html creation) * without requiring a direct import from registry.ts. */ let handlerResolver: ((type: string) => BlockHandler | undefined) | null = null; /** Called by registry.ts to wire up the resolver function. */ export function setHandlerResolver(resolver: (type: string) => BlockHandler | undefined): void { handlerResolver = resolver; } /** Resolve a block handler by type. Used by handlers that render nested blocks. */ export function resolveBlockHandler(type: string): BlockHandler | undefined { return handlerResolver?.(type); } /** * Split raw markdown into block chunks — type + raw content. * Single-pass line-based scanner, no recursion. */ export function identifyBlocks(markdown: string): BlockChunk[] { const lines = markdown.split("\n"); const chunks: BlockChunk[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.trim() === "") { i++; continue; } // Try custom identifiers before built-in detection const custom = tryCustomIdentifiers(lines, i); if (custom) { chunks.push(custom.chunk); i = custom.nextIndex; continue; } const headingLevel = detectHeading(line); if (headingLevel > 0) { const content = line .trimStart() .slice(headingLevel) .trim() .replace(/\s+#+\s*$/, ""); chunks.push({ type: "heading", raw: content, attrs: { level: String(headingLevel) }, }); i++; continue; } const fence = detectFence(line); if (fence) { const [fenceChar, fenceLen, language, fenceIndent] = fence; const contentLines: string[] = []; i++; while (i < lines.length) { if (isClosingFence(lines[i], fenceChar, fenceLen)) { i++; break; } contentLines.push(stripFenceIndent(lines[i], fenceIndent)); i++; } const info = parseInfoString(language); if (info.language?.startsWith("widget:")) { const widgetName = info.language.slice(7); const raw = contentLines.join("\n").trim(); const attrs = Object.fromEntries( Object.entries(parseJsonBody(raw)).map(([k, v]) => [camelToKebab(k), v]), ); chunks.push({ type: `widget:${widgetName}`, raw, attrs }); continue; } if (info.language?.startsWith("html:")) { const raw = contentLines.join("\n").trim(); const attrs = parseJsonBody(raw); chunks.push({ type: info.language, raw, attrs }); continue; } if (info.language === "table") { const raw = contentLines.join("\n").trim(); chunks.push({ type: "table", raw }); continue; } if (info.language === "router-slot") { const raw = contentLines.join("\n").trim(); const attrs = Object.fromEntries( Object.entries(parseJsonBody(raw)).map(([k, v]) => [camelToKebab(k), v]), ); chunks.push({ type: "router-slot", raw, attrs }); continue; } const attrs: Record = { ...info.meta }; if (info.language) attrs.language = info.language; chunks.push({ type: "code-block", raw: contentLines.join("\n"), attrs }); continue; } if (isHorizontalRule(line)) { chunks.push({ type: "hr", raw: "" }); i++; continue; } if (line.trimStart().startsWith(">")) { const innerLines: string[] = []; while (i < lines.length) { const cur = lines[i].trimStart(); // Any `>`-prefixed line, with or without the space after it // (`>quoted` is valid). Matching the same condition that entered // this branch guarantees the first iteration advances `i`. if (cur.startsWith(">")) { const rest = cur.slice(1); innerLines.push(rest.startsWith(" ") ? rest.slice(1) : rest); i++; } else if ( lines[i].trim() === "" && i + 1 < lines.length && lines[i + 1].trimStart().startsWith(">") ) { innerLines.push(""); i++; } else { break; } } chunks.push({ type: "blockquote", raw: innerLines.join("\n") }); continue; } const listStart = detectListItem(line); if (listStart) { const isOrdered = listStart.ordered; const startNum = listStart.start; const items: string[] = []; let currentItemLines: string[] = []; let column = 0; while (i < lines.length) { const cur = lines[i]; if (cur.trim() === "") { if (i + 1 < lines.length) { const nextMarker = detectListItem(lines[i + 1]); const nextInItem = currentItemLines.length > 0 && indentWidth(lines[i + 1]) >= column; if ((nextMarker && nextMarker.ordered === isOrdered) || nextInItem) { currentItemLines.push(""); i++; continue; } } break; } if (currentItemLines.length > 0 && indentWidth(cur) >= column) { currentItemLines.push(cur); i++; continue; } const item = detectListItem(cur); if (item && item.ordered === isOrdered) { if (currentItemLines.length > 0) { items.push(currentItemLines.join("\n")); } currentItemLines = [item.content]; column = item.column; i++; } else if (isLazyContinuation(currentItemLines, cur)) { currentItemLines.push(cur); i++; } else { break; } } if (currentItemLines.length > 0) { items.push(currentItemLines.join("\n")); } const attrs: Record = { "list-type": isOrdered ? "ordered" : "unordered", }; if (isOrdered && startNum !== 1) attrs.start = String(startNum); chunks.push({ type: "list", raw: items.join(ITEM_SEPARATOR), attrs, }); continue; } const paraLines: string[] = []; let setextLevel: number | null = null; while (i < lines.length) { const cur = lines[i]; if (cur.trim() === "") break; if (paraLines.length === 1) { const level = detectSetextUnderline(cur); if (level !== null) { setextLevel = level; i++; break; } } // The boundary test only applies once the paragraph has content. // `isBlockBoundary` is looser than the detectors above (it accepts // `#hello`, `####### x`, `>x`), so a line it claims but no detector // consumes would otherwise leave `i` unmoved and spin forever. if (paraLines.length > 0 && isBlockBoundary(cur)) break; paraLines.push(cur); i++; } if (setextLevel !== null) { chunks.push({ type: "heading", raw: paraLines[0].trim(), attrs: { level: String(setextLevel) }, }); } else if (paraLines.length > 0) { chunks.push({ type: "paragraph", raw: paraLines.join("\n") }); } else { // Defensive: nothing consumed this line, so force progress. i++; } } return chunks; } // -- Custom identifier dispatch ---------------------------------------------- function tryCustomIdentifiers( lines: string[], index: number, ): { chunk: BlockChunk; nextIndex: number } | null { if (!handlerRegistry) return null; for (const [type, handler] of Object.entries(handlerRegistry)) { if (!handler.identify) continue; const result = handler.identify(lines, index); if (result) { // A handler that fails to advance would hang the scanner, and one that // returns an out-of-range index would crash it. Third-party handlers // are a documented extension point, so the contract is enforced here // rather than trusted. if ( !Number.isInteger(result.nextIndex) || result.nextIndex <= index || result.nextIndex > lines.length ) { throw new RangeError( `Block handler "${type}" returned nextIndex ${result.nextIndex} ` + `for line ${index}; it must be an integer in ` + `(${index}, ${lines.length}].`, ); } return { chunk: { type, raw: result.raw, attrs: result.attrs }, nextIndex: result.nextIndex, }; } } return null; } // -- Detection helpers ------------------------------------------------------- function detectHeading(line: string): number { const trimmed = line.trimStart(); let level = 0; for (const c of trimmed) { if (c === "#") level++; else break; } if (level === 0 || level > 6) return 0; const rest = trimmed.slice(level); if (rest !== "" && !rest.startsWith(" ")) return 0; return level; } function detectFence(line: string): [string, number, string | null, number] | null { let indent = 0; while (indent < line.length && line[indent] === " ") indent++; if (indent > 3) return null; const rest = line.slice(indent).trimEnd(); let char = ""; let len = 0; for (const c of rest) { if (len === 0) { if (c === "`" || c === "~") { char = c; len = 1; } else return null; } else if (c === char) { len++; } else break; } if (len < 3) return null; const lang = rest.slice(len).trim(); if (char === "`" && lang.includes("`")) return null; return [char, len, lang || null, indent]; } function isClosingFence(line: string, fenceChar: string, minLen: number): boolean { let indent = 0; while (indent < line.length && line[indent] === " ") indent++; if (indent > 3) return false; const rest = line.slice(indent).trimEnd(); if (rest.length < minLen) return false; let count = 0; for (const c of rest) { if (c === fenceChar) count++; else return false; } return count >= minLen; } function stripFenceIndent(line: string, indent: number): string { let i = 0; while (i < indent && i < line.length && line[i] === " ") i++; return line.slice(i); } function isHorizontalRule(line: string): boolean { const trimmed = line.trim(); if (trimmed.length < 3) return false; const chars: string[] = []; for (const c of trimmed) { if (c !== " ") chars.push(c); } if (chars.length < 3) return false; const first = chars[0]; if (first !== "-" && first !== "_" && first !== "*") return false; return chars.every((c) => c === first); } interface ListItemStart { ordered: boolean; start: number; column: number; content: string; } function detectListItem(line: string): ListItemStart | null { const trimmed = line.trimStart(); const indent = line.length - trimmed.length; if (indent > 3) return null; let markerWidth = 0; let ordered = false; let start = 1; if (/^[-*+](?: |$)/.test(trimmed)) { markerWidth = 1; } else { let numStr = ""; for (let i = 0; i < trimmed.length; i++) { const c = trimmed[i]; if (c >= "0" && c <= "9") { numStr += c; if (numStr.length > 9) break; } else if (c === "." && numStr !== "") { if (i + 1 === trimmed.length || trimmed[i + 1] === " ") { ordered = true; start = parseInt(numStr, 10); markerWidth = i + 1; } break; } else break; } if (markerWidth === 0) return null; } const rest = trimmed.slice(markerWidth); let run = 0; while (run < rest.length && rest[run] === " ") run++; const spaces = run === rest.length || run > 4 ? 1 : run; const content = run === rest.length ? "" : rest.slice(spaces); const column = indent + markerWidth + spaces; return { ordered, start, column, content }; } function detectListMarker(line: string): [boolean, number] | null { const item = detectListItem(line); return item ? [item.ordered, item.start] : null; } export function indentWidth(line: string): number { let width = 0; for (const c of line) { if (c === " ") width++; else if (c === "\t") width += 4 - (width % 4); else break; } return width; } function detectSetextUnderline(line: string): number | null { const trimmed = line.trimStart(); if (line.length - trimmed.length > 3) return null; const body = trimmed.trimEnd(); if (body === "") return null; if (/^=+$/.test(body)) return 1; if (/^-+$/.test(body)) return 2; return null; } function isEmptyListItem(line: string): boolean { const trimmed = line.trim(); return /^[-*+]$/.test(trimmed) || /^\d{1,9}\.$/.test(trimmed); } export function leavesParagraphOpen(line: string): boolean { if (line.trim() === "") return false; if (detectHeading(line) > 0) return false; if (detectFence(line)) return false; if (isHorizontalRule(line)) return false; const item = detectListItem(line); if (item) return item.content.trim() !== ""; return line.trim() !== ">"; } function isLazyContinuation(itemLines: readonly string[], line: string): boolean { if (itemLines.length === 0) return false; if (isBlockBoundary(line)) return false; return leavesParagraphOpen(itemLines[itemLines.length - 1]); } export function isBlockBoundary(line: string): boolean { const trimmed = line.trimStart(); if (trimmed.startsWith("#")) return true; if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) return true; if (trimmed.startsWith(">")) return true; if (detectListMarker(line) && !isEmptyListItem(line)) return true; return isHorizontalRule(line); } /** Convert camelCase to kebab-case. */ function camelToKebab(s: string): string { return s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); } /** HTML spec: attribute names must not contain these characters. */ const INVALID_ATTR_NAME = /[\s"'>/=\x00-\x1f\x7f-\x9f]/; /** Parse a JSON string into a flat string-valued attribute map. */ function parseJsonBody(raw: string): Record { if (!raw) return {}; try { const json = JSON.parse(raw); return Object.fromEntries( Object.entries(json) .filter(([k]) => k.length > 0 && !INVALID_ATTR_NAME.test(k)) .map(([k, v]) => [k, String(v)]), ); } catch { return {}; } } /** * Parse fence info string into language + key=value metadata. * * Examples: * "typescript" → { language: "typescript", meta: {} } * "typescript filepath=src/greet.ts" → { language: "typescript", meta: { filepath: "src/greet.ts" } } * 'ts filepath="path with spaces"' → { language: "ts", meta: { filepath: "path with spaces" } } * "" → { language: null, meta: {} } */ function parseInfoString(raw: string | null): { language: string | null; meta: Record; } { if (!raw) return { language: null, meta: {} }; const meta: Record = {}; let rest = raw; // First token (no '=') is the language const firstSpace = rest.indexOf(" "); let language: string | null; if (firstSpace === -1) { // Entire string is the language if it has no '=' if (rest.includes("=")) { language = null; } else { return { language: rest, meta }; } } else { const first = rest.slice(0, firstSpace); if (first.includes("=")) { language = null; } else { language = first; rest = rest.slice(firstSpace + 1); } } // Parse remaining key=value pairs const kvPattern = /(\w[\w-]*)=(?:"([^"]*)"|(\S+))/g; for (const m of rest.matchAll(kvPattern)) { meta[m[1]] = m[2] ?? m[3]; } return { language, meta }; }