tests/anchors.test.ts
Rama main · réplica de lectura del repositorio original.
// 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);
});
});