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.
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
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);
|
|
}
|