feat: parse and render Obsidian cross-references (task 0005)

Model `[[wikilinks]]` and `![[transclusions]]` as distinct inline node
types sharing a `{ target, anchor?, display? }` shape, and render them:
wikilinks underlined (display text or target), transclusions as their
raw source text.

remark-wiki-link is added to the pipeline for `[[…]]`. It does not
recognise `![[…]]` embeds or split the `#anchor` from the target, so
transclusions are recovered by scanning text runs and anchors are split
in the translation layer. Its alias divider is set to `|` so Step
Reference anchors like `#rolling:2` survive.
This commit is contained in:
2026-07-29 20:05:35 -04:00
parent 22befaaa71
commit b0fdc22165
9 changed files with 441 additions and 5 deletions

View File

@@ -2,11 +2,42 @@ 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 } from "./types.ts";
import type {
Block,
DocumentAST,
Frontmatter,
InlineNode,
ListItemBlock,
TransclusionNode,
WikilinkNode,
} from "./types.ts";
const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
// `|` 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);
@@ -69,11 +100,11 @@ function translateListItem(item: ListItem, input: string): ListItemBlock {
};
}
function translateInline(nodes: PhrasingContent[], input: string): InlineNode[] {
function translateInline(nodes: InlineMdast[], input: string): InlineNode[] {
return nodes.flatMap((node): InlineNode[] => {
switch (node.type) {
case "text":
return [{ type: "text", value: node.value }];
return splitTransclusions(node.value);
case "emphasis":
return [{ type: "emphasis", children: translateInline(node.children, input) }];
case "strong":
@@ -82,12 +113,64 @@ function translateInline(nodes: PhrasingContent[], input: string): InlineNode[]
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 {