Commit bc875d7f by PLN (Algolia)

test: article anatomy — heading ids, TOC, margin clones, neighbors

parent 12387c68
import fs from "fs";
import path from "path";
import { rehypeHeadingIds, rehypeMarginNotes, buildTocFromHtml } from "@/lib/markdown";
import { getBlogNeighbors } from "@/lib/blog";
// The unified chain is ESM-only and does not load under jest (see the note in
// blog-drafts.test.js). These tests exercise the rehype plugins directly on
// hand-built hast trees, so the ESM packages are stubbed at the module gate.
// The module builds its processor at import time — give it a chainable stub.
jest.mock("unified", () => ({
unified: () => {
const chain = { use: () => chain };
return chain;
},
}));
jest.mock("remark-parse", () => ({}));
jest.mock("remark-gfm", () => ({}));
jest.mock("remark-directive", () => ({}));
jest.mock("remark-rehype", () => ({}));
jest.mock("rehype-pretty-code", () => ({}));
jest.mock("rehype-stringify", () => ({}));
// A faithful-enough stand-in for the real traversal: elements first, then
// their children, with in-place replacement semantics matching unist visit.
jest.mock("unist-util-visit", () => ({
visit: (tree, _type, fn) => {
const walk = (node, index, parent) => {
if (node.type === "element") fn(node, index, parent);
node.children?.forEach((child, i) => walk(child, i, node));
};
walk(tree, null, null);
},
}));
const el = (tagName, properties = {}, children = []) => ({
type: "element",
tagName,
properties,
children,
});
const text = (value) => ({ type: "text", value });
/**
* Article anatomy — the build-time pieces of the article page: heading
* anchors, TOC extraction, footnote margin clones, and date-ordered
* neighbours.
*/
describe("rehypeHeadingIds", () => {
it("mints GitHub-style ids on h1–h4, folding diacritics and inline markup", () => {
const tree = {
type: "root",
children: [
el("h2", {}, [text("Résumé & méthode: "), el("em", {}, [text("le plan")])]),
el("h3", {}, [text("Deep dive")]),
el("h4", {}, [text("Too deep")]),
],
};
rehypeHeadingIds()(tree);
expect(tree.children.map((n) => n.properties.id)).toEqual([
"resume-methode-le-plan",
"deep-dive",
"too-deep",
]);
});
it("dedupes repeated heading slugs", () => {
const tree = {
type: "root",
children: [el("h2", {}, [text("Notes")]), el("h2", {}, [text("Notes")])],
};
rehypeHeadingIds()(tree);
expect(tree.children.map((n) => n.properties.id)).toEqual(["notes", "notes-1"]);
});
});
describe("buildTocFromHtml", () => {
it("reads h2/h3 back from rendered HTML, stripping inline tags and entities", () => {
const html =
'<h1 id="top">Top</h1>' +
'<h2 id="resume-methode-le-plan">Résumé &amp; méthode: <em>le plan</em></h2>' +
'<h3 id="deep-dive">Deep <code>dive</code></h3>' +
'<h4 id="too-deep">Too deep</h4>' +
'<h2 id="deuxieme-section">Deuxième &#x26; dernière</h2>';
expect(buildTocFromHtml(html)).toEqual([
{ id: "resume-methode-le-plan", text: "Résumé & méthode: le plan", depth: 2 },
{ id: "deep-dive", text: "Deep dive", depth: 3 },
{ id: "deuxieme-section", text: "Deuxième & dernière", depth: 2 },
]);
});
it("returns an empty TOC for posts without h2/h3", () => {
expect(buildTocFromHtml("<p>Only paragraphs.</p>")).toEqual([]);
});
});
describe("rehypeMarginNotes", () => {
const refSup = (noteId, refId) =>
el("sup", {}, [
el("a", {
href: `#user-content-fn-${noteId}`,
id: `user-content-fnref-${refId}`,
dataFootnoteRef: true,
}, [text(String(noteId))]),
]);
const defLi = (noteId, backrefId) =>
el("li", { id: `user-content-fn-${noteId}` }, [
el("p", {}, [
text("First note with a "),
el("a", { href: "https://example.com" }, [text("link")]),
text(". "),
el("a", { href: `#user-content-fnref-${backrefId}`, dataFootnoteBackref: "" }, [text("↩")]),
]),
el("ul", {}, [el("li", {}, [text("alpha")])]),
]);
const treeWith = (sups, defs) => ({
type: "root",
children: [
el("p", {}, [text("Para with "), ...sups, text(".")]),
el("section", { dataFootnotes: true }, [el("ol", {}, defs)]),
],
});
it("wraps each reference with an aria-hidden inline clone of its definition", () => {
const sup = refSup(1, 1);
const tree = treeWith([sup], [defLi(1, 1)]);
rehypeMarginNotes()(tree);
const wrapper = tree.children[0].children[1];
expect(wrapper.tagName).toBe("span");
expect(wrapper.properties.className).toEqual(["prose-fn"]);
expect(wrapper.children[0]).toBe(sup); // the original ref stays clickable
const body = wrapper.children[1];
expect(body.tagName).toBe("span");
expect(body.properties.className).toEqual(["prose-fn-body"]);
expect(body.properties.ariaHidden).toBe("true");
// Clone content: p → span block, link survives, backref dropped,
// ul → block with item spans.
const [block, list] = body.children;
expect(block.tagName).toBe("span");
expect(block.properties.className).toEqual(["prose-fn-block"]);
const serialized = JSON.stringify(block);
expect(serialized).toContain("https://example.com");
expect(serialized).not.toContain("dataFootnoteBackref");
expect(list.children[0].properties.className).toEqual(["prose-fn-item"]);
expect(JSON.stringify(list)).toContain("alpha");
});
it("clones once per reference, even to the same note", () => {
const tree = treeWith([refSup(1, 1), refSup(1, "1-2")], [defLi(1, 1)]);
rehypeMarginNotes()(tree);
const wrappers = tree.children[0].children.filter(
(n) => n.type === "element" && n.properties?.className?.includes("prose-fn"),
);
expect(wrappers).toHaveLength(2);
for (const wrapper of wrappers) expect(wrapper.children[0].tagName).toBe("sup");
});
it("leaves the endnote section untouched as the accessible source", () => {
const tree = treeWith([refSup(1, 1)], [defLi(1, 1)]);
rehypeMarginNotes()(tree);
expect(tree.children[1].children[0].children[0]).toMatchObject({
tagName: "li",
properties: { id: "user-content-fn-1" },
});
});
});
describe("getBlogNeighbors — prev/next by date", () => {
/**
* A deterministic trio with far-out dates, so assertions hold regardless
* of the other suites' fixtures. Removed with the suite.
*/
const DIR = path.join(process.cwd(), "content", "blog");
const TRIO = [
["_fixture-neighbors-alpha", "2046-01-01", true],
["_fixture-neighbors-beta", "2045-01-01", true],
["_fixture-neighbors-gamma", "2044-01-01", false], // fr-only: the fallback case
];
beforeAll(() => {
for (const [slug, date, bilingual] of TRIO) {
const dir = path.join(DIR, slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "meta.md"),
`---\ntitle: ${slug.replace("_fixture-neighbors-", "")}\ndate: '${date}'\ndraft: false\n---\n`,
);
fs.writeFileSync(path.join(dir, "fr.md"), "---\ntitle: x\n---\n\nbody\n");
if (bilingual) {
fs.writeFileSync(path.join(dir, "en.md"), "---\ntitle: x\n---\n\nbody\n");
}
}
});
afterAll(() => {
for (const [slug] of TRIO) fs.rmSync(path.join(DIR, slug), { recursive: true, force: true });
});
it("orders older/newer around the current post (newest-first listing)", () => {
const { older, newer } = getBlogNeighbors("_fixture-neighbors-alpha");
expect(newer).toBeNull(); // alpha is the newest post on disk
expect(older).toMatchObject({ slug: "_fixture-neighbors-beta" });
});
it("links both sides for a middle post", () => {
const { older, newer } = getBlogNeighbors("_fixture-neighbors-beta");
expect(newer).toMatchObject({ slug: "_fixture-neighbors-alpha" });
expect(older).toMatchObject({ slug: "_fixture-neighbors-gamma" });
});
it("points at the requested language when the target post has it", () => {
const { newer } = getBlogNeighbors("_fixture-neighbors-beta", "en");
expect(newer).toMatchObject({
slug: "_fixture-neighbors-alpha",
href: "/blog/_fixture-neighbors-alpha/en",
});
});
it("falls back to the canonical URL when the target lacks the language", () => {
// gamma is fr-only; an en reader still lands on its canonical post.
const { older } = getBlogNeighbors("_fixture-neighbors-beta", "en");
expect(older).toMatchObject({ slug: "_fixture-neighbors-gamma", href: "/blog/_fixture-neighbors-gamma" });
});
it("returns nulls for slugs outside the visible listing", () => {
expect(getBlogNeighbors("no-such-post")).toEqual({ older: null, newer: null });
});
});
......@@ -17,9 +17,10 @@ import { parseShareId, shareTokenOf, getDraftPost } from "@/lib/content";
// The unified/shiki markdown chain is ESM-only and does not load under jest
// (see jest.config.js note); these tests exercise the folder accessors, not
// rendering, so stub the renderer.
// rendering, so stub the renderer and its TOC read-back.
jest.mock("@/lib/markdown", () => ({
renderMarkdown: jest.fn(async (source) => source),
buildTocFromHtml: jest.fn(() => []),
}));
/**
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment