import { BULLET_MARKS, COMMENT_CLOSE, COMMENT_OPEN, FENCE, FRONTMATTER_FENCE, HEADING_MARK, HEADING_MAX_LEVEL, ORDERED_CLOSE, QUOTE_MARK, RULE_CHARS, RULE_MIN_LENGTH, TABLE_PIPE, TABLE_RULE_CHARS, } from "#configuration/constants/markdown.constants"; import { DIGITS } from "#configuration/constants/syntax.constants"; import { LINE_BREAK } from "#configuration/constants/code.constants"; import type { MarkdownNode } from "#types/markdown.types"; import { SPACE } from "#configuration/constants/document.constants"; const charsOf = function charsOf(text: string): string[] { const chars: string[] = []; for (const char of text) { chars.push(char); } return chars; }; const isRule = function isRule(line: string): boolean { const chars = charsOf(line.trim()).filter((char) => char !== SPACE); const [first] = chars; return ( chars.length >= RULE_MIN_LENGTH && first !== undefined && RULE_CHARS.has(first) && chars.every((char) => char === first) ); }; const headingLevel = function headingLevel(line: string): number { let level = 0; while (level < line.length && line.charAt(level) === HEADING_MARK) { level += 1; } return level > 0 && level <= HEADING_MAX_LEVEL && line.charAt(level) === SPACE ? level : 0; }; const bulletText = function bulletText(line: string): string | null { const trimmed = line.trim(); return BULLET_MARKS.has(trimmed.charAt(0)) && trimmed.charAt(1) === SPACE ? trimmed.slice(2) : null; }; const orderedText = function orderedText(line: string): string | null { const trimmed = line.trim(); let digits = 0; while (digits < trimmed.length && DIGITS.includes(trimmed.charAt(digits))) { digits += 1; } const closes = trimmed.charAt(digits) === ORDERED_CLOSE && trimmed.charAt(digits + 1) === SPACE; return digits > 0 && closes ? trimmed.slice(digits + 2) : null; }; const cellsOf = function cellsOf(line: string): string[] { const trimmed = line.trim(); const inner = trimmed.startsWith(TABLE_PIPE) ? trimmed.slice(1) : trimmed; const body = inner.endsWith(TABLE_PIPE) ? inner.slice(0, -1) : inner; return body.split(TABLE_PIPE).map((cell) => cell.trim()); }; const isTableRule = function isTableRule(line: string): boolean { const trimmed = line.trim(); return ( trimmed.includes(TABLE_PIPE) && charsOf(trimmed).every((char) => char === TABLE_PIPE || TABLE_RULE_CHARS.has(char)) ); }; const uncommented = function uncommented(text: string): string { let out = ""; let at = 0; while (at < text.length) { const open = text.indexOf(COMMENT_OPEN, at); if (open === -1) { return out + text.slice(at); } const close = text.indexOf(COMMENT_CLOSE, open + COMMENT_OPEN.length); out += text.slice(at, open); at = close === -1 ? text.length : close + COMMENT_CLOSE.length; } return out; }; const bodyLines = function bodyLines(text: string): string[] { const lines = uncommented(text).split(LINE_BREAK); if (lines[0]?.trim() !== FRONTMATTER_FENCE) { return lines; } const close = lines.findIndex((line, index) => index > 0 && line.trim() === FRONTMATTER_FENCE); return close === -1 ? lines : lines.slice(close + 1); }; class Parser { private readonly lines: readonly string[]; private readonly nodes: MarkdownNode[] = []; private paragraph: string[] = []; private at = 0; public constructor(text: string) { this.lines = bodyLines(text); } public parse(): MarkdownNode[] { while (this.at < this.lines.length) { this.step(this.lines[this.at] ?? ""); } this.flush(); return this.nodes; } private line(offset = 0): string { return this.lines[this.at + offset] ?? ""; } private step(line: string): void { const trimmed = line.trim(); if (trimmed.length === 0) { this.flush(); this.at += 1; return; } if (trimmed.startsWith(FENCE)) { this.fence(trimmed.slice(FENCE.length).trim()); return; } if (trimmed.startsWith(QUOTE_MARK)) { this.quote(); return; } if (bulletText(line) !== null || orderedText(line) !== null) { this.list(orderedText(line) !== null); return; } if (trimmed.includes(TABLE_PIPE) && isTableRule(this.line(1))) { this.table(); return; } this.simple(trimmed); } private simple(trimmed: string): void { const level = headingLevel(trimmed); if (level > 0) { this.push({ kind: "heading", level, text: trimmed.slice(level + 1).trim() }); } else if (isRule(trimmed)) { this.push({ kind: "rule" }); } else { this.paragraph.push(trimmed); } this.at += 1; } private push(node: MarkdownNode): void { this.flush(); this.nodes.push(node); } private flush(): void { if (this.paragraph.length > 0) { this.nodes.push({ kind: "paragraph", text: this.paragraph.join(SPACE) }); this.paragraph = []; } } private fence(language: string): void { const code: string[] = []; this.at += 1; while (this.at < this.lines.length && this.line().trim() !== FENCE) { code.push(this.line()); this.at += 1; } this.at += 1; this.push({ code: code.join(LINE_BREAK), kind: "code", language }); } private quote(): void { const held: string[] = []; while (this.at < this.lines.length && this.line().trim().startsWith(QUOTE_MARK)) { held.push(this.line().trim().slice(1).trim()); this.at += 1; } this.push({ kind: "quote", text: held.join(SPACE) }); } private list(ordered: boolean): void { const items: string[] = []; while (this.at < this.lines.length) { const line = this.line(); const item = ordered ? orderedText(line) : bulletText(line); const continued = item === null && line.startsWith(SPACE) && line.trim().length > 0 && items.length > 0; if (item !== null) { items.push(item); } else if (continued) { items[items.length - 1] = `${items.at(-1) ?? ""}${SPACE}${line.trim()}`; } else { break; } this.at += 1; } this.push({ items, kind: "list", ordered }); } private table(): void { const headers = cellsOf(this.line()); const rows: string[][] = []; this.at += 2; while (this.at < this.lines.length && this.line().includes(TABLE_PIPE)) { rows.push(cellsOf(this.line())); this.at += 1; } this.push({ headers, kind: "table", rows }); } } export const parseMarkdown = function parseMarkdown(text: string): MarkdownNode[] { return new Parser(text).parse(); };