rework/lightweight #12

Merged
GauthierWebDev merged 106 commits from rework/lightweight into main 2025-04-21 16:27:38 +00:00
5 changed files with 231 additions and 4 deletions
Showing only changes of commit 2cb7827938 - Show all commits

View File

@ -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=="],

View File

@ -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
View 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;
}

View File

@ -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",

View 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>
);
}