mirror of
https://github.com/jimeh/commonflow.org.git
synced 2026-02-19 05:46:40 +00:00
wip: anchor links on every spec clause
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user