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

@@ -7,6 +7,7 @@ import type {
InlineNode,
ListBlock,
ListItemBlock,
TransclusionNode,
} from "@kitchen-md/core";
import chalk from "chalk";
import { stringify as stringifyYaml } from "yaml";
@@ -101,7 +102,19 @@ function renderInlineNode(node: InlineNode): string {
return chalk.inverse(node.value);
case "link":
return chalk.underline(renderInline(node.children));
case "wikilink":
return chalk.underline(node.display ?? node.target);
case "transclusion":
return renderTransclusion(node);
case "rawInline":
return node.value;
}
}
// A transclusion shows as its raw source text.
// Resolving the embed is out of scope.
function renderTransclusion(node: TransclusionNode): string {
const anchor = node.anchor !== undefined ? `#${node.anchor}` : "";
const display = node.display !== undefined ? `|${node.display}` : "";
return `![[${node.target}${anchor}${display}]]`;
}

View File

@@ -216,6 +216,59 @@ describe("render", () => {
}
});
test("a wikilink renders its display text, or the target when there is none", () => {
const withDisplay = bodyOf({
type: "paragraph",
children: [{ type: "wikilink", target: "basic-brine", anchor: "step", display: "the brine" }],
});
const bare = bodyOf({
type: "paragraph",
children: [{ type: "wikilink", target: "Maple Syrup" }],
});
expect(withDisplay).toBe("the brine\n\n");
expect(bare).toBe("Maple Syrup\n\n");
});
test("a wikilink carries styling distinct from plain text", () => {
const previousLevel = chalk.level;
chalk.level = 1;
try {
const styled = render({
frontmatter: {},
blocks: [{ type: "paragraph", children: [{ type: "wikilink", target: "x" }] }],
diagnostics: [],
});
expect(styled).not.toBe(Bun.stripANSI(styled));
} finally {
chalk.level = previousLevel;
}
});
test("a transclusion renders as its raw source text", () => {
const sectioned = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "italian meatballs", anchor: "rolling:2" }],
});
const headingless = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "basic brine", anchor: "3" }],
});
const aliased = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "recipe", display: "As shown" }],
});
const bare = bodyOf({
type: "paragraph",
children: [{ type: "transclusion", target: "maple syrup" }],
});
expect(sectioned).toBe("![[italian meatballs#rolling:2]]\n\n");
expect(headingless).toBe("![[basic brine#3]]\n\n");
expect(aliased).toBe("![[recipe|As shown]]\n\n");
expect(bare).toBe("![[maple syrup]]\n\n");
});
test("a rawInline node renders its verbatim value", () => {
const stripped = bodyOf({
type: "paragraph",

View File

@@ -13,6 +13,7 @@
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-wiki-link": "^2.0.1",
"unified": "^11.0.5",
"yaml": "^2.9.0"
}

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 {

View File

@@ -247,6 +247,92 @@ A paragraph under it.
]);
});
test("a bare wikilink carries only its target", () => {
const result = parse("See [[Basic Brine]] first.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({ type: "wikilink", target: "Basic Brine" });
});
test("a wikilink anchor is passed through verbatim", () => {
const result = parse("[[recipe#the section]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([{ type: "wikilink", target: "recipe", anchor: "the section" }]);
});
test("a wikilink display alias is captured after the pipe", () => {
const result = parse("[[recipe#anchor|Read this]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "wikilink", target: "recipe", anchor: "anchor", display: "Read this" },
]);
});
test("a wikilink and a transclusion are distinct node types sharing one shape", () => {
const result = parse("[[recipe]] versus ![[recipe]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "wikilink", target: "recipe" },
{ type: "text", value: " versus " },
{ type: "transclusion", target: "recipe" },
]);
});
test("a transclusion display alias is captured after the pipe", () => {
const result = parse("![[recipe|As shown]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "recipe",
display: "As shown",
});
});
test("a Step Reference transclusion passes its section anchor through as-is", () => {
const result = parse("![[italian meatballs#rolling:2]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "italian meatballs",
anchor: "rolling:2",
});
});
test("a headingless Step Reference transclusion passes its bare step anchor through as-is", () => {
const result = parse("![[basic brine#3]]\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toContainEqual({
type: "transclusion",
target: "basic brine",
anchor: "3",
});
});
test("a transclusion is extracted from surrounding prose", () => {
const result = parse("Finish with ![[maple syrup#2]] on top.\n");
const para = result.blocks[0];
if (para?.type !== "paragraph") throw new Error("expected a paragraph block");
expect(para.children).toEqual([
{ type: "text", value: "Finish with " },
{ type: "transclusion", target: "maple syrup", anchor: "2" },
{ type: "text", value: " on top." },
]);
});
test("frontmatter is not emitted as a block", () => {
const input = `---
title: X

View File

@@ -34,6 +34,26 @@ export interface LinkNode {
children: InlineNode[];
}
// An Obsidian reference `[[target#anchor|display]]`: target is the filename
// without extension, anchor is the part after `#` verbatim, display the alias after `|`.
// The node type — not a flag — distinguishes it from a transclusion.
export interface WikilinkNode {
type: "wikilink";
target: string;
anchor?: string;
display?: string;
}
// An Obsidian embed `![[target#anchor|display]]`, covering step references like
// `![[file#section:N]]` and `![[file#N]]` whose anchor is passed through as-is.
// It shares WikilinkNode's shape, so the type is what tells the two apart.
export interface TransclusionNode {
type: "transclusion";
target: string;
anchor?: string;
display?: string;
}
// Verbatim source for any inline construct core does not model, sliced from the
// original input so the unmodelled span round-trips byte-for-byte.
export interface RawInline {
@@ -41,7 +61,15 @@ export interface RawInline {
value: string;
}
export type InlineNode = TextNode | EmphasisNode | StrongNode | CodeSpanNode | LinkNode | RawInline;
export type InlineNode =
| TextNode
| EmphasisNode
| StrongNode
| CodeSpanNode
| LinkNode
| WikilinkNode
| TransclusionNode
| RawInline;
export interface HeadingBlock {
type: "heading";