import type { ListItem, Node, PhrasingContent, Root, RootContent } from "mdast"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import remarkWikiLink from "remark-wiki-link"; import { unified } from "unified"; import { parse as parseYaml } from "yaml"; import type { Block, DocumentAST, Frontmatter, InlineNode, ListItemBlock, TransclusionNode, WikilinkNode, } from "./types.ts"; // `|` is the Obsidian alias divider. // The plugin defaults to `:`, which would swallow anchors like `#rolling:2`. const processor = unified() .use(remarkParse) .use(remarkFrontmatter) .use(remarkGfm) .use(remarkWikiLink, { aliasDivider: "|" }); // The mdast node remark-wiki-link injects for `[[…]]`. // Its `value` is the target with any `#anchor` still attached. // `data.alias` is the display text, and equals `value` when no alias was written. interface WikiLinkMdast { type: "wikiLink"; value: string; data?: { alias?: string }; } type InlineMdast = PhrasingContent | WikiLinkMdast; // Transclusions (`![[…]]`) are not matched by remark-wiki-link — the leading `!` // makes remark treat the brackets as a failed image, leaving the whole span as // literal text — so they are recovered by scanning text with this pattern. const TRANSCLUSION = /!\[\[([^[\]]+)]]/g; export function parse(input: string): DocumentAST { const tree = processor.parse(input); const frontmatter = extractFrontmatter(tree); const blocks = tree.children.flatMap((node) => translateBlock(node, input)); return { frontmatter, blocks, diagnostics: [] }; } function extractFrontmatter(tree: Root): Frontmatter { const yamlNode = tree.children.find((node) => node.type === "yaml"); if (!yamlNode) { return {}; } const data = parseYaml(yamlNode.value); if (data !== null && typeof data === "object" && !Array.isArray(data)) { return data as Frontmatter; } return {}; } function translateBlock(node: RootContent, input: string): Block[] { switch (node.type) { case "heading": return [ { type: "heading", level: node.depth, children: translateInline(node.children, input) }, ]; case "paragraph": return [{ type: "paragraph", children: translateInline(node.children, input) }]; case "list": return [ { type: "list", ordered: node.ordered ?? false, items: node.children.map((item) => translateListItem(item, input)), }, ]; case "blockquote": return [ { type: "blockquote", children: node.children.flatMap((child) => translateBlock(child, input)), }, ]; case "code": return [{ type: "code", ...(node.lang ? { lang: node.lang } : {}), value: node.value }]; case "thematicBreak": return [{ type: "thematicBreak" }]; // Frontmatter is captured separately and must not double as a block. case "yaml": return []; default: return [{ type: "raw", value: slice(node, input) }]; } } function translateListItem(item: ListItem, input: string): ListItemBlock { return { type: "listItem", children: item.children.flatMap((child) => translateBlock(child, input)), }; } function translateInline(nodes: InlineMdast[], input: string): InlineNode[] { return nodes.flatMap((node): InlineNode[] => { switch (node.type) { case "text": return splitTransclusions(node.value); case "emphasis": return [{ type: "emphasis", children: translateInline(node.children, input) }]; case "strong": return [{ type: "strong", children: translateInline(node.children, input) }]; case "inlineCode": return [{ type: "codeSpan", value: node.value }]; case "link": return [{ type: "link", href: node.url, children: translateInline(node.children, input) }]; case "wikiLink": return [translateWikilink(node)]; default: return [{ type: "rawInline", value: slice(node, input) }]; } }); } function translateWikilink(node: WikiLinkMdast): WikilinkNode { const display = node.data?.alias !== undefined && node.data.alias !== node.value ? node.data.alias : undefined; return { type: "wikilink", ...splitAnchor(node.value), ...(display !== undefined ? { display } : {}), }; } // Split a text run into plain text and the transclusions embedded in it, preserving order. // A run with no transclusion yields a single text node. function splitTransclusions(value: string): InlineNode[] { const out: InlineNode[] = []; let cursor = 0; for (const match of value.matchAll(TRANSCLUSION)) { const at = match.index; if (at > cursor) { out.push({ type: "text", value: value.slice(cursor, at) }); } out.push(buildTransclusion(match[1])); cursor = at + match[0].length; } if (out.length === 0 || cursor < value.length) { out.push({ type: "text", value: value.slice(cursor) }); } return out; } function buildTransclusion(inner: string): TransclusionNode { const pipe = inner.indexOf("|"); const display = pipe === -1 ? undefined : inner.slice(pipe + 1); const targetPart = pipe === -1 ? inner : inner.slice(0, pipe); return { type: "transclusion", ...splitAnchor(targetPart), ...(display !== undefined ? { display } : {}), }; } // Split `target#anchor` at the first `#`. // The anchor is passed through verbatim, and is omitted entirely when absent. function splitAnchor(value: string): { target: string; anchor?: string } { const hash = value.indexOf("#"); if (hash === -1) { return { target: value }; } return { target: value.slice(0, hash), anchor: value.slice(hash + 1) }; } // Verbatim source for an unmodelled node, taken by position so it round-trips // byte-for-byte rather than being re-stringified through remark. function slice(node: Node, input: string): string { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start === undefined || end === undefined) { return ""; } return input.slice(start, end); }