diff --git a/plug-api/lib/ref.test.ts b/plug-api/lib/ref.test.ts index 535f15db..93f67fb4 100644 --- a/plug-api/lib/ref.test.ts +++ b/plug-api/lib/ref.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { decodePageURI, encodePageURI, @@ -117,6 +117,50 @@ test("isValidPath() and isValidName()", () => { expect(!isValidName("hello>there")).toBeTruthy(); }); +describe("anchor refs", () => { + test("parseToRef bare anchor", () => { + expect(parseToRef("$pete")).toEqual({ + path: "", + details: { type: "anchor", name: "pete" }, + }); + }); + + test("parseToRef page-qualified anchor", () => { + expect(parseToRef("Some Page$pete")).toEqual({ + path: "Some Page.md", + details: { type: "anchor", name: "pete" }, + }); + }); + + test("parseToRef accepts allowed character classes", () => { + for (const name of ["toc1", "tasks/7", "a:b", "a-b", "_foo"]) { + const ref = parseToRef("$" + name); + expect(ref?.details).toEqual({ type: "anchor", name }); + } + }); + + test("parseToRef rejects digit-leading anchors", () => { + // "$100" should NOT be parsed as an anchor; falling through to + // the path branch should fail because the path would be "$100" + // which is not a valid path either. + expect(parseToRef("$100")).toBeNull(); + }); + + test("encodeRef round-trips bare anchor", () => { + expect(encodeRef({ path: "", details: { type: "anchor", name: "pete" } })) + .toBe("$pete"); + }); + + test("encodeRef round-trips page-qualified anchor", () => { + expect( + encodeRef({ + path: "Some Page.md", + details: { type: "anchor", name: "pete" }, + }), + ).toBe("Some Page$pete"); + }); +}); + test("Page URI encoding", () => { expect(encodePageURI("foo")).toEqual("foo"); expect(encodePageURI("folder/foo")).toEqual("folder/foo"); diff --git a/plug-api/lib/ref.ts b/plug-api/lib/ref.ts index edbf33c5..672e7af0 100644 --- a/plug-api/lib/ref.ts +++ b/plug-api/lib/ref.ts @@ -23,7 +23,8 @@ export type Ref = { details?: | { type: "position"; pos: number } | { type: "linecolumn"; line: number; column: number } - | { type: "header"; header: string }; + | { type: "header"; header: string } + | { type: "anchor"; name: string }; }; /** @@ -96,7 +97,7 @@ export function isValidPath(path: string): path is Path { * TO THE INNER WORKINGS OF SILVERBULLET AND CHANGES COULD INTRODUCE MAJOR BUGS */ const refRegex = - /^(?\^)?(?(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>]*)(@(?\d+)|@[Ll](?\d+)(?:[Cc](?\d+))?|#\s*(?
.*))?$/; + /^(?\^)?(?(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>$]*)(@(?\d+)|@[Ll](?\d+)(?:[Cc](?\d+))?|#\s*(?
.*)|\$(?[A-Za-z_][A-Za-z0-9_/:-]*))?$/; /** * Parses a reference string into a ref object. @@ -132,6 +133,8 @@ export function parseToRef(stringRef: string): Ref | null { type: "header", header: groups.header, }; + } else if (groups.anchor !== undefined) { + ref.details = { type: "anchor", name: groups.anchor }; } return ref; @@ -158,6 +161,8 @@ export function encodeRef(ref: Ref): string { stringRef += `@${ref.details.pos}`; } else if (ref.details?.type === "header") { stringRef += `#${ref.details.header}`; + } else if (ref.details?.type === "anchor") { + stringRef += `$${ref.details.name}`; } return stringRef; @@ -191,6 +196,8 @@ export function getOffsetFromRef( case "header": { return getOffsetFromHeader(parseTree, ref.details.header); } + case "anchor": + return -1; } }