anchors: extend Ref grammar with anchor variant ($anchor)

This commit is contained in:
Zef Hemel
2026-04-30 17:52:21 +02:00
parent d566a00336
commit ea44937863
2 changed files with 54 additions and 3 deletions
+45 -1
View File
@@ -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");
+9 -2
View File
@@ -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 =
/^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*))?$/;
/^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#|<>$]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*)|\$(?<anchor>[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;
}
}