Add anchor resolving through space.readRef

This commit is contained in:
Zef Hemel
2026-04-30 19:35:24 +02:00
parent cb36d44634
commit be0e1c60ca
4 changed files with 140 additions and 3 deletions
+6 -1
View File
@@ -312,7 +312,12 @@ export class Client {
await this.mq.send("indexQueue", name);
});
const space = new Space(this.eventedSpacePrimitives, this.eventHook);
const space = new Space(
this.eventedSpacePrimitives,
this.eventHook,
(name, page) =>
this.clientSystem.localSyscall("index.resolveAnchor", [name, page]),
);
this.space = space;
+3 -1
View File
@@ -334,7 +334,9 @@ export async function readTransclusionContent(
}
const ref = parseToRef(transclusion.url);
if (!ref || !isMarkdownPath(ref.path)) {
// Anchor refs (e.g. $name or Page$name) have an empty or page-only path;
// allow them through since readRef handles them via the anchorResolver.
if (!ref || (ref.details?.type !== "anchor" && !isMarkdownPath(ref.path))) {
throw Error(
`Couldn't transclude markdown, invalid path: ${transclusion.url}`,
);
+99 -1
View File
@@ -1,10 +1,14 @@
import { expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { MemoryKvPrimitives } from "./data/memory_kv_primitives.ts";
import { EventHook } from "./plugos/hooks/event.ts";
import { Space } from "./space.ts";
import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts";
import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref";
import { createMockSystem } from "../plug-api/system_mock.ts";
import { indexMarkdown } from "../plugs/index/indexer.ts";
import { resolveAnchor } from "../plugs/index/api.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
const testPage = `
Some paragraph
@@ -75,3 +79,97 @@ test("readRef checks", async () => {
offset: subItemPos,
});
});
// Helper to create a pageMeta for tests
const makeMeta = (name: string): PageMeta => ({
ref: name,
tag: "page",
name,
perm: "rw",
lastModified: "",
created: "",
});
/**
* Indexes a page's markdown into the mock system index.
*/
async function indexPage(text: string, name: string): Promise<void> {
const objects = await indexMarkdown(text, makeMeta(name));
await (globalThis as any).syscall("index.indexObjects", name, objects);
}
describe("readRef anchor variant", () => {
test("readRef resolves bare anchor $pete", async () => {
createMockSystem();
const kv = new MemoryKvPrimitives();
const eventHook = new EventHook();
const pageContent = "Anchored para $pete here.";
const space = new Space(
new DataStoreSpacePrimitives(kv),
eventHook,
(name, page) => resolveAnchor(name, page),
);
await sleep(1);
await space.writePage("Other", pageContent);
await indexPage(pageContent, "Other");
const result = await space.readRef(parseToRef("$pete")!);
expect(result.text).toBe("Anchored para $pete here.");
});
test("readRef resolves page-qualified anchor Other$pete", async () => {
createMockSystem();
const kv = new MemoryKvPrimitives();
const eventHook = new EventHook();
const pageContent = "Anchored para $pete here.";
const space = new Space(
new DataStoreSpacePrimitives(kv),
eventHook,
(name, page) => resolveAnchor(name, page),
);
await sleep(1);
await space.writePage("Other", pageContent);
await indexPage(pageContent, "Other");
const result = await space.readRef(parseToRef("Other$pete")!);
expect(result.text).toBe("Anchored para $pete here.");
});
test("readRef throws on missing anchor", async () => {
createMockSystem();
const kv = new MemoryKvPrimitives();
const eventHook = new EventHook();
const space = new Space(
new DataStoreSpacePrimitives(kv),
eventHook,
(name, page) => resolveAnchor(name, page),
);
await sleep(1);
await indexPage("Normal paragraph.\n", "SomePage");
await expect(space.readRef(parseToRef("$nope")!)).rejects.toThrow(
/Anchor not found/,
);
});
test("readRef throws on duplicate anchor", async () => {
createMockSystem();
const kv = new MemoryKvPrimitives();
const eventHook = new EventHook();
const space = new Space(
new DataStoreSpacePrimitives(kv),
eventHook,
(name, page) => resolveAnchor(name, page),
);
await sleep(1);
// Define $pete on two different pages so the resolver returns duplicate
await space.writePage("PageA", "First $pete anchor.");
await space.writePage("PageB", "Second $pete anchor.");
await indexPage("First $pete anchor.", "PageA");
await indexPage("Second $pete anchor.", "PageB");
await expect(space.readRef(parseToRef("$pete")!)).rejects.toThrow(
/Duplicate anchor/,
);
});
});
+32
View File
@@ -21,6 +21,12 @@ import {
renderToText,
traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type { ResolveAnchorResult } from "../plugs/index/types.ts";
export type AnchorResolver = (
name: string,
page?: string,
) => Promise<ResolveAnchorResult>;
const pageWatchInterval = 3000; // + jitter
@@ -42,6 +48,7 @@ export class Space {
constructor(
readonly spacePrimitives: SpacePrimitives,
eventHook: EventHook,
private anchorResolver?: AnchorResolver,
) {
eventHook.addLocalListener("file:deleted", (fileName: string) => {
if (this.watchedFiles.has(fileName)) {
@@ -79,6 +86,31 @@ export class Space {
}
async readRef(ref: Ref): Promise<{ offset: number; text: string }> {
if (ref.details?.type === "anchor") {
if (!this.anchorResolver) {
throw new Error("Anchor resolver not configured");
}
const anchorName = ref.details.name;
const filterPage = ref.path
? ref.path.endsWith(".md") ? ref.path.slice(0, -3) : ref.path
: undefined;
const result = await this.anchorResolver(anchorName, filterPage);
if (!result.ok) {
if (result.reason === "missing") {
throw new Error(`Anchor not found: $${anchorName}`);
}
const pages = result.hits.map((h) => h.page).join(", ");
throw new Error(`Duplicate anchor $${anchorName} on pages: ${pages}`);
}
const pageText = (await this.readPage(result.page)).text;
// Page-level anchors don't have a meaningful range — return the
// whole page (matches the behaviour for refs with no details).
if (result.hostTag === "page") {
return { offset: 0, text: pageText };
}
const [from, to] = result.range;
return { offset: from, text: pageText.slice(from, to) };
}
if (!ref.path.endsWith(".md")) {
throw new Error("Not supported");
}