SPB Git
6.3 KB · 67 lines typescript
1/** Pure helpers over ProseMirror JSON (no DOM): plain text, block list, link/mention extraction, Markdown. Shared by server + client. */2export type PMNode = { type: string; attrs?: Record<string, any>; content?: PMNode[]; text?: string; marks?: { type: string; attrs?: Record<string, any> }[] };34export function nodeText(n: PMNode): string {5  if (n.text) return n.text;6  if (n.type === "hardBreak") return "\n";7  if (n.type === "mention") return `@${n.attrs?.label ?? ""}`;8  if (n.type === "math" || n.type === "inlineMath") return n.attrs?.latex ?? "";9  if (n.type === "blockMath") return n.attrs?.latex ?? "";10  return (n.content ?? []).map(nodeText).join((n.content ?? []).some(isBlock) ? "\n" : "");11}12const BLOCKS = new Set(["paragraph", "heading", "blockquote", "codeBlock", "bulletList", "orderedList", "listItem", "taskList", "taskItem", "table", "tableRow", "callout", "details", "detailsSummary", "detailsContent", "aiBlock", "blockMath", "horizontalRule", "image", "fileBlock", "youtube", "footnote"]);13const isBlock = (n: PMNode) => BLOCKS.has(n.type);14export function docText(doc: PMNode | null | undefined): string { return doc ? nodeText(doc).replace(/\n{3,}/g, "\n\n").trim() : ""; }15export const wordCount = (s: string) => (s.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? []).length;1617/** Top-level-ish blocks with their text and a stable id (attrs.id if present). Used for chunking + citations. */18export function docBlocks(doc: PMNode | null | undefined): { id: string | null; type: string; heading: string | null; text: string }[] {19  const out: { id: string | null; type: string; heading: string | null; text: string }[] = []; let heading: string | null = null;20  const walk = (n: PMNode) => {21    if (n.type === "heading") { heading = nodeText(n).trim(); out.push({ id: n.attrs?.id ?? null, type: "heading", heading, text: heading }); return; }22    if (["paragraph", "blockquote", "codeBlock", "bulletList", "orderedList", "taskList", "table", "callout", "details", "aiBlock", "blockMath"].includes(n.type)) { const t = nodeText(n).trim(); if (t) out.push({ id: n.attrs?.id ?? null, type: n.type, heading, text: t }); return; }23    (n.content ?? []).forEach(walk);24  };25  (doc?.content ?? []).forEach(walk); return out;26}27export function extractPageRefs(doc: PMNode | null | undefined): string[] {28  const ids = new Set<string>();29  const walk = (n: PMNode) => { if (n.type === "mention" && n.attrs?.id && n.attrs?.kind !== "user") ids.add(n.attrs.id); for (const m of n.marks ?? []) if (m.type === "link" && typeof m.attrs?.href === "string") { const mm = m.attrs.href.match(/^\/p\/([0-9a-f-]{36})/); if (mm) ids.add(mm[1]); } (n.content ?? []).forEach(walk); };30  if (doc) walk(doc); return [...ids];31}3233/** ProseMirror JSON → Markdown (lossless enough for export/AI context). */34export function toMarkdown(doc: PMNode | null | undefined): string {35  if (!doc) return "";36  const inline = (n: PMNode): string => {37    if (n.type === "text") { let t = n.text ?? ""; for (const m of n.marks ?? []) { if (m.type === "bold") t = `**${t}**`; else if (m.type === "italic") t = `*${t}*`; else if (m.type === "code") t = `\`${t}\``; else if (m.type === "strike") t = `~~${t}~~`; else if (m.type === "link") t = `[${t}](${m.attrs?.href})`; else if (m.type === "highlight") t = `==${t}==`; } return t; }38    if (n.type === "hardBreak") return "  \n"; if (n.type === "mention") return `[[${n.attrs?.label ?? n.attrs?.id}]]`; if (n.type === "inlineMath" || n.type === "math") return `$${n.attrs?.latex ?? ""}$`; if (n.type === "footnoteRef") return `[^${n.attrs?.id}]`;39    return (n.content ?? []).map(inline).join("");40  };41  const block = (n: PMNode, depth = 0): string => {42    const pad = "  ".repeat(depth);43    switch (n.type) {44      case "paragraph": return inline(n) + "\n";45      case "heading": return `${"#".repeat(n.attrs?.level ?? 1)} ${inline(n)}\n`;46      case "blockquote": return (n.content ?? []).map((c) => block(c)).join("").split("\n").filter(Boolean).map((l) => `> ${l}`).join("\n") + "\n";47      case "codeBlock": return `\`\`\`${n.attrs?.language ?? ""}\n${nodeText(n)}\n\`\`\`\n`;48      case "bulletList": return (n.content ?? []).map((li) => `${pad}- ${(li.content ?? []).map((c, i) => (i === 0 ? block(c, depth + 1).trimStart() : block(c, depth + 1))).join("")}`).join("");49      case "orderedList": return (n.content ?? []).map((li, i) => `${pad}${i + 1}. ${(li.content ?? []).map((c, j) => (j === 0 ? block(c, depth + 1).trimStart() : block(c, depth + 1))).join("")}`).join("");50      case "taskList": return (n.content ?? []).map((li) => `${pad}- [${li.attrs?.checked ? "x" : " "}] ${(li.content ?? []).map((c, i) => (i === 0 ? block(c, depth + 1).trimStart() : block(c, depth + 1))).join("")}`).join("");51      case "table": { const rows = (n.content ?? []).map((r) => (r.content ?? []).map((c) => inline(c).replace(/\|/g, "\\|").replace(/\n/g, " "))); if (!rows.length) return ""; return `| ${rows[0].join(" | ")} |\n| ${rows[0].map(() => "---").join(" | ")} |\n${rows.slice(1).map((r) => `| ${r.join(" | ")} |`).join("\n")}\n`; }52      case "horizontalRule": return "---\n";53      case "image": return `![${n.attrs?.alt ?? ""}](${n.attrs?.src})\n`;54      case "fileBlock": return `[📎 ${n.attrs?.name}](${n.attrs?.src})\n`;55      case "youtube": return `${n.attrs?.src}\n`;56      case "blockMath": return `$$\n${n.attrs?.latex ?? ""}\n$$\n`;57      case "callout": return `> [!${(n.attrs?.kind ?? "note").toUpperCase()}] ${n.attrs?.icon ?? ""}\n` + (n.content ?? []).map((c) => block(c)).join("").split("\n").filter(Boolean).map((l) => `> ${l}`).join("\n") + "\n";58      case "details": { const s = n.content?.find((c) => c.type === "detailsSummary"); const b = n.content?.find((c) => c.type === "detailsContent"); return `<details><summary>${s ? inline(s) : ""}</summary>\n\n${b ? (b.content ?? []).map((c) => block(c)).join("\n") : ""}\n</details>\n`; }59      case "aiBlock": return `> [!AI] ${n.attrs?.model ?? ""}\n` + (n.content ?? []).map((c) => block(c)).join("").split("\n").filter(Boolean).map((l) => `> ${l}`).join("\n") + "\n";60      case "footnote": return `[^${n.attrs?.id}]: ${(n.content ?? []).map(inline).join(" ")}\n`;61      default: return (n.content ?? []).map((c) => block(c, depth)).join("");62    }63  };64  return (doc.content ?? []).map((n) => block(n)).join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";65}66export const emptyDoc = (): PMNode => ({ type: "doc", content: [{ type: "paragraph" }] });67
Files · main
Menu

Theme