SPB Git
5.0 KB · 61 lines typescript
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(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/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
Files · main
Menu

Theme