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

@@ -149,7 +149,7 @@ html {
background-color: theme(colors.neutral.900);
}
pre > code {
pre>code {
background-color: transparent !important;
padding: 0;
font-size: 0.875rem;
@@ -259,6 +259,7 @@ html {
}
@keyframes bounce-subtle {
0%,
100% {
transform: translateY(0);
@@ -329,6 +330,7 @@ html {
/* Component styles */
@layer components {
/* Section container - uses CSS vars, keep here */
.section-container {
max-width: calc(var(--content-max-width) + var(--sidebar-width) + 4rem);
@@ -393,19 +395,20 @@ html {
color: theme(colors.neutral.400);
}
/* Nested ordered list counters (spec numbering: 1., 1.1., 1.2.) */
/* Spec clauses - ordered list with CSS counters and hover anchor links */
.prose-spec ol {
padding-left: 2.5rem;
counter-reset: item;
list-style: none;
}
.prose-spec ol > li {
.prose-spec ol>li {
counter-increment: item;
position: relative;
margin-bottom: 0.5rem;
}
.prose-spec ol > li::before {
.prose-spec ol>li::before {
content: counters(item, ".") ".";
position: absolute;
left: -2.5rem;
@@ -415,10 +418,50 @@ html {
color: theme(colors.slate.400);
}
.dark .prose-spec ol > li::before {
.dark .prose-spec ol>li::before {
color: theme(colors.neutral.500);
}
/* Invisible anchor link that appears on hover */
.prose-spec .clause-link {
position: absolute;
left: -3.5rem;
top: 0.125rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
text-decoration: none;
}
.prose-spec .clause-link::before {
content: "";
display: block;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.dark .prose-spec .clause-link::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='%23737373'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E");
}
/* Show anchor link on hover - only when not hovering nested lists */
.prose-spec ol>li:hover:not(:has(ol:hover))>.clause-link,
.prose-spec ol>li:hover:not(:has(ol:hover))>p>.clause-link,
.prose-spec .clause-link:hover {
opacity: 1;
}
.prose-spec .clause-link:hover::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='%23f97316'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E");
}
.prose-spec img {
max-width: 100%;
height: auto;

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;
}
}