From 65b814c7dca2a77e862da913a1da410d4f913b1e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 1 May 2026 21:11:14 +0700 Subject: [PATCH] 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. --- package-lock.json | 82 ++++++++++++++++++++++ packages/website/package.json | 2 + packages/website/src/docs-rehype.ts | 20 ++++++ packages/website/src/routes/docs/$.tsx | 3 +- packages/website/src/routes/docs/index.tsx | 3 +- packages/website/src/styles.css | 43 ++++++++++++ 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 packages/website/src/docs-rehype.ts diff --git a/package-lock.json b/package-lock.json index 4b1c496eb..6c8f108de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/packages/website/package.json b/packages/website/package.json index 69a584cd0..fe7f722a8 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -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": { diff --git a/packages/website/src/docs-rehype.ts b/packages/website/src/docs-rehype.ts new file mode 100644 index 000000000..37128d9af --- /dev/null +++ b/packages/website/src/docs-rehype.ts @@ -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: "#" }], + }, + }, + ], +]; diff --git a/packages/website/src/routes/docs/$.tsx b/packages/website/src/routes/docs/$.tsx index a538ad311..b0a056b9e 100644 --- a/packages/website/src/routes/docs/$.tsx +++ b/packages/website/src/routes/docs/$.tsx @@ -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

Doc not found.

; } - return {doc.content}; + return {doc.content}; } diff --git a/packages/website/src/routes/docs/index.tsx b/packages/website/src/routes/docs/index.tsx index 752b43aa4..0528b23ab 100644 --- a/packages/website/src/routes/docs/index.tsx +++ b/packages/website/src/routes/docs/index.tsx @@ -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

Doc not found.

; - return {doc.content}; + return {doc.content}; } diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 088ac93fe..bd672aab9 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -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;