feat(website): add anchor links to docs headings

Hover a heading to reveal a # link on the left that updates the URL
hash without layout shift, matching GitHub README behavior.
This commit is contained in:
Mohamed Boudra
2026-05-01 21:11:14 +07:00
parent f2a23039cb
commit 65b814c7dc
6 changed files with 151 additions and 2 deletions
+82
View File
@@ -24760,6 +24760,12 @@
"node": ">=6"
}
},
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
"license": "ISC"
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -25201,6 +25207,32 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-heading-rank": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz",
"integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-is-element": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -25228,6 +25260,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -33575,6 +33620,41 @@
"regjsparser": "bin/parser"
}
},
"node_modules/rehype-autolink-headings": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz",
"integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-is-element": "^3.0.0",
"unified": "^11.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-slug": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz",
"integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"github-slugger": "^2.0.0",
"hast-util-heading-rank": "^3.0.0",
"hast-util-to-string": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -39129,6 +39209,8 @@
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"wrangler": "^4.59.1"
},
"devDependencies": {
+2
View File
@@ -20,6 +20,8 @@
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"wrangler": "^4.59.1"
},
"devDependencies": {
+20
View File
@@ -0,0 +1,20 @@
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSlug from "rehype-slug";
import type { PluggableList } from "unified";
export const docsRehypePlugins: PluggableList = [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "prepend",
properties: { className: "heading-anchor", ariaLabel: "Link to this section" },
content: {
type: "element",
tagName: "span",
properties: { className: "heading-anchor-icon", ariaHidden: "true" },
children: [{ type: "text", value: "#" }],
},
},
],
];
+2 -1
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import ReactMarkdown from "react-markdown";
import { getDoc } from "~/docs";
import { docsRehypePlugins } from "~/docs-rehype";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/$")({
@@ -24,5 +25,5 @@ function DocsPage() {
return <p className="text-muted-foreground">Doc not found.</p>;
}
return <ReactMarkdown>{doc.content}</ReactMarkdown>;
return <ReactMarkdown rehypePlugins={docsRehypePlugins}>{doc.content}</ReactMarkdown>;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import ReactMarkdown from "react-markdown";
import { getDoc } from "~/docs";
import { docsRehypePlugins } from "~/docs-rehype";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/")({
@@ -17,5 +18,5 @@ export const Route = createFileRoute("/docs/")({
function DocsIndex() {
const doc = getDoc("");
if (!doc) return <p className="text-muted-foreground">Doc not found.</p>;
return <ReactMarkdown>{doc.content}</ReactMarkdown>;
return <ReactMarkdown rehypePlugins={docsRehypePlugins}>{doc.content}</ReactMarkdown>;
}
+43
View File
@@ -94,6 +94,16 @@
margin-top: 0;
}
.docs-prose h1,
.docs-prose h2,
.docs-prose h3,
.docs-prose h4,
.docs-prose h5,
.docs-prose h6 {
position: relative;
scroll-margin-top: 1.5rem;
}
.docs-prose h1 {
color: var(--color-foreground);
font-size: 1.875rem;
@@ -121,6 +131,39 @@
margin-bottom: 0.75rem;
}
.docs-prose .heading-anchor {
position: absolute;
right: 100%;
top: 0;
bottom: 0;
display: inline-flex;
align-items: center;
padding-right: 0.375rem;
text-decoration: none;
color: rgba(255, 255, 255, 0.4);
opacity: 0;
transition: opacity 0.15s ease;
}
.docs-prose .heading-anchor:hover {
color: var(--color-foreground);
}
.docs-prose h1:hover .heading-anchor,
.docs-prose h2:hover .heading-anchor,
.docs-prose h3:hover .heading-anchor,
.docs-prose h4:hover .heading-anchor,
.docs-prose h5:hover .heading-anchor,
.docs-prose h6:hover .heading-anchor,
.docs-prose .heading-anchor:focus {
opacity: 1;
}
.docs-prose .heading-anchor-icon {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 400;
}
.docs-prose p {
margin-top: 1rem;
margin-bottom: 1rem;