Add anchor resolving through space.readRef
This commit is contained in:
+6
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user