diff --git a/client/client.ts b/client/client.ts index 8541f5e5..5c1dda6e 100644 --- a/client/client.ts +++ b/client/client.ts @@ -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; diff --git a/client/markdown_renderer/inline.ts b/client/markdown_renderer/inline.ts index 6d0ca988..10216791 100644 --- a/client/markdown_renderer/inline.ts +++ b/client/markdown_renderer/inline.ts @@ -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}`, ); diff --git a/client/space.test.ts b/client/space.test.ts index 1ad624ac..6929b8cc 100644 --- a/client/space.test.ts +++ b/client/space.test.ts @@ -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 { + 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/, + ); + }); +}); diff --git a/client/space.ts b/client/space.ts index 77a500fa..e34589fd 100644 --- a/client/space.ts +++ b/client/space.ts @@ -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; 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"); }