feat: Add table of contents generation for docs pages

This commit is contained in:
Gauthier Daniels 2025-04-20 00:33:19 +02:00
parent 803da33f37
commit 2e692f7264
8 changed files with 219 additions and 210 deletions

View File

@ -1,3 +1,6 @@
import type { readingTime } from "reading-time-estimator";
import type { TableOfContents } from "./remarkHeadingId";
import { createHandler } from "@universal-middleware/fastify"; import { createHandler } from "@universal-middleware/fastify";
import { telefuncHandler } from "./server/telefunc-handler"; import { telefuncHandler } from "./server/telefunc-handler";
import { vikeHandler } from "./server/vike-handler"; import { vikeHandler } from "./server/vike-handler";
@ -11,6 +14,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const root = __dirname; const root = __dirname;
declare global {
namespace Vike {
interface PageContext {
exports: {
frontmatter?: Partial<{
title: string;
description: string;
tags: string[];
}>;
readingTime?: ReturnType<typeof readingTime>;
tableOfContents?: TableOfContents;
[key: string]: unknown;
};
}
}
}
async function startServer() { async function startServer() {
const app = Fastify(); const app = Fastify();

View File

@ -1,5 +1,3 @@
import type { readingTime } from "reading-time-estimator";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { config } from "./config"; import { config } from "./config";
@ -12,22 +10,6 @@ const root = __dirname;
const pagesDir = `${root}/dist/client`; const pagesDir = `${root}/dist/client`;
declare global {
namespace Vike {
interface PageContext {
exports: {
frontmatter?: Partial<{
title: string;
description: string;
tags: string[];
}>;
readingTime?: ReturnType<typeof readingTime>;
[key: string]: unknown;
};
}
}
}
async function startServer() { async function startServer() {
const app = Fastify(); const app = Fastify();

View File

@ -1,21 +1,22 @@
import type { JSXElement } from "solid-js"; import type { JSXElement } from "solid-js";
import { TableOfContents } from "@/partials/TableOfContents";
import { PrevNextLinks } from "@/components/PrevNextLinks"; import { PrevNextLinks } from "@/components/PrevNextLinks";
import { usePageContext } from "vike-solid/usePageContext"; import { usePageContext } from "vike-solid/usePageContext";
import { readingTime } from "reading-time-estimator"; import { clientOnly } from "vike-solid/clientOnly";
import { collectSections } from "@/libs/sections";
import { clock } from "solid-heroicons/outline"; import { clock } from "solid-heroicons/outline";
import { navigation } from "@/libs/navigation"; import { navigation } from "@/libs/navigation";
import { Prose } from "@/components/Prose"; import { Prose } from "@/components/Prose";
import { MDXProvider } from "solid-jsx"; import { MDXProvider } from "solid-jsx";
import { createSignal } from "solid-js";
import { Icon } from "solid-heroicons"; import { Icon } from "solid-heroicons";
type DocsLayoutProps = { type DocsLayoutProps = {
children: JSXElement; children: JSXElement;
}; };
const TableOfContents = clientOnly(() =>
import("@/partials/TableOfContents").then((m) => m.TableOfContents),
);
export function DocsLayout(props: DocsLayoutProps) { export function DocsLayout(props: DocsLayoutProps) {
const { const {
exports: { frontmatter, readingTime }, exports: { frontmatter, readingTime },
@ -34,7 +35,7 @@ export function DocsLayout(props: DocsLayoutProps) {
<PrevNextLinks /> <PrevNextLinks />
</div> </div>
{/* <TableOfContents tableOfContents={tableOfContents} /> */} <TableOfContents fallback={null} />
</MDXProvider> </MDXProvider>
); );
} }

View File

@ -1,100 +1,6 @@
import type { Node } from "@markdoc/markdoc"; export type Section = {
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; id: string;
title: string; title: string;
children?: undefined; level: 2 | 3;
path: string;
}; };
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;
}

9
app/pages/+data.ts Normal file
View File

@ -0,0 +1,9 @@
import type { PageContext } from "vike/types";
export type Data = Awaited<ReturnType<typeof data>>;
export async function data(pageContext: PageContext) {
return {
tableOfContents: pageContext.exports.tableOfContents,
};
}

View File

@ -1,37 +1,39 @@
import type { Section, Subsection } from "@/libs/sections"; import type { TableOfContents as TableOfContentsType } from "@/remarkHeadingId";
import type { Section } from "@/libs/sections";
import type { Data } from "@/pages/+data";
import { createSignal, createEffect, For } from "solid-js"; import { createSignal, createEffect, For } from "solid-js";
import { useData } from "vike-solid/useData";
import { Link } from "@/components/Link"; import { Link } from "@/components/Link";
import clsx from "clsx"; import clsx from "clsx";
type TableOfContentsProps = { export function TableOfContents() {
tableOfContents: Array<Section>; const { tableOfContents } = useData<Data>();
};
if (!tableOfContents) return null;
export function TableOfContents(props: TableOfContentsProps) {
const [currentSection, setCurrentSection] = createSignal( const [currentSection, setCurrentSection] = createSignal(
props.tableOfContents[0]?.id, tableOfContents[0]?.id,
); );
const getHeadings = (tableOfContents: Array<Section>) => { const getHeadings = () => {
return tableOfContents return tableOfContents
.flatMap((node) => [node.id, ...node.children.map((child) => child.id)]) .map((section) => {
.map((id) => { const el = document.getElementById(section.id);
const el = document.getElementById(id);
if (!el) return null; if (!el) return null;
const style = window.getComputedStyle(el); const style = window.getComputedStyle(el);
const scrollMt = Number.parseFloat(style.scrollMarginTop); const scrollMt = Number.parseFloat(style.scrollMarginTop);
const top = window.scrollY + el.getBoundingClientRect().top - scrollMt; const top = window.scrollY + el.getBoundingClientRect().top - scrollMt;
return { id, top }; return { id: section.id, top };
}) })
.filter((x): x is { id: string; top: number } => x !== null); .filter((x): x is { id: string; top: number } => x !== null);
}; };
createEffect(() => { createEffect(() => {
if (props.tableOfContents.length === 0) return; if (tableOfContents.length === 0) return;
const headings = getHeadings(props.tableOfContents); const headings = getHeadings();
function onScroll() { function onScroll() {
const top = window.scrollY; const top = window.scrollY;
@ -49,69 +51,46 @@ export function TableOfContents(props: TableOfContentsProps) {
return () => { return () => {
window.removeEventListener("scroll", onScroll); window.removeEventListener("scroll", onScroll);
}; };
}, [getHeadings, props.tableOfContents]); }, [getHeadings, tableOfContents]);
function isActive(section: Section | Subsection) { function isActive(section: Section) {
if (section.id === currentSection()) return true; if (section.id === currentSection()) return true;
if (!section.children) return false; return false;
// if (!section.children) return false;
return section.children.findIndex(isActive) > -1; // return section.children.findIndex(isActive) > -1;
} }
return ( 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"> <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"> <nav aria-labelledby="on-this-page-title" class="w-56">
{props.tableOfContents.length > 0 && ( <h2
<> id="on-this-page-title"
<h2 class="font-display text-sm font-medium text-slate-900"
id="on-this-page-title" >
class="font-display text-sm font-medium text-slate-900" Table des matières
> </h2>
Table des matières
</h2>
<ol class="mt-4 space-y-3 text-sm"> <ol class="mt-4 space-y-3 text-sm">
<For each={props.tableOfContents}> <For each={tableOfContents}>
{(section) => ( {(section) => (
<li> <li>
<h3> <h3>
<Link <Link
href={`#${section.id}`} href={`#${section.id}`}
class={clsx( class={clsx(
isActive(section) isActive(section)
? "text-violet-500" ? "text-violet-500"
: "font-normal text-slate-500 hover:text-slate-700", : "font-normal text-slate-500 hover:text-slate-700",
)}
>
{section.title}
</Link>
</h3>
{section.children.length > 0 && (
<ol class="mt-2 space-y-3 pl-5 text-slate-500">
<For each={section.children}>
{(subSection) => (
<li>
<Link
href={`#${subSection.id}`}
class={
isActive(subSection)
? "text-violet-500"
: "hover:text-slate-600"
}
>
{subSection.title}
</Link>
</li>
)}
</For>
</ol>
)} )}
</li> >
)} {section.title}
</For> </Link>
</ol> </h3>
</> </li>
)} )}
</For>
</ol>
</nav> </nav>
</div> </div>
); );

View File

@ -1,26 +1,18 @@
import type { Root, Literal } from "mdast";
import type { Program } from "estree-jsx"; import type { Program } from "estree-jsx";
import type { Plugin } from "unified"; import type { Plugin } from "unified";
import type { VFile } from "vfile"; import type { VFile } from "vfile";
import type { Root } from "mdast";
import { readingTime } from "reading-time-estimator"; import { readingTime } from "reading-time-estimator";
import { visit } from "unist-util-visit"; import { visit } from "unist-util-visit";
import yaml from "js-yaml"; import yaml from "js-yaml";
// Type pour le frontmatter
export interface Frontmatter { export interface Frontmatter {
title: string; title: string;
description: string; description: string;
tags: string[]; tags: string[];
} }
// Interface pour le noeud YAML
interface YamlNode extends Literal {
type: "yaml";
value: string;
}
// Interface pour le noeud MDX ESM
interface MDXJSEsm { interface MDXJSEsm {
type: "mdxjsEsm"; type: "mdxjsEsm";
value: string; value: string;
@ -29,7 +21,6 @@ interface MDXJSEsm {
}; };
} }
// Type pour la VFile avec données personnalisées
interface CustomVFile extends VFile { interface CustomVFile extends VFile {
data: { data: {
frontmatter?: Frontmatter; frontmatter?: Frontmatter;
@ -40,7 +31,7 @@ interface CustomVFile extends VFile {
const remarkExtractFrontmatter: Plugin<[], Root> = const remarkExtractFrontmatter: Plugin<[], Root> =
() => (tree: Root, file: CustomVFile) => { () => (tree: Root, file: CustomVFile) => {
visit(tree, "yaml", (node: YamlNode) => { visit(tree, "yaml", (node) => {
try { try {
const data = (yaml.load(node.value) as Frontmatter) || {}; const data = (yaml.load(node.value) as Frontmatter) || {};

View File

@ -1,4 +1,6 @@
import type { Heading, PhrasingContent } from "mdast"; import type { Heading, PhrasingContent } from "mdast";
import type { Section } from "./libs/sections";
import type { Program } from "estree-jsx";
import type { Plugin } from "unified"; import type { Plugin } from "unified";
import type { Root } from "mdast"; import type { Root } from "mdast";
@ -9,6 +11,18 @@ type PhrasingContentWithParent = PhrasingContent & {
children: PhrasingContent[]; children: PhrasingContent[];
}; };
export type TableOfContents = Array<Section>;
interface MDXJSEsm {
type: "mdxjsEsm";
value: string;
data?: {
estree?: Program;
};
}
const tableOfContents: TableOfContents = [];
const doesHaveChildren = (child: PhrasingContent): boolean => { const doesHaveChildren = (child: PhrasingContent): boolean => {
return ["delete", "emphasis", "strong", "link", "linkReference"].includes( return ["delete", "emphasis", "strong", "link", "linkReference"].includes(
child.type, child.type,
@ -40,32 +54,139 @@ const extractText = (children: PhrasingContent[]): string => {
.join(" "); .join(" ");
}; };
const remarkHeadingId: Plugin<[], Root> = () => (tree: Root) => { const formatExportNode = (): MDXJSEsm => {
return {
type: "mdxjsEsm",
value: `export const tableOfContents = ${JSON.stringify(tableOfContents)};`,
data: {
estree: {
type: "Program",
body: [
{
type: "ExportNamedDeclaration",
declaration: {
type: "VariableDeclaration",
kind: "const",
declarations: [
{
type: "VariableDeclarator",
id: {
type: "Identifier",
name: "tableOfContents",
},
init: {
type: "ArrayExpression",
elements: tableOfContents.map((section) => ({
type: "ObjectExpression",
properties: [
{
type: "Property",
key: {
type: "Identifier",
name: "id",
},
value: {
type: "Literal",
value: section.id,
},
kind: "init",
computed: false,
method: false,
shorthand: false,
},
{
type: "Property",
key: {
type: "Identifier",
name: "title",
},
value: {
type: "Literal",
value: section.title,
},
kind: "init",
computed: false,
method: false,
shorthand: false,
},
{
type: "Property",
key: {
type: "Identifier",
name: "level",
},
value: {
type: "Literal",
value: section.level,
},
kind: "init",
computed: false,
method: false,
shorthand: false,
},
{
type: "Property",
key: {
type: "Identifier",
name: "path",
},
value: {
type: "Literal",
value: section.path,
},
kind: "init",
computed: false,
method: false,
shorthand: false,
},
],
})),
},
},
],
},
specifiers: [],
source: null,
},
],
sourceType: "module",
} as unknown as Program,
},
};
};
const remarkHeadingId: Plugin<[], Root> = () => (tree: Root, file) => {
const slugify = slugifyWithCounter(); const slugify = slugifyWithCounter();
visit(tree, "heading", (node) => { visit(tree, "heading", (node) => {
const lastChild = node.children[node.children.length - 1]; const lastChild = node.children[node.children.length - 1];
const filePath = file.path;
console.log(`File path: ${filePath}`);
if (lastChild && lastChild.type === "text") { if (lastChild && lastChild.type === "text") {
let string = lastChild.value.replace(/ +$/, ""); const string = lastChild.value.replace(/ +$/, "");
const matched = string.match(/ {#(.*?)}$/); const matched = string.match(/ {#(.*?)}$/);
if (matched) { if (matched) return;
const id = matched[1];
if (id.length > 0) {
setNodeId(node, id);
string = string.substring(0, matched.index);
lastChild.value = string;
return;
}
}
} }
const slug = slugify(extractText(node.children)); const slug = slugify(extractText(node.children));
setNodeId(node, slug); setNodeId(node, slug);
const depth = node.depth as 2 | 3;
if (depth > 3) return;
tableOfContents.push({
id: slug,
title: extractText(node.children),
level: depth,
path: filePath,
});
}); });
const exportNode = formatExportNode();
tree.children.push(exportNode);
}; };
export default remarkHeadingId; export default remarkHeadingId;