feat(app): Add link components and sections collection
This commit is contained in:
parent
8810e9759a
commit
2cb7827938
@ -5,6 +5,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/middie": "^9.0.3",
|
"@fastify/middie": "^9.0.3",
|
||||||
"@fastify/static": "^8.1.1",
|
"@fastify/static": "^8.1.1",
|
||||||
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@universal-middleware/core": "^0.4.7",
|
"@universal-middleware/core": "^0.4.7",
|
||||||
"@universal-middleware/fastify": "^0.5.16",
|
"@universal-middleware/fastify": "^0.5.16",
|
||||||
"clsx": "^2.1.1",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { type Node } from "@markdoc/markdoc";
|
import { type Node } from "@markdoc/markdoc";
|
||||||
|
|
||||||
import { TableOfContents } from "@syntax/TableOfContents";
|
import { TableOfContents } from "@syntax/TableOfContents";
|
||||||
import { PrevNextLinks } from "@syntax/PrevNextLinks";
|
import { PrevNextLinks } from "@/components/PrevNextLinks";
|
||||||
import { collectSections } from "@/lib/sections";
|
import { collectSections } from "@/lib/sections";
|
||||||
import { DocsHeader } from "@syntax/DocsHeader";
|
import { DocsHeader } from "@syntax/DocsHeader";
|
||||||
import { Prose } from "@syntax/Prose";
|
import { Prose } from "@/components/Prose";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export function DocsLayout({
|
export function DocsLayout({
|
||||||
children,
|
children,
|
||||||
@ -22,7 +21,7 @@ export function DocsLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-2xl min-w-0 flex-auto px-4 py-16 lg:max-w-none lg:pr-0 lg:pl-8 xl:px-16 grow">
|
<div class="max-w-2xl min-w-0 flex-auto px-4 py-16 lg:max-w-none lg:pr-0 lg:pl-8 xl:px-16 grow">
|
||||||
<article>
|
<article>
|
||||||
<DocsHeader
|
<DocsHeader
|
||||||
title={title}
|
title={title}
|
||||||
|
|||||||
100
app/libs/sections.ts
Normal file
100
app/libs/sections.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import type { Node } from "@markdoc/markdoc";
|
||||||
|
|
||||||
|
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
||||||
|
|
||||||
|
interface HeadingNode extends Node {
|
||||||
|
type: "heading";
|
||||||
|
attributes: {
|
||||||
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type H2Node = HeadingNode & {
|
||||||
|
attributes: {
|
||||||
|
level: 2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type H3Node = HeadingNode & {
|
||||||
|
attributes: {
|
||||||
|
level: 3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function isHeadingNode(node: Node): node is HeadingNode {
|
||||||
|
return (
|
||||||
|
node.type === "heading" &&
|
||||||
|
[1, 2, 3, 4, 5, 6].includes(node.attributes.level) &&
|
||||||
|
(typeof node.attributes.id === "string" ||
|
||||||
|
typeof node.attributes.id === "undefined")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isH2Node(node: Node): node is H2Node {
|
||||||
|
return isHeadingNode(node) && node.attributes.level === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isH3Node(node: Node): node is H3Node {
|
||||||
|
return isHeadingNode(node) && node.attributes.level === 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeText(node: Node) {
|
||||||
|
let text = "";
|
||||||
|
|
||||||
|
for (const child of node.children ?? []) {
|
||||||
|
if (child.type === "text") {
|
||||||
|
text += child.attributes.content;
|
||||||
|
}
|
||||||
|
text += getNodeText(child);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Subsection = H3Node["attributes"] & {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Section = H2Node["attributes"] & {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children: Array<Subsection>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function collectSections(
|
||||||
|
nodes: Array<Node>,
|
||||||
|
slugify = slugifyWithCounter(),
|
||||||
|
) {
|
||||||
|
const sections: Array<Section> = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/middie": "^9.0.3",
|
"@fastify/middie": "^9.0.3",
|
||||||
"@fastify/static": "^8.1.1",
|
"@fastify/static": "^8.1.1",
|
||||||
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@universal-middleware/core": "^0.4.7",
|
"@universal-middleware/core": "^0.4.7",
|
||||||
"@universal-middleware/fastify": "^0.5.16",
|
"@universal-middleware/fastify": "^0.5.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
118
app/partials/TableOfContents.tsx
Normal file
118
app/partials/TableOfContents.tsx
Normal file
@ -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<Section>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TableOfContents(props: TableOfContentsProps) {
|
||||||
|
const [currentSection, setCurrentSection] = createSignal(
|
||||||
|
props.tableOfContents[0]?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getHeadings = (tableOfContents: Array<Section>) => {
|
||||||
|
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 (
|
||||||
|
<div class="hidden xl:sticky xl:top-[4.75rem] xl:-mr-6 xl:block xl:h-[calc(100vh-4.75rem)] xl:flex-none xl:overflow-y-auto xl:py-16 xl:pr-6">
|
||||||
|
<nav aria-labelledby="on-this-page-title" class="w-56">
|
||||||
|
{props.tableOfContents.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2
|
||||||
|
id="on-this-page-title"
|
||||||
|
class="font-display text-sm font-medium text-slate-900 dark:text-white"
|
||||||
|
>
|
||||||
|
Table des matières
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ol class="mt-4 space-y-3 text-sm">
|
||||||
|
<For each={props.tableOfContents}>
|
||||||
|
{(section) => (
|
||||||
|
<li>
|
||||||
|
<h3>
|
||||||
|
<Link
|
||||||
|
href={`#${section.id}`}
|
||||||
|
class={clsx(
|
||||||
|
isActive(section)
|
||||||
|
? "text-violet-500"
|
||||||
|
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
{section.children.length > 0 && (
|
||||||
|
<ol class="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
|
||||||
|
<For each={section.children}>
|
||||||
|
{(subSection) => (
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href={`#${subSection.id}`}
|
||||||
|
class={
|
||||||
|
isActive(subSection)
|
||||||
|
? "text-violet-500"
|
||||||
|
: "hover:text-slate-600 dark:hover:text-slate-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{subSection.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user