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 { telefuncHandler } from "./server/telefunc-handler";
import { vikeHandler } from "./server/vike-handler";
@ -11,6 +14,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
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() {
const app = Fastify();

View File

@ -1,5 +1,3 @@
import type { readingTime } from "reading-time-estimator";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { config } from "./config";
@ -12,22 +10,6 @@ const root = __dirname;
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() {
const app = Fastify();

View File

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

View File

@ -1,100 +1,6 @@
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"] & {
export type Section = {
id: 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 { useData } from "vike-solid/useData";
import { Link } from "@/components/Link";
import clsx from "clsx";
type TableOfContentsProps = {
tableOfContents: Array<Section>;
};
export function TableOfContents() {
const { tableOfContents } = useData<Data>();
if (!tableOfContents) return null;
export function TableOfContents(props: TableOfContentsProps) {
const [currentSection, setCurrentSection] = createSignal(
props.tableOfContents[0]?.id,
tableOfContents[0]?.id,
);
const getHeadings = (tableOfContents: Array<Section>) => {
const getHeadings = () => {
return tableOfContents
.flatMap((node) => [node.id, ...node.children.map((child) => child.id)])
.map((id) => {
const el = document.getElementById(id);
.map((section) => {
const el = document.getElementById(section.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 };
return { id: section.id, top };
})
.filter((x): x is { id: string; top: number } => x !== null);
};
createEffect(() => {
if (props.tableOfContents.length === 0) return;
const headings = getHeadings(props.tableOfContents);
if (tableOfContents.length === 0) return;
const headings = getHeadings();
function onScroll() {
const top = window.scrollY;
@ -49,69 +51,46 @@ export function TableOfContents(props: TableOfContentsProps) {
return () => {
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.children) return false;
return false;
// if (!section.children) return false;
return section.children.findIndex(isActive) > -1;
// 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"
>
Table des matières
</h2>
<h2
id="on-this-page-title"
class="font-display text-sm font-medium text-slate-900"
>
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",
)}
>
{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>
<ol class="mt-4 space-y-3 text-sm">
<For each={tableOfContents}>
{(section) => (
<li>
<h3>
<Link
href={`#${section.id}`}
class={clsx(
isActive(section)
? "text-violet-500"
: "font-normal text-slate-500 hover:text-slate-700",
)}
</li>
)}
</For>
</ol>
</>
)}
>
{section.title}
</Link>
</h3>
</li>
)}
</For>
</ol>
</nav>
</div>
);

View File

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

View File

@ -1,4 +1,6 @@
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 { Root } from "mdast";
@ -9,6 +11,18 @@ type PhrasingContentWithParent = 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 => {
return ["delete", "emphasis", "strong", "link", "linkReference"].includes(
child.type,
@ -40,32 +54,139 @@ const extractText = (children: PhrasingContent[]): string => {
.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();
visit(tree, "heading", (node) => {
const lastChild = node.children[node.children.length - 1];
const filePath = file.path;
console.log(`File path: ${filePath}`);
if (lastChild && lastChild.type === "text") {
let string = lastChild.value.replace(/ +$/, "");
const string = lastChild.value.replace(/ +$/, "");
const matched = string.match(/ {#(.*?)}$/);
if (matched) {
const id = matched[1];
if (id.length > 0) {
setNodeId(node, id);
string = string.substring(0, matched.index);
lastChild.value = string;
return;
}
}
if (matched) return;
}
const slug = slugify(extractText(node.children));
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;