wip: anchor links on every spec clause

This commit is contained in:
2026-01-11 09:50:36 +00:00
parent 164729b57e
commit 9a2ae93ccf
11 changed files with 463 additions and 401 deletions

View File

@@ -6,7 +6,14 @@ import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import type { Root, RootContent, Heading, List, ListItem } from "mdast";
import type {
Root,
RootContent,
Heading,
List,
ListItem,
Html,
} from "mdast";
import type { Root as HastRoot } from "hast";
export interface TocItem {
@@ -186,11 +193,12 @@ function findSpecSections(nodes: RootContent[]): SpecSection[] {
const titles = extractListItemTitles(node as List);
for (let i = 0; i < titles.length; i++) {
const title = titles[i];
const clauseNum = i + 1;
sections.push({
id: `spec-${slugify(title)}`,
id: `clause-${clauseNum}`,
title,
content: "",
clause: `${i + 1}.`,
clause: `${clauseNum}.`,
});
}
break; // Only process first ordered list
@@ -201,33 +209,44 @@ function findSpecSections(nodes: RootContent[]): SpecSection[] {
}
/**
* Add anchor IDs to list items in the spec ordered list
* Add anchor IDs and links to ordered list items recursively.
* Injects an invisible anchor link before content for hover-to-reveal behavior.
*/
function addAnchorsToList(list: List, sections: SpecSection[]): void {
const titleMap = new Map(sections.map((s) => [s.title, s.id]));
for (const item of list.children) {
function addClauseAnchors(list: List, prefix: string = ""): void {
for (let i = 0; i < list.children.length; i++) {
const item = list.children[i];
if (item.type !== "listItem") continue;
// Get the title of this item
let title = "";
// Calculate clause number and ID
const clauseNum = prefix ? `${prefix}.${i + 1}` : `${i + 1}`;
const clauseId = `clause-${clauseNum.replace(/\./g, "-")}`;
// Add ID to the list item via hProperties
(item as ListItem & { data?: { hProperties?: { id?: string } } }).data = {
hProperties: { id: clauseId },
};
// Find the first paragraph in the item and prepend an anchor link
for (const child of item.children) {
if (child.type === "list") break;
if (child.type === "paragraph") {
title = extractText(child).split("\n")[0].trim();
// Create anchor link HTML to inject
const anchorHtml: Html = {
type: "html",
value: `<a href="#${clauseId}" class="clause-link" aria-hidden="true"></a>`,
};
// Prepend anchor to paragraph children
(child as { children: RootContent[] }).children.unshift(
anchorHtml as unknown as RootContent,
);
break;
}
title += extractText(child);
}
title = title.split("\n")[0].trim();
// Add ID as data attribute (will be processed by rehype)
const id = titleMap.get(title);
if (id) {
// Add hProperties for rehype to convert to HTML id attribute
(item as ListItem & { data?: { hProperties?: { id?: string } } }).data = {
hProperties: { id },
};
// Recursively process nested ordered lists
for (const child of item.children) {
if (child.type === "list" && (child as List).ordered) {
addClauseAnchors(child as List, clauseNum);
}
}
}
}
@@ -339,10 +358,10 @@ export async function parseSpecContent(
// Extract spec sections from the first ordered list
const specSections = findSpecSections(specNodes);
// Add anchor IDs to spec list items
// Add anchor IDs and links to spec list items
for (const node of specNodes) {
if (node.type === "list" && (node as List).ordered) {
addAnchorsToList(node as List, specSections);
addClauseAnchors(node as List);
break;
}
}