Organize document URLs by category and collection with compact navigation
Verify and deploy Tabularium / verify-and-deploy (pull_request) Has been cancelled

This commit is contained in:
2026-10-28 18:38:13 +08:00
parent 8be53c1e25
commit 422d6622a2
8 changed files with 221 additions and 48 deletions
+18 -7
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
@@ -36,9 +37,6 @@ export function Navigation({
return (
<nav aria-label="文档导航">
{collection ? <>
<Link className="nav-back" href="/" onClick={onNavigate}>
<Icon name="arrow" size={15} style={{ transform: "rotate(180deg)" }} />全部文档
</Link>
<Link className="collection-identity" href={`/docs/${collections.find((c) => c.name === collection)!.entry.slug}`} onClick={onNavigate}>
<Icon name={collectionIcon(collection)} size={22} /><span>{collectionLabel(collection)}</span>
</Link>
@@ -52,7 +50,7 @@ export function Navigation({
{scopedDocs.filter((d) => d.group === group).map((d) => (
<Link onClick={onNavigate} aria-current={d.slug === current ? "page" : undefined}
className="nav-item" href={`/docs/${d.slug}`} key={d.slug}>
<span>{d.slug.endsWith("/00-overview") ? "概览" : d.title.replace("(草案)", "")}</span>
<span>{d.slug.endsWith("/overview") ? "概览" : d.title.replace("(草案)", "").replace(/^\d+\s*·\s*/, "")}</span>
{d.draft && <span className="tiny-dot" aria-label="草案" />}
</Link>
))}
@@ -71,6 +69,7 @@ export function Shell({
current?: string;
children: ReactNode;
}) {
const router = useRouter();
const dialog = useRef<HTMLDialogElement>(null);
const input = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
@@ -126,9 +125,21 @@ export function Shell({
</a>
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/" aria-current={!current ? "page" : undefined}>文档中心</Link>
{collections.map(({ name, entry }) => <Link key={name} href={`/docs/${entry.slug}`} aria-current={collection === name ? "page" : undefined}>{collectionLabel(name)}</Link>)}
<nav className="top-nav" aria-label="文档选择">
<label className="collection-picker">
<span className="picker-category">{docs.find((d) => d.slug === current)?.category ?? "全部分类"}</span>
<span className="sr-only">选择分类与文档合集</span>
<select value={collection ?? ""} onChange={(event) => {
const entry = collections.find((c) => c.name === event.target.value)?.entry;
void router.push(entry ? `/docs/${entry.slug}` : "/");
}}>
<option value="">文档中心</option>
{[...new Set(docs.map((d) => d.category))].map((category) => <optgroup label={category} key={category}>
{collections.filter((c) => c.entry.category === category).map(({name}) => <option value={name} key={name}>{collectionLabel(name)}</option>)}
</optgroup>)}
</select>
<Icon name="chevron" size={13} />
</label>
</nav>
<button
ref={trigger}
+1 -1
View File
@@ -18,7 +18,7 @@ export function collectionIcon(name?: string): "book" | "code" | "layers" | "nod
export function collectionEntries(docs: DocLink[]) {
return [...new Set(docs.map((doc) => doc.collection))].map((name) => {
const items = docs.filter((doc) => doc.collection === name);
const entry = items.find((doc) => doc.slug.endsWith("/00-overview")) ?? items[0];
const entry = items.find((doc) => doc.slug.endsWith("/overview")) ?? items[0];
return { name, entry, count: items.length };
});
}
+13 -7
View File
@@ -4,20 +4,24 @@ import catalog from "../../../../content/catalog.json";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
type Metadata = { collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
type Metadata = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean };
export type DocLink = {
slug: string; title: string; searchText?: string;
slug: string; sourceSlug: string; category: string; title: string; searchText?: string;
group: string; collection: string; description: string; draft: boolean; featured: boolean;
};
export type Doc = DocLink & { content: string };
export async function readDoc(slug: string): Promise<Doc> {
if (!validSlug.test(slug)) throw new Error("Invalid document slug");
const content = await fs.readFile(path.join(directory, `${slug}.md`), "utf8");
const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug;
const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8");
const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
const collection = catalog.collections.find((c) => !c.path || slug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[slug] ?? {};
return { slug, title, content, searchText: content,
const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!;
const metadata: Metadata = (catalog.documents as Record<string, Metadata>)[sourceSlug] ?? {};
if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) {
throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`);
}
return { slug: metadata.slug, sourceSlug, category: metadata.category, title, content, searchText: content,
group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title,
description: metadata.description ?? collection.description,
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
@@ -36,9 +40,11 @@ export async function listDocs(): Promise<DocLink[]> {
}));
return files.flat();
}
return Promise.all((await walk("")).sort().map(async (slug) => {
const docs = await Promise.all((await walk("")).sort().map(async (slug) => {
const { content: _, searchText: __, ...doc } = await readDoc(slug);
void _; void __;
return doc;
}));
if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug");
return docs;
}
+17 -7
View File
@@ -24,14 +24,16 @@ import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
paths: (await listDocs()).flatMap(({ slug, sourceSlug }) => [...new Set([slug, sourceSlug])].map((value) => ({ params: { slug: value.split("/") } }))),
fallback: false,
});
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug;
if (typeof slug !== "string") return { notFound: true };
const docs = await listDocs();
if (!docs.some((d) => d.slug === slug)) return { notFound: true };
const match = docs.find((d) => d.slug === slug || d.sourceSlug === slug);
if (!match) return { notFound: true };
if (slug !== match.slug) return { redirect: { destination: `/docs/${match.slug}`, permanent: true } };
return { props: { doc: await readDoc(slug), docs } };
};
function textOf(value: ReactNode): string {
@@ -102,6 +104,17 @@ export default function DocPage({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => {
if (!href) return <a>{children}</a>;
let target = href;
if (!/^(?:[a-z]+:|\/\/|#)/i.test(href)) {
const resolved = new URL(href, `https://docs.local/docs/${doc.sourceSlug}`);
const source = decodeURIComponent(resolved.pathname).replace(/^\/docs\//, "").replace(/\.md$/, "");
const linked = docs.find((d) => d.sourceSlug === source || d.slug === source);
if (linked) target = `/docs/${linked.slug}${resolved.search}${resolved.hash}`;
}
return <a href={target}>{children}</a>;
},
p: ({ children, node }) =>
node?.children.some(
(child) =>
@@ -201,7 +214,7 @@ export default function DocPage({
<div className="article-end">
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.slug}.md`}
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.sourceSlug}.md`}
>
查看文档源码 ↗
</a>
@@ -213,10 +226,7 @@ export default function DocPage({
<strong>{siblings[index - 1].title}</strong>
</Link>
) : (
<Link href="/">
<small>← 返回</small>
<strong>文档概览</strong>
</Link>
<span />
)}
{index < siblings.length - 1 && (
<Link
+39
View File
@@ -1262,3 +1262,42 @@ svg { flex-shrink: 0; }
*, *::before, *::after { animation: none !important; transition: none !important; }
.featured-link:hover, .featured-link:hover .collection-art, .pagination > a:hover { transform: none; }
}
/* Compact navigation and local font roles, with no font downloads. */
:root {
--font-reading: "Charter", "Iowan Old Style", "Songti SC", "STSong", "Noto Serif CJK SC", serif;
--font-code: "SFMono-Regular", Consolas, "Liberation Mono", "PingFang SC", monospace;
}
.collection-picker { display: flex; flex-wrap: wrap; align-items: center; position: relative; min-width: 180px; max-width: 280px; }
.collection-picker select { appearance: none; width: 100%; cursor: pointer; padding: 8px 30px 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font: inherit; font-size: 13px; color: var(--ink); text-overflow: ellipsis; }
.picker-category { width: 100%; font-size: 10px; line-height: 1.4; color: var(--muted); margin-bottom: 2px; }
.collection-picker > svg { position: absolute; right: 10px; bottom: 12px; pointer-events: none; transform: rotate(90deg); color: var(--muted); }
.collection-picker select:focus-visible { outline: 2px solid var(--green); outline-offset: 3px; }
.top-nav { margin-left: 0; }
.sidebar { padding: 22px 16px 32px; }
.collection-identity { font-size: 14px; gap: 9px; margin: 0 8px 20px; }
.collection-identity svg { width: 18px; height: 18px; }
.nav-group { margin-top: 20px; }
.nav-group > p { margin: 0 8px 6px; font-size: 11px; letter-spacing: .02em; color: #7d8981; }
.nav-item { font-size: 13px; padding: 5px 8px; margin: 0; line-height: 1.5; gap: 6px; }
.nav-item[aria-current="page"]::before { top: 7px; bottom: 7px; }
.collection-nav-link { padding: 7px 8px; margin-bottom: 2px; gap: 9px; font-size: 13px; }
.prose { font-family: var(--font-reading); font-size: 16px; line-height: 1.95; }
.prose :is(h1,h2,h3,h4), .prose table, .code-toolbar, .mobile-toc { font-family: var(--font-sans); }
.prose :is(h1,h2,h3) { font-weight: 600; letter-spacing: -.025em; }
.prose :is(code,pre), .code-block pre, kbd { font-family: var(--font-code); font-variant-ligatures: none; }
.prose code { font-size: .86em; }
@media (min-width: 1201px) {
.home-layout { grid-template-columns: 240px minmax(0,1fr); }
.reading-layout { grid-template-columns: 240px minmax(0,1fr) 222px; }
}
@media (min-width: 761px) and (max-width: 1200px) {
.home-layout, .reading-layout { grid-template-columns: 224px minmax(0,1fr); }
}
@media (max-width: 760px) {
.top-nav { overflow: visible; }
.collection-picker { width: 100%; max-width: 100%; }
.picker-category { display: none; }
.collection-picker select { padding-block: 6px; }
.mobile-navigation .nav-group { margin-top: 16px; }
}