Compare commits

..

1 Commits

6 changed files with 38 additions and 157 deletions
+7 -11
View File
@@ -187,27 +187,23 @@ async function main() {
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "source-studies/pretext/measurement-cache" && doc.searchText.includes("getSegmentMetrics")));
for (const doc of searchIndex as { slug: string; sourceSlug: string }[]) {
assert.ok(!doc.slug.split("/").some((part) => /^\d+-/.test(part)), doc.slug);
const legacy = await request(`/docs/${doc.sourceSlug}?from=legacy`, { headers });
const legacy = await request(`/docs/${doc.sourceSlug}`, { headers });
assert.equal(legacy.status, 308, doc.sourceSlug);
const destination = new URL(legacy.headers.get("location")!, base);
assert.equal(destination.pathname, `/docs/${doc.slug}`);
assert.equal(destination.searchParams.get("from"), "legacy");
assert.ok(legacy.headers.get("cache-control")?.includes("no-store"));
assert.equal(new URL(legacy.headers.get("location")!, base).pathname, `/docs/${doc.slug}`);
}
let home = "";
let purpose = "";
for (const route of routes) {
const response = await request(route, { headers });
assert.equal(response.status, 200, route);
const alias = (searchIndex as { sourceSlug: string }[]).some((doc) => route === `/docs/${doc.sourceSlug}`);
assert.equal(response.status, alias ? 308 : 200, route);
if (alias) continue;
const html = await response.text();
if (route === "/") home = html;
if (route === "/docs/endor/endor-purpose-draft") purpose = html;
if (route.startsWith("/docs/")) {
assert.ok(html.includes('aria-label="文档分类"'), route);
assert.ok(html.includes('data-category="语言与运行时"'), route);
assert.ok(html.includes('class="category-panel" hidden=""'), route);
assert.ok(!html.includes('class="collection-picker"'), route);
assert.ok(!html.includes("<select"), route);
assert.ok(html.includes('aria-label="文档选择"'), route);
assert.ok(html.includes('<optgroup label="语言与运行时">'), route);
assert.ok(!html.includes('class="nav-back"'), route);
const ids = [...html.matchAll(/<h[23] id="([^"]+)"/g)].map((match) => match[1]);
assert.equal(new Set(ids).size, ids.length, `Unique heading anchors: ${route}`);
@@ -1,93 +0,0 @@
import Link from "next/link";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import type { DocLink } from "@/lib/docs";
import { collectionEntries, collectionIcon, collectionLabel, collectionTone } from "@/lib/collections";
import { Icon } from "./Icon";
export function CategoryNavigation({ docs, current }: { docs: DocLink[]; current?: string }) {
const navigation = useRef<HTMLElement>(null);
const triggers = useRef(new Map<string, HTMLButtonElement>());
const [open, setOpen] = useState<string | null>(null);
const id = useId();
const categories = [...new Set(docs.map((doc) => doc.category))];
const collections = collectionEntries(docs);
const selected = docs.find((doc) => doc.slug === current);
useEffect(() => {
function outside(event: PointerEvent) {
if (event.target instanceof Node && !navigation.current?.contains(event.target)) setOpen(null);
}
function escape(event: globalThis.KeyboardEvent) {
if (event.key !== "Escape" || !open) return;
if (navigation.current?.contains(document.activeElement)) {
event.preventDefault();
triggers.current.get(open)?.focus();
}
setOpen(null);
}
document.addEventListener("pointerdown", outside);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape);
};
}, [open]);
function focusItem(panel: string, last = false) {
requestAnimationFrame(() => {
const links = document.getElementById(panel)?.querySelectorAll<HTMLAnchorElement>("a");
links?.[last ? links.length - 1 : 0]?.focus();
});
}
function move(event: KeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const links = [...event.currentTarget.querySelectorAll<HTMLAnchorElement>("a")];
const index = links.indexOf(document.activeElement as HTMLAnchorElement);
if (index < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? links.length - 1 :
(index + (event.key === "ArrowDown" ? 1 : -1) + links.length) % links.length;
links[next]?.focus();
}
return <nav ref={navigation} className="top-nav category-navigation" aria-label="文档分类"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(null);
}}>
<ul>
<li><Link className="category-home" href="/" aria-current={!current ? "page" : undefined} onClick={() => setOpen(null)}>文档中心</Link></li>
{categories.map((category, index) => {
const panel = `${id}-category-${index}`;
const expanded = open === category;
return <li className="category-menu" key={category}
onPointerEnter={(event) => { if (event.pointerType === "mouse") setOpen(category); }}
onPointerLeave={(event) => { if (event.pointerType === "mouse") setOpen(null); }}>
<button ref={(element) => { if (element) triggers.current.set(category, element); else triggers.current.delete(category); }}
type="button" className="category-trigger" data-category={category}
data-active={selected?.category === category || undefined}
aria-expanded={expanded} aria-controls={panel}
onClick={() => setOpen(expanded ? null : category)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpen(category);
focusItem(panel, event.key === "ArrowUp");
}
}}>
{category}<Icon name="chevron" size={11} />
</button>
<div id={panel} className="category-panel" hidden={!expanded} onKeyDown={move}>
<p className="category-panel-title">{category}</p>
{collections.filter(({entry}) => entry.category === category).map(({name, entry}) => <Link
key={name} href={`/docs/${entry.slug}`} data-tone={collectionTone(name)}
aria-current={current === entry.slug ? "page" : selected?.collection === name ? "true" : undefined}
onClick={() => setOpen(null)}>
<Icon name={collectionIcon(name)} size={19} />
<span><strong>{collectionLabel(name)}</strong><small>{entry.description}</small></span>
</Link>)}
</div>
</li>;
})}
</ul>
</nav>;
}
+19 -2
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { CategoryNavigation } from "./CategoryNavigation";
import { useRouter } from "next/router";
import { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
@@ -69,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);
@@ -79,6 +80,7 @@ export function Shell({
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const collection = docs.find((d) => d.slug === current)?.collection;
const collections = collectionEntries(docs);
const results = searchDocs.filter((d) =>
(!collection || d.collection === collection) &&
`${d.title} ${d.description} ${d.searchText ?? ""}`
@@ -123,7 +125,22 @@ export function Shell({
</a>
<header className="site-header">
<Brand />
<CategoryNavigation docs={docs} current={current} />
<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}
className="search-trigger"
+4 -2
View File
@@ -25,14 +25,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 {
-12
View File
@@ -1,10 +1,5 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import catalog from "../../../content/catalog.json";
const legacyRoutes = new Map(Object.entries(catalog.documents).map(([source, metadata]) => [
`/docs/${source}`, `/docs/${metadata.slug}`,
]));
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
@@ -54,13 +49,6 @@ export function proxy(request: NextRequest) {
);
}
}
// Keep legacy URLs behind authentication and out of static generation.
const destination = legacyRoutes.get(path);
if (destination && ["GET", "HEAD"].includes(request.method)) {
const target = request.nextUrl.clone();
target.pathname = destination;
return NextResponse.redirect(target, { status: 308, headers });
}
return NextResponse.next({ headers });
}
if (path.startsWith("/api/") || !["GET", "HEAD"].includes(request.method)) {
+8 -37
View File
@@ -1319,6 +1319,11 @@ svg { flex-shrink: 0; }
--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; }
@@ -1342,42 +1347,8 @@ svg { flex-shrink: 0; }
}
@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; }
}
/* Categories stay visible across the header, each with its own disclosure panel. */
.top-nav.category-navigation { position: relative; display: flex; margin-left: 0; gap: 0; }
.category-navigation > ul { display: flex; align-items: stretch; gap: 22px; height: 100%; list-style: none; padding: 0; margin: 0; }
.category-navigation > ul > li { display: flex; align-items: stretch; }
.category-menu { position: relative; }
.category-navigation .category-home, .category-trigger { display: flex; align-items: center; gap: 6px; color: var(--muted); font-family: var(--font-sans); font-size: 13px; white-space: nowrap; }
.category-trigger { position: relative; padding: 0; background: transparent; cursor: pointer; }
.category-trigger > svg { transform: rotate(90deg); transition: transform 160ms; }
.category-trigger[aria-expanded="true"] > svg { transform: rotate(-90deg); }
.category-trigger:hover, .category-trigger[aria-expanded="true"], .category-trigger[data-active="true"] { color: var(--ink); }
.category-trigger[data-active="true"]::after { content: ""; position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--green); }
.category-trigger:focus-visible, .category-navigation a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: 3px; }
.category-panel { position: absolute; top: 100%; left: -16px; width: 320px; padding: 12px; background: #fdfefd; border: 1px solid var(--line); border-radius: 0 0 10px 10px; box-shadow: 0 16px 40px #24352b1a; z-index: 50; }
.category-panel[hidden] { display: none; }
.category-menu:last-child .category-panel { left: auto; right: -16px; }
.category-panel-title { margin: 3px 10px 8px; font-size: 11px; color: var(--muted); }
.top-nav .category-panel a { display: flex; align-items: flex-start; gap: 12px; padding: 12px 10px; white-space: normal; border-radius: 6px; color: var(--ink); }
.top-nav .category-panel a::after { display: none; }
.category-panel a:hover, .category-panel a[aria-current] { background: var(--accent-wash); }
.category-panel a > svg { margin-top: 2px; color: var(--green); }
.category-panel strong { display: block; font-size: 13px; font-weight: 550; line-height: 1.5; }
.category-panel small { display: block; margin-top: 4px; font-size: 12px; line-height: 1.6; color: var(--muted); }
@media (min-width: 761px) and (max-width: 1100px) {
.site-header { gap: 20px; }
.category-navigation > ul { gap: 16px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.search-trigger { width: 36px; height: 34px; padding: 8px; justify-content: center; }
.search-trigger span, .search-trigger kbd { display: none; }
}
@media (max-width: 760px) {
.top-nav.category-navigation { overflow: visible; }
.category-navigation > ul { width: 100%; gap: 18px; }
.category-navigation .category-home, .category-trigger { font-size: 12px; }
.category-menu { position: static; }
.category-panel, .category-menu:last-child .category-panel { width: auto; left: 0; right: 0; }
}