spb/note-spboucher-ai
Public
1/** Markdown → ProseMirror JSON (client-safe, small subset: headings, paragraphs, lists, tasks, quotes, code, tables, hr, inline marks, math). Used for AI output insertion and Markdown import. */2import { marked, type Token, type Tokens } from "marked";3import type { PMNode } from "./text";4function inline(tokens: Token[] | undefined, marks: { type: string; attrs?: any }[] = []): PMNode[] {5 const out: PMNode[] = [];6 for (const t of tokens ?? []) {7 const tt = t as any;8 switch (t.type) {9 case "text": if (tt.tokens) out.push(...inline(tt.tokens, marks)); else if (tt.text) out.push({ type: "text", text: unescape(tt.text), ...(marks.length ? { marks } : {}) }); break;10 case "strong": out.push(...inline(tt.tokens, [...marks, { type: "bold" }])); break;11 case "em": out.push(...inline(tt.tokens, [...marks, { type: "italic" }])); break;12 case "del": out.push(...inline(tt.tokens, [...marks, { type: "strike" }])); break;13 case "codespan": { const m = tt.text.match(/^\$(.+)\$$/); if (m) out.push({ type: "inlineMath", attrs: { latex: m[1] } }); else out.push({ type: "text", text: unescape(tt.text), marks: [...marks, { type: "code" }] }); break; }14 case "link": out.push(...inline(tt.tokens, [...marks, { type: "link", attrs: { href: tt.href } }])); break;15 case "br": out.push({ type: "hardBreak" }); break;16 case "escape": out.push({ type: "text", text: tt.text, ...(marks.length ? { marks } : {}) }); break;17 case "html": out.push({ type: "text", text: tt.text, ...(marks.length ? { marks } : {}) }); break;18 case "image": out.push({ type: "text", text: `[image: ${tt.text || tt.href}]`, ...(marks.length ? { marks } : {}) }); break;19 default: if (tt.tokens) out.push(...inline(tt.tokens, marks)); else if (tt.text) out.push({ type: "text", text: unescape(tt.text), ...(marks.length ? { marks } : {}) });20 }21 }22 // split inline $...$ math inside plain text nodes23 const res: PMNode[] = [];24 for (const n of out) { if (n.type !== "text" || !n.text || n.marks?.some((m) => m.type === "code")) { res.push(n); continue; } const parts = n.text.split(/(\$[^$\n]+?\$)/g); for (const p of parts) { if (!p) continue; const m = p.match(/^\$([^$]+)\$$/); if (m) res.push({ type: "inlineMath", attrs: { latex: m[1] } }); else res.push({ ...n, text: p }); } }25 return res;26}27const unescape = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");28const para = (content: PMNode[]): PMNode => (content.length ? { type: "paragraph", content } : { type: "paragraph" });29function blocks(tokens: Token[]): PMNode[] {30 const out: PMNode[] = [];31 for (const t of tokens) {32 const tt = t as any;33 switch (t.type) {34 case "heading": out.push({ type: "heading", attrs: { level: Math.min(3, tt.depth) }, content: inline(tt.tokens) }); break;35 case "paragraph": { const m = tt.text?.match(/^\$\$([\s\S]+?)\$\$$/); if (m) out.push({ type: "blockMath", attrs: { latex: m[1].trim() } }); else out.push(para(inline(tt.tokens))); break; }36 case "text": out.push(para(inline(tt.tokens ?? [{ type: "text", text: tt.text } as any]))); break;37 case "blockquote": out.push({ type: "blockquote", content: blocks(tt.tokens) }); break;38 case "code": { const m = tt.lang === "math" || /^\$\$/.test(tt.text); if (tt.lang === "math") out.push({ type: "blockMath", attrs: { latex: tt.text } }); else out.push({ type: "codeBlock", attrs: { language: tt.lang || null }, content: tt.text ? [{ type: "text", text: tt.text }] : [] }); break; }39 case "hr": out.push({ type: "horizontalRule" }); break;40 case "space": break;41 case "list": {42 const items: Tokens.ListItem[] = tt.items; const isTask = items.some((i) => i.task);43 const li = items.map((i) => { const c = blocks(i.tokens as Token[]); const content = c.length ? (c[0].type === "paragraph" || c[0].type === "heading" ? c : [para([]), ...c]) : [para([])]; return isTask ? { type: "taskItem", attrs: { checked: Boolean(i.checked) }, content } : { type: "listItem", content }; });44 out.push(isTask ? { type: "taskList", content: li } : tt.ordered ? { type: "orderedList", attrs: { start: tt.start || 1 }, content: li } : { type: "bulletList", content: li }); break;45 }46 case "table": {47 const row = (cells: any[], header: boolean) => ({ type: "tableRow", content: cells.map((c) => ({ type: header ? "tableHeader" : "tableCell", content: [para(inline(c.tokens))] })) });48 out.push({ type: "table", content: [row(tt.header, true), ...tt.rows.map((r: any[]) => row(r, false))] }); break;49 }50 case "html": out.push(para([{ type: "text", text: tt.text }])); break;51 default: if (tt.tokens) out.push(para(inline(tt.tokens))); else if (tt.raw?.trim()) out.push(para([{ type: "text", text: tt.raw }]));52 }53 }54 return out;55}56export function markdownToDoc(md: string): PMNode[] {57 const tokens = marked.lexer(md.replace(/\r\n/g, "\n"), { gfm: true });58 const nodes = blocks(tokens);59 return nodes.length ? nodes : [para([])];60}61