From 2e0dd67671000715abb3eedad61dea991cf39606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Wed, 26 Nov 2025 11:58:14 +0100 Subject: [PATCH] Add Table of Contents for posts --- astro.config.mjs | 38 ++++++++++++++++++++ package-lock.json | 72 ++++++++++++++++++++++++++++++++++++++ package.json | 3 ++ src/pages/[slug].astro | 2 +- src/styles/global.css | 79 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) diff --git a/astro.config.mjs b/astro.config.mjs index 0db95503..08f8e582 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -11,6 +11,10 @@ import mdx from "@astrojs/mdx"; import sitemap from "@astrojs/sitemap"; import mermaid from "astro-mermaid"; +import rehypeSlug from "rehype-slug"; +import rehypeAutolinkHeadings from "rehype-autolink-headings"; +import rehypeToc from "rehype-toc"; + // https://astro.build/config export default defineConfig({ site: 'https://thedevexchange.com', @@ -26,6 +30,40 @@ export default defineConfig({ trailingSlash: 'always', + + markdown: { + rehypePlugins: [ + rehypeSlug, + [rehypeAutolinkHeadings, { behavior: 'append' }], + // @ts-ignore + [rehypeToc, { + headings: ['h1', 'h2', 'h3'], + cssClasses: { + toc: 'toc-sidebar', + link: 'toc-link', + }, + nav: false, + customizeTOC: (toc) => { + // Add "Table of Contents" heading + return { + type: 'element', + tagName: 'nav', + properties: { className: ['toc-container', 'mt-5'], ariaLabel: 'Table of Contents' }, + children: [ + { + type: 'element', + tagName: 'div', + properties: { className: ['toc-header'] }, + children: [{ type: 'text', value: 'Table of Contents', }] + }, + toc + ] + }; + } + }], + ], + }, + image: { service: { entrypoint: 'astro/assets/services/sharp', diff --git a/package-lock.json b/package-lock.json index dfb346f8..af4217c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,9 @@ "mermaid": "^11.12.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "rehype-autolink-headings": "^7.1.0", + "rehype-slug": "^6.0.0", + "rehype-toc": "^3.0.2", "tailwindcss": "^4.1.8" }, "devDependencies": { @@ -1459,6 +1462,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/rehype-toc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@jsdevtools/rehype-toc/-/rehype-toc-3.0.2.tgz", + "integrity": "sha512-n5JEf16Wr4mdkRMZ8wMP/wN9/sHmTjRPbouXjJH371mZ2LEGDl72t8tEsMRNFerQN/QJtivOxqK1frdGa4QK5Q==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz", @@ -4519,6 +4531,19 @@ "url": "https://opencollective.com/unified" } }, + "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", @@ -7074,6 +7099,24 @@ "url": "https://opencollective.com/unified" } }, + "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-expressive-code": { "version": "0.41.2", "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.41.2.tgz", @@ -7128,6 +7171,23 @@ "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/rehype-stringify": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", @@ -7143,6 +7203,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-toc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rehype-toc/-/rehype-toc-3.0.2.tgz", + "integrity": "sha512-DMt376+4i1KJGgHJL7Ezd65qKkJ7Eqp6JSB47BJ90ReBrohI9ufrornArM6f4oJjP2E2DVZZHufWucv/9t7GUQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/rehype-toc": "3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/package.json b/package.json index 35a6d5f6..19a22d78 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "mermaid": "^11.12.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "rehype-autolink-headings": "^7.1.0", + "rehype-slug": "^6.0.0", + "rehype-toc": "^3.0.2", "tailwindcss": "^4.1.8" }, "devDependencies": { diff --git a/src/pages/[slug].astro b/src/pages/[slug].astro index a80e94f1..f9a43041 100644 --- a/src/pages/[slug].astro +++ b/src/pages/[slug].astro @@ -68,7 +68,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);

By {post.data.author}

-

{dateFormat(post.data.released)} • {timeToRead(post)}min read

+

{dateFormat(post.data.released)} • {timeToRead(post)} min read

diff --git a/src/styles/global.css b/src/styles/global.css index a2302188..544f74de 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -101,4 +101,83 @@ .prose .expressive-code { margin-bottom: 1.25em; } + + /* Table of Contents Styles */ + .toc-container { + border: var(--zag-stroke) solid; + border-color: var(--color-zag-dark-muted); + padding: 1rem; + margin: 1.5rem 0; + background-color: var(--color-neutral-50); + + &:where(.dark, .dark *) { + border-color: var(--color-zag-light-muted); + background-color: var(--color-neutral-800); + } + + /* Mobile: inline */ + @media (max-width: 1279px) { + display: block; + width: 100%; + } + + /* Desktop: sidebar on the right, positioned outside content */ + @media (min-width: 90rem) { + position: fixed; + top: 5rem; + right: max(1rem, calc((100vw - 80rem) / 2 - 4rem)); + width: 16rem; + margin: 0; + max-height: calc(100vh - 10rem); + overflow-y: auto; + } + + /* Extra wide screens: position it further out */ + @media (min-width: 96rem) { + right: max(2rem, calc((100vw - 90rem) / 2 - 1rem)); + width: 18rem; + } + } + + .toc-header { + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + color: var(--color-zag-dark-muted); + + &:where(.dark, .dark *) { + color: var(--color-zag-light-muted); + } + } + + .toc-sidebar { + margin: 0; + font-size: 0.875rem; + line-height: 1.6; + + li { + list-style: none; + } + } + + .toc-link { + color: var(--color-zag-dark); + text-decoration: none; + transition: color var(--zag-transition-duration) var(--zag-transition-timing-function); + + &:hover { + color: var(--color-zag-accent-dark); + text-decoration: underline; + } + + &:where(.dark, .dark *) { + color: var(--color-zag-light); + + &:hover { + color: var(--color-zag-accent-light); + } + } + } }