Rework docs navigation and move alternatives to top-level pages (#1487)

* Rework docs navigation and move alternatives to top-level pages

- Add nested docs nav with categories, collapsible groups, breadcrumbs,
  and a right-hand page outline with heading slugs.
- Move alternatives out of /docs/alternatives into /alternatives/*
  marketing pages with 301 redirects.
- Add github-slugger for heading IDs and require explicit width on
  SiteShell so new pages can't accidentally pick the narrow prose layout.
- Remove the best-practices doc from public-docs.

* Make docs content column prose-width
This commit is contained in:
Mohamed Boudra
2026-06-12 12:53:30 +00:00
committed by GitHub
parent fb0c5b27f3
commit 7e46f6a647
54 changed files with 934 additions and 200 deletions
+196 -1
View File
@@ -1,8 +1,17 @@
import Slugger from "github-slugger";
interface DocFrontmatter {
title: string;
description: string;
nav: string;
order: number;
category?: string;
}
export interface DocHeading {
depth: number;
text: string;
id: string;
}
export interface Doc {
@@ -11,8 +20,31 @@ export interface Doc {
sourcePath: string;
frontmatter: DocFrontmatter;
content: string;
headings: DocHeading[];
}
export type DocsNavNode =
| {
type: "category";
label: string;
children: DocsNavNode[];
order: number;
}
| {
type: "group";
segment: string;
label: string;
children: DocsNavNode[];
order: number;
}
| {
type: "page";
segment: string;
label: string;
href: string;
order: number;
};
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
@@ -32,6 +64,40 @@ function parseFrontmatter(raw: string): { data: Record<string, string>; content:
return { data, content: match[2] };
}
function stripInlineMarkdown(text: string): string {
return text
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/!?\[([^\]]*)\]\[[^\]]*\]/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/<[^>]+>/g, "")
.trim();
}
function parseHeadings(content: string): DocHeading[] {
const slugger = new Slugger();
const headings: DocHeading[] = [];
const regex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const depth = match[1].length;
if (depth < 2 || depth > 4) continue;
const text = stripInlineMarkdown(match[2].trim());
if (!text) continue;
headings.push({
depth,
text,
id: slugger.slug(text),
});
}
return headings;
}
const docModules = import.meta.glob("../../../public-docs/**/*.md", {
eager: true,
query: "?raw",
@@ -41,7 +107,8 @@ const docModules = import.meta.glob("../../../public-docs/**/*.md", {
function pathToSlug(path: string): string {
const after = path.split("/public-docs/")[1] ?? path;
const noExt = after.replace(/\.md$/, "");
return noExt === "index" ? "" : noExt;
if (noExt === "index") return "";
return noExt.replace(/\/index$/, "");
}
function pathToSourcePath(path: string): string {
@@ -66,8 +133,10 @@ function loadDocs(): Doc[] {
description: data.description ?? "",
nav: data.nav ?? data.title ?? slug,
order: Number.isFinite(order) ? order : 999,
category: data.category,
},
content,
headings: parseHeadings(content),
});
}
@@ -85,3 +154,129 @@ export function getDocs(): Doc[] {
export function getDoc(slug: string): Doc | undefined {
return getDocs().find((d) => d.slug === slug);
}
function formatLabel(segment: string): string {
return segment.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function insertDocByPath(nodes: DocsNavNode[], doc: Doc): void {
const relative = doc.sourcePath.replace(/^public-docs\//, "");
const segments = relative.replace(/\.md$/, "").split("/");
const fileName = segments.pop() ?? "";
const directories = segments;
let current = nodes;
for (const segment of directories) {
let group = current.find(
(node): node is Extract<DocsNavNode, { type: "group" }> =>
node.type === "group" && node.segment === segment,
);
if (!group) {
group = {
type: "group",
segment,
label: formatLabel(segment),
children: [],
order: doc.frontmatter.order,
};
current.push(group);
}
current = group.children;
}
const pageSegment = fileName === "index" ? (directories.at(-1) ?? "") : fileName;
current.push({
type: "page",
segment: pageSegment,
label: doc.frontmatter.nav,
href: doc.href,
order: doc.frontmatter.order,
});
}
function nodeOrder(node: DocsNavNode): number {
if (node.type === "page") return node.order;
if (node.children.length === 0) return Infinity;
let min = Infinity;
for (const child of node.children) {
const childOrder = nodeOrder(child);
if (childOrder < min) min = childOrder;
}
return min;
}
function sortNodes(nodes: DocsNavNode[]): void {
nodes.sort((a, b) => nodeOrder(a) - nodeOrder(b));
for (const node of nodes) {
if (node.type !== "page") {
sortNodes(node.children);
}
}
}
export function buildDocsNavTree(docs: Doc[]): DocsNavNode[] {
const byCategory = new Map<string, Doc[]>();
const uncategorized: Doc[] = [];
for (const doc of docs) {
const category = doc.frontmatter.category?.trim();
if (category) {
const list = byCategory.get(category) ?? [];
list.push(doc);
byCategory.set(category, list);
} else {
uncategorized.push(doc);
}
}
const root: DocsNavNode[] = [];
for (const doc of uncategorized) {
insertDocByPath(root, doc);
}
for (const [label, docsInCategory] of byCategory) {
const children: DocsNavNode[] = [];
for (const doc of docsInCategory) {
insertDocByPath(children, doc);
}
root.push({
type: "category",
label,
children,
order: nodeOrder({ type: "category", label, children, order: Infinity }),
});
}
sortNodes(root);
return root;
}
function findNodePath(
nodes: DocsNavNode[],
href: string,
path: DocsNavNode[] = [],
): DocsNavNode[] | null {
for (const node of nodes) {
if (node.type === "page" && node.href === href) {
return [...path, node];
}
if (node.type !== "page") {
const found = findNodePath(node.children, href, [...path, node]);
if (found) return found;
}
}
return null;
}
export function getDocBreadcrumbGroups(doc: Doc, tree: DocsNavNode[]): { label: string }[] {
const path = findNodePath(tree, doc.href);
if (!path) return [];
return path
.slice(0, -1)
.filter((node): node is Extract<DocsNavNode, { type: "group" }> => node.type === "group")
.map((node) => ({ label: node.label }));
}