// Anchors: the "#" half of a wikilink. // // Astrolabe has ONE anchor space with TWO resolvers, and a [[Note#Anchor]] has to // land in both: // • the editor jumps by heading TEXT (shared/headings.ts findHeadingLine) // • the reading view jumps by heading SLUG (client/reading/toc.ts Slugger, // with a textContent fallback in ReadingView) // Every case below asks the same question of both, because a link that works // in the editor and dies in the reading view is the bug this file exists for. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { findHeadingLine, scanHeadings } from "../shared/headings.ts"; /** The headings the editor sees, by title (shared/headings.ts's scan). */ const editorHeadings = (md: string): string[] => scanHeadings(md).map((h) => h.title); import { extractHeadings, Slugger, stripInline } from "../client/reading/toc.ts"; /** What the reading view would scroll to for `anchor`: the heading whose slug * matches, else (ReadingView's fallback) the one whose display text matches. */ function readingAnchor(md: string, anchor: string): string | null { const headings = extractHeadings(md); const wantSlug = new Slugger().slug(stripInline(anchor)); const bySlug = headings.find((h) => h.slug === wantSlug); if (bySlug) return bySlug.text; const want = anchor.trim().toLowerCase(); return headings.find((h) => h.text.trim().toLowerCase() === want)?.text ?? null; } describe("Slugger", () => { it("lowercases, drops punctuation and hyphenates spaces", () => { const s = new Slugger(); assert.equal(s.slug("What's next? (draft)"), "whats-next-draft"); }); it("keeps unicode letters and digits", () => { const s = new Slugger(); assert.equal(s.slug("مقدمة"), "مقدمة"); assert.equal(s.slug("Étude № 3"), "étude-3"); }); it("numbers collisions deterministically", () => { const s = new Slugger(); assert.equal(s.slug("Notes"), "notes"); assert.equal(s.slug("notes"), "notes-1"); assert.equal(s.slug("NOTES"), "notes-2"); }); it("falls back to 'section' when nothing survives", () => { const s = new Slugger(); assert.equal(s.slug("†‡"), "section"); assert.equal(s.slug("***"), "section-1"); }); it("is a pure function of the sequence, not of time", () => { const a = new Slugger(); const b = new Slugger(); for (const text of ["A", "A", "B", "A"]) assert.equal(a.slug(text), b.slug(text)); }); }); describe("stripInline (heading display text)", () => { const CASES: [string, string][] = [ ["Plain heading", "Plain heading"], ["**Bold** heading", "Bold heading"], ["`code` heading", "code heading"], ["A [[Wikilink]] here", "A Wikilink here"], ["A [[Target|Alias]] here", "A Alias here"], ["An ![[embed.png]]", "An embed.png"], ["A [link](https://example.com)", "A link"], ["==highlighted==", "highlighted"], ["Closed ATX ##", "Closed ATX"], ["مقدمة **قصيرة**", "مقدمة قصيرة"], ]; for (const [input, expected] of CASES) { it(JSON.stringify(input), () => assert.equal(stripInline(input), expected)); } }); describe("extractHeadings", () => { it("skips frontmatter and reports 1-based source lines", () => { const md = "---\ntitle: A\n---\n# One\n\ntext\n\n## Two\n"; assert.deepEqual( extractHeadings(md).map((h) => [h.level, h.text, h.line]), [ [1, "One", 4], [2, "Two", 8], ], ); }); it("never reports a ### inside a fenced code block", () => { const md = "# Real\n\n```md\n### Not a heading\n```\n\n## Also real\n"; assert.deepEqual(extractHeadings(md).map((h) => h.text), ["Real", "Also real"]); }); it("handles nested levels and duplicate names", () => { const md = "# A\n## B\n### B\n## B\n"; assert.deepEqual( extractHeadings(md).map((h) => `${h.level}:${h.slug}`), ["1:a", "2:b", "3:b-1", "2:b-2"], ); }); it("marks a link-list section as furniture (TOC noise, still anchorable)", () => { const md = "# Real\n\nProse here.\n\n## Related\n\n- [[A]]\n- [[B]]\n"; const [real, related] = extractHeadings(md); assert.equal(real.furniture, undefined); assert.equal(related.furniture, true); }); }); describe("[[Note#Anchor]] resolution, both resolvers", () => { const md = [ "---", "title: Doc", "---", "# Doc", "", "## What's next? (draft)", "", "prose", "", "## مقدمة", "", "نص", "", "## **Bold** section", "", "## Repeat", "", "## Repeat", "", "```", "## Fenced not-a-heading", "```", "", ].join("\n"); const RESOLVES = ["Doc", "What's next? (draft)", "مقدمة", "Repeat"]; for (const anchor of RESOLVES) { it(`#${anchor} resolves in the editor AND the reading view`, () => { assert.notEqual(findHeadingLine(md, anchor), null, "editor cannot find it"); assert.notEqual(readingAnchor(md, anchor), null, "reading view cannot find it"); }); } it("is case-insensitive in both", () => { assert.notEqual(findHeadingLine(md, "what's NEXT? (draft)"), null); assert.notEqual(readingAnchor(md, "what's NEXT? (draft)"), null); }); it("never resolves to a heading inside a code fence", () => { assert.equal(findHeadingLine(md, "Fenced not-a-heading"), null); assert.equal(readingAnchor(md, "Fenced not-a-heading"), null); }); it("resolves a duplicated heading to the FIRST one in both", () => { assert.equal(findHeadingLine(md, "Repeat"), 16); const headings = extractHeadings(md); const first = headings.find((h) => h.text === "Repeat"); assert.equal(first?.line, 16); assert.equal(first?.slug, "repeat", "the first keeps the un-suffixed slug"); }); // These three were pinned here as KNOWN BUGS until shared/headings.ts made // the editor, the outline, the anchor table and the reading view read one // rule. They now assert the agreement. it("emphasis: both spellings resolve in both views", () => { // The editor used to match only the RAW source ("**Bold** section") and // the reading view only the display text ("Bold section"). for (const spelling of ["**Bold** section", "Bold section"]) { assert.equal(findHeadingLine(md, spelling), 14, spelling); assert.equal(readingAnchor(md, spelling), "Bold section", spelling); } assert.ok(editorHeadings(md).includes("Bold section"), "the completion offers what the reader sees"); }); it("an indented heading is a heading for the editor AND the reader", () => { // CommonMark allows up to three spaces; the reading view and the outline // used to anchor `#` to column 0, so the editor offered a link that // landed nowhere. const indented = "# Top\n\n ## Indented\n\ntext\n"; assert.equal(findHeadingLine(indented, "Indented"), 3); assert.deepEqual(editorHeadings(indented), ["Top", "Indented"]); assert.deepEqual(extractHeadings(indented).map((h) => h.text), ["Top", "Indented"]); assert.equal(readingAnchor(indented, "Indented"), "Indented"); // Four spaces is indented code, in every view. assert.deepEqual(editorHeadings(" ## Code\n"), []); assert.deepEqual(extractHeadings(" ## Code\n"), []); }); it("a closed-ATX heading is reachable by its title from the editor", () => { const closed = "# Top\n\n## Notes ##\n\ntext\n"; assert.equal(readingAnchor(closed, "Notes"), "Notes"); assert.equal(findHeadingLine(closed, "Notes"), 3); }); it("a YAML `# comment` in the frontmatter is never offered as a heading", () => { const withComment = "---\n# a YAML comment\ntitle: T\n---\n# Real\n"; assert.deepEqual(editorHeadings(withComment), ["Real"]); assert.equal(findHeadingLine(withComment, "a YAML comment"), null); assert.deepEqual(extractHeadings(withComment).map((h) => h.text), ["Real"]); }); it("an empty or whitespace anchor resolves to nothing", () => { assert.equal(findHeadingLine(md, ""), null); assert.equal(findHeadingLine(md, " "), null); }); });