feat: Add table of contents generation for docs pages
This commit is contained in:
parent
803da33f37
commit
2e692f7264
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
9
app/pages/+data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,20 +51,19 @@ 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"
|
||||
@ -71,7 +72,7 @@ export function TableOfContents(props: TableOfContentsProps) {
|
||||
</h2>
|
||||
|
||||
<ol class="mt-4 space-y-3 text-sm">
|
||||
<For each={props.tableOfContents}>
|
||||
<For each={tableOfContents}>
|
||||
{(section) => (
|
||||
<li>
|
||||
<h3>
|
||||
@ -86,32 +87,10 @@ export function TableOfContents(props: TableOfContentsProps) {
|
||||
{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>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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) || {};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user