/** * Lightweight markdown tokenizer for the editor highlight mirror. * * Pure module — safe to import client-side. Splits the source into lines and * returns, per line, segments tagged with a Tailwind class for the mirror * layer behind the editor textarea. Not a full parser: fence state is tracked * across lines, everything else is line-local. */ export interface HighlightSegment { readonly text: string; readonly className?: string; } const HL = { heading: "font-semibold text-sky-200", bold: "font-semibold text-foreground", italic: "italic text-foreground", strike: "line-through text-muted-foreground/70", code: "rounded bg-muted px-0.5 text-emerald-300", wikilink: "text-sky-300 underline underline-offset-2", link: "text-sky-300 underline underline-offset-2", quote: "italic text-muted-foreground/80", directive: "text-violet-300", fence: "text-muted-foreground/60", codeBlock: "text-emerald-300/50", listMarker: "text-amber-300", } as const; const INLINE_RE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|((? cursor) segments.push({ text: text.slice(cursor, start) }); const groupIndex = match.findIndex((capture, index) => index > 0 && capture !== undefined) - 1; segments.push({ text: match[0], className: INLINE_CLASSES[groupIndex] }); cursor = start + match[0].length; } if (cursor < text.length) { segments.push({ text: text.slice(cursor) }); } return segments; } const FENCE_RE = /^\s{0,3}(```|~~~)/; const HEADING_RE = /^\s{0,3}#{1,6}\s/; const QUOTE_RE = /^\s{0,3}>/; const DIRECTIVE_RE = /^\s{0,3}:::/; const HRULE_RE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/; const LIST_MARKER_RE = /^(\s*)([-*+]|\d+[.)])(\s+)/; export function highlightMarkdownLines(source: string): HighlightSegment[][] { const lines: HighlightSegment[][] = []; let inFence = false; for (const line of source.split("\n")) { if (FENCE_RE.test(line)) { lines.push([{ text: line, className: HL.fence }]); inFence = !inFence; continue; } if (inFence) { lines.push([{ text: line, className: HL.codeBlock }]); continue; } if (HEADING_RE.test(line)) { lines.push([{ text: line, className: HL.heading }]); continue; } if (QUOTE_RE.test(line)) { lines.push([{ text: line, className: HL.quote }]); continue; } if (DIRECTIVE_RE.test(line)) { lines.push([{ text: line, className: HL.directive }]); continue; } if (HRULE_RE.test(line)) { lines.push([{ text: line, className: HL.fence }]); continue; } const listMatch = line.match(LIST_MARKER_RE); if (listMatch) { const markerEnd = listMatch[0].length; lines.push([ { text: listMatch[0], className: HL.listMarker }, ...tokenizeInline(line.slice(markerEnd)), ]); continue; } lines.push(tokenizeInline(line)); } return lines; }