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