import { unified } from "unified"; import remarkParse from "remark-parse"; // ── Types ───────────────────────────────────────────────────────────────────── type Mark = { type: string; attrs?: Record }; type TipTapNode = { type: string; attrs?: Record; content?: TipTapNode[]; text?: string; marks?: Mark[]; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any type MdastNode = Record; // ── Inline converter ────────────────────────────────────────────────────────── function convertInline(nodes: MdastNode[], marks: Mark[] = []): TipTapNode[] { const result: TipTapNode[] = []; for (const node of nodes) { switch (node.type) { case "text": { if (!node.value) break; const n: TipTapNode = { type: "text", text: node.value as string }; if (marks.length) n.marks = marks; result.push(n); break; } case "strong": result.push(...convertInline(node.children, [...marks, { type: "bold" }])); break; case "emphasis": result.push(...convertInline(node.children, [...marks, { type: "italic" }])); break; case "delete": result.push(...convertInline(node.children, [...marks, { type: "strike" }])); break; case "inlineCode": result.push({ type: "text", text: node.value as string, marks: [...marks, { type: "code" }], }); break; case "link": { const linkMark: Mark = { type: "link", attrs: { href: node.url ?? "#", target: "_blank", rel: "noopener noreferrer", class: null, }, }; result.push(...convertInline(node.children, [...marks, linkMark])); break; } case "image": // Only HTTP/HTTPS images — local Obsidian paths are not resolvable here if (typeof node.url === "string" && /^https?:\/\//.test(node.url)) { result.push({ type: "image", attrs: { src: node.url, alt: node.alt ?? null, title: node.title ?? null, }, }); } break; case "break": result.push({ type: "hardBreak" }); break; default: // Try to extract content from unknown inline nodes if (Array.isArray(node.children)) { result.push(...convertInline(node.children, marks)); } else if (node.value) { const n: TipTapNode = { type: "text", text: node.value as string }; if (marks.length) n.marks = marks; result.push(n); } } } return result; } // ── Block converter ─────────────────────────────────────────────────────────── function convertBlock(nodes: MdastNode[]): TipTapNode[] { const result: TipTapNode[] = []; for (const node of nodes) { switch (node.type) { case "paragraph": { const content = convertInline(node.children ?? []); result.push(content.length ? { type: "paragraph", content } : { type: "paragraph" }); break; } case "heading": { const content = convertInline(node.children ?? []); result.push({ type: "heading", attrs: { level: node.depth ?? 2 }, content, }); break; } case "blockquote": { const content = convertBlock(node.children ?? []); result.push({ type: "blockquote", content }); break; } case "code": { result.push({ type: "codeBlock", attrs: { language: node.lang ?? null }, content: [{ type: "text", text: node.value as string }], }); break; } case "list": { const listType = node.ordered ? "orderedList" : "bulletList"; const items = (node.children as MdastNode[]).map((item) => ({ type: "listItem", content: convertBlock(item.children ?? []), })); const listNode: TipTapNode = { type: listType, content: items }; if (node.ordered) listNode.attrs = { start: (node.start as number) ?? 1 }; result.push(listNode); break; } case "thematicBreak": result.push({ type: "horizontalRule" }); break; // Skip html, definitions, footnotes, etc. } } return result; } // ── Public API ──────────────────────────────────────────────────────────────── /** * Converts a Markdown string to a TipTap/ProseMirror JSON document. * Handles: headings, paragraphs, bold, italic, strike, inline code, * code blocks, blockquotes, lists (nested), links, images (HTTP only), * horizontal rules, hard breaks. * * Obsidian-specific syntax (![[wikilink]], [[link]]) is silently ignored * since local file paths are not available during server-side import. */ export function mdToTiptap(markdown: string): object { // Strip Obsidian wikilinks: [[link]] → plain text, ![[image]] → removed const cleaned = markdown .replace(/!\[\[([^\]]+)\]\]/g, "") // remove ![[image]] embeds .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, alias) => alias ?? target); // [[link|alias]] → alias or target const tree = unified().use(remarkParse).parse(cleaned); // eslint-disable-next-line @typescript-eslint/no-explicit-any const content = convertBlock((tree as any).children ?? []); if (content.length === 0) { content.push({ type: "paragraph" }); } return { type: "doc", content }; }