Files
plainleaf/plugs/index/header.ts
T
Zef Hemel 05d3ba9333 anchor extraction and per-type indexer wiring
Paragraphs, list items, tasks, headers, and fenced-data blocks now adopt the anchor name as the host's `ref` field when present. For fenced-data blocks the anchor comes from a YAML  field named `$ref:` (the markdown anchor syntax doesn't apply inside YAML).
2026-04-30 17:57:10 +02:00

105 lines
2.7 KiB
TypeScript

import {
cloneTree,
collectNodesMatching,
type ParseTree,
renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
getNameFromPath,
parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
ObjectValue,
PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import type { FrontMatter } from "./frontmatter.ts";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { cleanTags, collectTags } from "./tags.ts";
import { cleanAnchor, collectAnchor } from "./anchor.ts";
type HeaderObject = ObjectValue<
{
name: string;
text: string;
page: string;
level: number;
pos: number;
} & Record<string, any>
>;
export function indexHeaders(
pageMeta: PageMeta,
_frontmatter: FrontMatter,
tree: ParseTree,
): Promise<HeaderObject[]> {
const headers: ObjectValue<HeaderObject>[] = [];
for (const n of collectNodesMatching(
tree,
(t) => !!t.type?.startsWith("ATXHeading"),
)) {
const level = +n.type!.substring("ATXHeading".length);
const tags = collectTags(n);
const anchor = collectAnchor(n);
const attributes = collectAttributes(n);
const nClone = cloneTree(n);
cleanTags(nClone);
cleanAttributes(nClone);
cleanAnchor(nClone); // strip $anchor token from clone
// Compute name from the cleaned tree so $anchor is stripped from both name and text.
const name = renderToText(nClone).slice(level + 1);
const text = name;
headers.push({
// First anchor wins; T12 lint flags duplicates.
ref: anchor ? anchor.name : `${pageMeta.name}@${n.from}`,
tag: "header",
tags: [...tags],
level,
name,
text,
page: pageMeta.name,
pos: n.from!,
range: [n.from!, n.to!],
...attributes,
});
}
return Promise.resolve(headers);
}
export async function headerComplete(completeEvent: CompleteEvent) {
const match = /(?:\[\[|\[.*?\]\()(?<path>[^[]*)$/.exec(
completeEvent.linePrefix,
);
if (!match || !match.groups?.path) {
return;
}
const ref = parseToRef(match.groups.path);
if (!ref || ref.details?.type !== "header") {
return;
}
const headers = await index.queryLuaObjects<HeaderObject>(
"header",
{
objectVariable: "_",
where: await lua.parseExpression(`_.page == name`),
},
{ name: getNameFromPath(ref.path) || completeEvent.pageName },
);
return {
from: completeEvent.pos - match.groups.path.length,
options: headers.map((header) => ({
label: `${
(ref.meta ? "^" : "") + getNameFromPath(ref.path)
}#${header.name}`,
type: "header",
})),
};
}