From 2e692f726472a23ba9dc9272a02a09eef40ba905 Mon Sep 17 00:00:00 2001 From: GauthierWebDev Date: Sun, 20 Apr 2025 00:33:19 +0200 Subject: [PATCH] feat: Add table of contents generation for docs pages --- app/fastify-entry.ts | 20 +++++ app/fastify-server.ts | 18 ---- app/layouts/DocsLayout.tsx | 11 +-- app/libs/sections.ts | 100 +-------------------- app/pages/+data.ts | 9 ++ app/partials/TableOfContents.tsx | 111 ++++++++++------------- app/remarkExtractFrontmatter.ts | 13 +-- app/remarkHeadingId.ts | 147 ++++++++++++++++++++++++++++--- 8 files changed, 219 insertions(+), 210 deletions(-) create mode 100644 app/pages/+data.ts diff --git a/app/fastify-entry.ts b/app/fastify-entry.ts index 1cc6c5d..6344318 100755 --- a/app/fastify-entry.ts +++ b/app/fastify-entry.ts @@ -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; + tableOfContents?: TableOfContents; + [key: string]: unknown; + }; + } + } +} + async function startServer() { const app = Fastify(); diff --git a/app/fastify-server.ts b/app/fastify-server.ts index 277ede2..0654742 100644 --- a/app/fastify-server.ts +++ b/app/fastify-server.ts @@ -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; - [key: string]: unknown; - }; - } - } -} - async function startServer() { const app = Fastify(); diff --git a/app/layouts/DocsLayout.tsx b/app/layouts/DocsLayout.tsx index 54913a0..945b4d0 100644 --- a/app/layouts/DocsLayout.tsx +++ b/app/layouts/DocsLayout.tsx @@ -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) { - {/* */} + ); } diff --git a/app/libs/sections.ts b/app/libs/sections.ts index 44df061..2664e04 100644 --- a/app/libs/sections.ts +++ b/app/libs/sections.ts @@ -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; -}; - -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/pages/+data.ts b/app/pages/+data.ts new file mode 100644 index 0000000..bf6850e --- /dev/null +++ b/app/pages/+data.ts @@ -0,0 +1,9 @@ +import type { PageContext } from "vike/types"; + +export type Data = Awaited>; + +export async function data(pageContext: PageContext) { + return { + tableOfContents: pageContext.exports.tableOfContents, + }; +} diff --git a/app/partials/TableOfContents.tsx b/app/partials/TableOfContents.tsx index 08258e9..6aa9d04 100644 --- a/app/partials/TableOfContents.tsx +++ b/app/partials/TableOfContents.tsx @@ -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
; -}; +export function TableOfContents() { + const { tableOfContents } = useData(); + + if (!tableOfContents) return null; -export function TableOfContents(props: TableOfContentsProps) { const [currentSection, setCurrentSection] = createSignal( - props.tableOfContents[0]?.id, + tableOfContents[0]?.id, ); - const getHeadings = (tableOfContents: Array
) => { + 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 ( ); diff --git a/app/remarkExtractFrontmatter.ts b/app/remarkExtractFrontmatter.ts index cc89546..eb4aaf1 100644 --- a/app/remarkExtractFrontmatter.ts +++ b/app/remarkExtractFrontmatter.ts @@ -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) || {}; diff --git a/app/remarkHeadingId.ts b/app/remarkHeadingId.ts index a88d76b..baee7e2 100644 --- a/app/remarkHeadingId.ts +++ b/app/remarkHeadingId.ts @@ -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
; + +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;