diff --git a/app/bun.lock b/app/bun.lock index 2831bbc..05ec320 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -5,6 +5,7 @@ "dependencies": { "@fastify/middie": "^9.0.3", "@fastify/static": "^8.1.1", + "@sindresorhus/slugify": "^2.2.1", "@universal-middleware/core": "^0.4.7", "@universal-middleware/fastify": "^0.5.16", "clsx": "^2.1.1", @@ -261,6 +262,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ=="], + "@sindresorhus/slugify": ["@sindresorhus/slugify@2.2.1", "", { "dependencies": { "@sindresorhus/transliterate": "^1.0.0", "escape-string-regexp": "^5.0.0" } }, "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw=="], + + "@sindresorhus/transliterate": ["@sindresorhus/transliterate@1.6.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.4", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.4" } }, "sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.4", "@tailwindcss/oxide-darwin-arm64": "4.1.4", "@tailwindcss/oxide-darwin-x64": "4.1.4", "@tailwindcss/oxide-freebsd-x64": "4.1.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.4", "@tailwindcss/oxide-linux-arm64-musl": "4.1.4", "@tailwindcss/oxide-linux-x64-gnu": "4.1.4", "@tailwindcss/oxide-linux-x64-musl": "4.1.4", "@tailwindcss/oxide-wasm32-wasi": "4.1.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.4", "@tailwindcss/oxide-win32-x64-msvc": "4.1.4" } }, "sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ=="], @@ -851,6 +856,10 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + "@sindresorhus/slugify/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "@sindresorhus/transliterate/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], diff --git a/app/layouts/DocsLayout.tsx b/app/layouts/DocsLayout.tsx index 8c89626..14afe88 100644 --- a/app/layouts/DocsLayout.tsx +++ b/app/layouts/DocsLayout.tsx @@ -1,11 +1,10 @@ import { type Node } from "@markdoc/markdoc"; import { TableOfContents } from "@syntax/TableOfContents"; -import { PrevNextLinks } from "@syntax/PrevNextLinks"; +import { PrevNextLinks } from "@/components/PrevNextLinks"; import { collectSections } from "@/lib/sections"; import { DocsHeader } from "@syntax/DocsHeader"; -import { Prose } from "@syntax/Prose"; -import React from "react"; +import { Prose } from "@/components/Prose"; export function DocsLayout({ children, @@ -22,7 +21,7 @@ export function DocsLayout({ return ( <> -
+
; +}; + +export function collectSections( + nodes: Array, + slugify = slugifyWithCounter(), +) { + const sections: Array
= []; + + for (const node of nodes) { + if (isH2Node(node) || isH3Node(node)) { + const title = getNodeText(node); + if (title) { + const id = slugify(title); + + if (isH3Node(node)) { + if (!sections[sections.length - 1]) { + throw new Error( + "Cannot add `h3` to table of contents without a preceding `h2`", + ); + } + sections[sections.length - 1].children.push({ + ...node.attributes, + id, + title, + }); + } else { + sections.push({ ...node.attributes, id, title, children: [] }); + } + } + } + + sections.push(...collectSections(node.children ?? [], slugify)); + } + + return sections; +} diff --git a/app/package.json b/app/package.json index a21cbf1..bd23aa1 100755 --- a/app/package.json +++ b/app/package.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/middie": "^9.0.3", "@fastify/static": "^8.1.1", + "@sindresorhus/slugify": "^2.2.1", "@universal-middleware/core": "^0.4.7", "@universal-middleware/fastify": "^0.5.16", "clsx": "^2.1.1", diff --git a/app/partials/TableOfContents.tsx b/app/partials/TableOfContents.tsx new file mode 100644 index 0000000..e691060 --- /dev/null +++ b/app/partials/TableOfContents.tsx @@ -0,0 +1,118 @@ +import type { Section, Subsection } from "@/libs/sections"; + +import { createSignal, createEffect, For } from "solid-js"; +import { Link } from "@/components/Link"; +import clsx from "clsx"; + +type TableOfContentsProps = { + tableOfContents: Array
; +}; + +export function TableOfContents(props: TableOfContentsProps) { + const [currentSection, setCurrentSection] = createSignal( + props.tableOfContents[0]?.id, + ); + + const getHeadings = (tableOfContents: Array
) => { + return tableOfContents + .flatMap((node) => [node.id, ...node.children.map((child) => child.id)]) + .map((id) => { + const el = document.getElementById(id); + if (!el) return null; + + const style = window.getComputedStyle(el); + const scrollMt = Number.parseFloat(style.scrollMarginTop); + + const top = window.scrollY + el.getBoundingClientRect().top - scrollMt; + return { id, top }; + }) + .filter((x): x is { id: string; top: number } => x !== null); + }; + + createEffect(() => { + if (props.tableOfContents.length === 0) return; + const headings = getHeadings(props.tableOfContents); + + function onScroll() { + const top = window.scrollY; + + let current = headings[0]?.id; + + for (const heading of headings) { + if (top < heading.top - 10) break; + current = heading.id; + } + setCurrentSection(current); + } + window.addEventListener("scroll", onScroll, { passive: true }); + onScroll(); + return () => { + window.removeEventListener("scroll", onScroll); + }; + }, [getHeadings, props.tableOfContents]); + + function isActive(section: Section | Subsection) { + if (section.id === currentSection()) return true; + if (!section.children) return false; + + return section.children.findIndex(isActive) > -1; + } + + return ( + + ); +}