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:
@@ -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}]]`;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user