Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787491622b | |||
| e448acb904 | |||
| e286139572 | |||
| c49c8348d2 | |||
| 7a9c88a05a | |||
| 422d6622a2 | |||
| bd932c36b2 | |||
| c08371647d | |||
| 8be53c1e25 |
@@ -6,4 +6,5 @@
|
||||
**/*.pyc
|
||||
**/*.tsbuildinfo
|
||||
.artifacts
|
||||
apps/docs/public/figures/**/*.webp
|
||||
.env*
|
||||
|
||||
@@ -11,3 +11,6 @@ __pycache__/
|
||||
!.env.example
|
||||
|
||||
.artifacts/
|
||||
|
||||
# Browser raster derivatives are generated on the confirmed remote runner.
|
||||
/apps/docs/public/figures/**/*.webp
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
|
||||
"build": "NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
|
||||
"dev": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next dev --hostname 127.0.0.1",
|
||||
"build": "node scripts/build-doc-images.mjs && NEXT_TELEMETRY_DISABLED=1 next build && node scripts/build-auth.mjs",
|
||||
"images": "node scripts/build-doc-images.mjs",
|
||||
"test:images": "node scripts/build-doc-images.mjs --check",
|
||||
"start": "NEXT_TELEMETRY_DISABLED=1 next start --hostname 127.0.0.1",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit",
|
||||
@@ -29,6 +31,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.4.3",
|
||||
"postcss": "8.5.31",
|
||||
"sharp": "0.35.5",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ async function main() {
|
||||
"/",
|
||||
"/docs/00-endor-concepts-draft",
|
||||
"/figures/endor-architecture.svg",
|
||||
"/figures/endor-review-gate.webp",
|
||||
"/figures/endor-review-desk.webp",
|
||||
"/docs/pretext/01-chapter",
|
||||
"/materials/pretext/verify.cjs",
|
||||
"/materials/pretext/results.chrome156.json",
|
||||
@@ -182,15 +184,64 @@ async function main() {
|
||||
const searchResponse = await request("/api/search", { headers });
|
||||
assert.equal(searchResponse.status, 200);
|
||||
const searchIndex = await searchResponse.json();
|
||||
assert.ok(searchIndex.some((doc: { slug: string; searchText: string }) => doc.slug === "pretext/04-chapter" && doc.searchText.includes("getSegmentMetrics")));
|
||||
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 });
|
||||
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"));
|
||||
}
|
||||
let home = "";
|
||||
let purpose = "";
|
||||
for (const route of routes) {
|
||||
const response = await request(route, { headers });
|
||||
assert.equal(response.status, 200, route);
|
||||
if (route === "/") home = await response.text();
|
||||
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('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}`);
|
||||
for (const id of ids) assert.ok(html.includes(`href="#${id}"`), `TOC anchor: ${route}#${id}`);
|
||||
}
|
||||
}
|
||||
assert.ok(home.includes("资料库"));
|
||||
assert.ok(home.includes("/docs/pretext/00-overview"));
|
||||
assert.ok(home.includes("/docs/source-studies/pretext/overview"));
|
||||
// Inline preview and zoom dialog must both use the compressed browser asset.
|
||||
assert.equal(
|
||||
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-gate\.webp"/g)].length,
|
||||
2,
|
||||
"The comic preview and lightbox must use WebP",
|
||||
);
|
||||
assert.ok(purpose.includes('aria-label="上一张图片"'));
|
||||
assert.ok(purpose.includes('aria-label="下一张图片"'));
|
||||
assert.ok(purpose.includes('aria-label="显示第 1 张图片" aria-current="true"'));
|
||||
assert.ok(purpose.includes('aria-label="显示第 2 张图片"'));
|
||||
assert.equal(
|
||||
[...purpose.matchAll(/<img\b[^>]*\bsrc="\/figures\/endor-review-desk\.webp"/g)].length,
|
||||
0,
|
||||
"The second slide should not download until selected",
|
||||
);
|
||||
for (const name of ["endor-review-gate", "endor-review-desk"]) {
|
||||
assert.ok(!purpose.includes(`/figures/${name}.png`));
|
||||
const comic = await request(`/figures/${name}.webp`, { headers });
|
||||
assert.equal(comic.status, 200);
|
||||
assert.match(comic.headers.get("content-type") || "", /image\/webp/);
|
||||
assert.deepEqual(
|
||||
Buffer.from(await comic.arrayBuffer()),
|
||||
readFileSync(join(__dirname, `public/figures/${name}.webp`)),
|
||||
);
|
||||
assert.equal((await request(`/figures/${name}.png`, { headers })).status, 404);
|
||||
}
|
||||
const assets = new Set(
|
||||
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
|
||||
m[1].replaceAll("&", "&"),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const root = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const sources = path.join(root, "tools/figures/assets");
|
||||
const output = path.join(root, "apps/docs/public/figures");
|
||||
const check = process.argv.includes("--check");
|
||||
const policy = JSON.parse(await readFile(path.join(root, "content/image-policy.json"), "utf8"));
|
||||
|
||||
async function files(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (await Promise.all(entries.map((entry) => {
|
||||
const name = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? files(name) : [name];
|
||||
}))).flat().sort();
|
||||
}
|
||||
|
||||
// Originals stay outside public; both inline and lightbox images use the WebP.
|
||||
for (const name of await files(path.join(root, "apps/docs/public"))) {
|
||||
if (/\.(?:png|jpe?g|tiff?|bmp)$/i.test(name)) {
|
||||
throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`);
|
||||
}
|
||||
}
|
||||
for (const name of await files(path.join(root, "content/docs"))) {
|
||||
if (!name.endsWith(".md")) continue;
|
||||
const content = await readFile(name, "utf8");
|
||||
const images = [
|
||||
...content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
|
||||
...content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
|
||||
];
|
||||
for (const [, source] of images) {
|
||||
if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) {
|
||||
throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(output, { recursive: true });
|
||||
for (const source of await files(sources)) {
|
||||
if (!/\.(?:png|jpe?g|tiff?|bmp)$/i.test(source)) continue;
|
||||
const relative = path.relative(sources, source).replace(/\.[^.]+$/, ".webp");
|
||||
const destination = path.join(output, relative);
|
||||
const original = await readFile(source);
|
||||
const encoded = await sharp(original)
|
||||
.rotate()
|
||||
.resize({ width: policy.maxWidth, withoutEnlargement: true })
|
||||
.webp({ quality: policy.quality, effort: 6 })
|
||||
.toBuffer();
|
||||
if (encoded.length > policy.maxBytes) {
|
||||
throw new Error(`${relative}: ${encoded.length} bytes exceeds the ${policy.maxBytes}-byte image budget.`);
|
||||
}
|
||||
if (check) {
|
||||
const generated = await readFile(destination);
|
||||
if (!encoded.equals(generated)) throw new Error(`${relative} is stale. Regenerate browser images on the confirmed remote runner.`);
|
||||
} else {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await writeFile(destination, encoded);
|
||||
}
|
||||
console.log(`${check ? "Verified" : "Generated"} ${relative}: ${original.length} → ${encoded.length} bytes (${(100 * (1 - encoded.length / original.length)).toFixed(1)}% smaller)`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useId, useState } from "react";
|
||||
import { ArchitectureFigure } from "./ArchitectureFigure";
|
||||
|
||||
type Slide = { source: string; alt: string };
|
||||
|
||||
export function FigureGallery({ slides }: { slides: Slide[] }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const viewport = useId();
|
||||
const slide = slides[active];
|
||||
if (!slide) return null;
|
||||
|
||||
function move(offset: number) {
|
||||
setActive((current) => (current + offset + slides.length) % slides.length);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="figure-gallery"
|
||||
role="region"
|
||||
aria-roledescription="轮播"
|
||||
aria-label="文档配图"
|
||||
onKeyDown={(event) => {
|
||||
if ((event.target as HTMLElement).closest("dialog")) return;
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
move(event.key === "ArrowLeft" ? -1 : 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={viewport}
|
||||
role="group"
|
||||
aria-roledescription="幻灯片"
|
||||
aria-label={`第 ${active + 1} 张,共 ${slides.length} 张`}
|
||||
>
|
||||
<ArchitectureFigure key={slide.source} source={slide.source} alt={slide.alt} />
|
||||
</div>
|
||||
<div className="figure-gallery-controls">
|
||||
<button type="button" className="figure-gallery-arrow" aria-label="上一张图片" aria-controls={viewport} onClick={() => move(-1)}>
|
||||
<span aria-hidden="true">‹</span>
|
||||
</button>
|
||||
<div className="figure-gallery-dots" role="group" aria-label="选择图片">
|
||||
{slides.map((item, index) => (
|
||||
<button
|
||||
key={item.source}
|
||||
type="button"
|
||||
className="figure-gallery-dot"
|
||||
aria-label={`显示第 ${index + 1} 张图片`}
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-controls={viewport}
|
||||
onClick={() => setActive(index)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="figure-gallery-arrow" aria-label="下一张图片" aria-controls={viewport} onClick={() => move(1)}>
|
||||
<span aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
第 {active + 1} 张,共 {slides.length} 张:{slide.alt}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { CategoryNavigation } from "./CategoryNavigation";
|
||||
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>
|
||||
))}
|
||||
@@ -81,7 +79,6 @@ 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 ?? ""}`
|
||||
@@ -126,10 +123,7 @@ 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>
|
||||
<CategoryNavigation docs={docs} current={current} />
|
||||
<button
|
||||
ref={trigger}
|
||||
className="search-trigger"
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,6 +17,7 @@ import remarkGfm from "remark-gfm";
|
||||
import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
|
||||
import { Shell, Navigation } from "@/components/Shell";
|
||||
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
|
||||
import { FigureGallery } from "@/components/FigureGallery";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { collectionEntries, collectionLabel } from "@/lib/collections";
|
||||
import { Icon } from "@/components/Icon";
|
||||
@@ -102,15 +103,41 @@ export default function DocPage({
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children, node }) =>
|
||||
node?.children.some(
|
||||
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 }) => {
|
||||
const slides = node?.children.flatMap((child) =>
|
||||
child.type === "element" && child.tagName === "img" &&
|
||||
typeof child.properties.src === "string" &&
|
||||
child.properties.src.startsWith("/figures/endor-")
|
||||
? [{ source: child.properties.src, alt: String(child.properties.alt ?? "") }]
|
||||
: [],
|
||||
) ?? [];
|
||||
const onlyImages = node?.children.every((child) =>
|
||||
(child.type === "element" && child.tagName === "img" &&
|
||||
typeof child.properties.src === "string" &&
|
||||
child.properties.src.startsWith("/figures/endor-")) ||
|
||||
(child.type === "text" && child.value.trim() === ""),
|
||||
);
|
||||
if (slides.length > 1 && onlyImages) return <FigureGallery slides={slides} />;
|
||||
return node?.children.some(
|
||||
(child) =>
|
||||
child.type === "element" && child.tagName === "img",
|
||||
) ? (
|
||||
<div>{children}</div>
|
||||
) : (
|
||||
<p>{children}</p>
|
||||
),
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) =>
|
||||
typeof src === "string" && src.startsWith("/figures/endor-") ? (
|
||||
<ArchitectureFigure source={src} alt={alt ?? "Endor 架构图"} />
|
||||
@@ -201,7 +228,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 +240,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
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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;
|
||||
@@ -49,6 +54,13 @@ 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)) {
|
||||
|
||||
@@ -882,6 +882,57 @@ button {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.figure-gallery {
|
||||
margin: 28px 0;
|
||||
}
|
||||
.figure-gallery .architecture-preview {
|
||||
margin: 0;
|
||||
}
|
||||
.figure-gallery-controls,
|
||||
.figure-gallery-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.figure-gallery-controls {
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.figure-gallery-arrow,
|
||||
.figure-gallery-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #57534e;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.figure-gallery-arrow {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
.figure-gallery-dot span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #d6d3d1;
|
||||
}
|
||||
.figure-gallery-dot[aria-current="true"] span {
|
||||
background: #2563eb;
|
||||
}
|
||||
.figure-gallery-arrow:hover,
|
||||
.figure-gallery-dot:hover {
|
||||
background: #f5f5f4;
|
||||
}
|
||||
.figure-gallery-arrow:focus-visible,
|
||||
.figure-gallery-dot:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.image-lightbox {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
@@ -1262,3 +1313,71 @@ 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;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
+111
-21
@@ -21,90 +21,180 @@
|
||||
"group": "架构设计",
|
||||
"description": "Endor 的全球入口、区域运行时与对象数据。",
|
||||
"draft": true,
|
||||
"featured": true
|
||||
"featured": true,
|
||||
"slug": "endor/endor-concepts-draft",
|
||||
"category": "Endor"
|
||||
},
|
||||
"01-getting-started": {
|
||||
"group": "开始使用",
|
||||
"description": "本地开发、文档编写与发布。"
|
||||
"description": "本地开发、文档编写与发布。",
|
||||
"slug": "endor/getting-started",
|
||||
"category": "Endor"
|
||||
},
|
||||
"02-mounting-draft": {
|
||||
"group": "架构设计",
|
||||
"description": "节点、对象与统一接入协议。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "endor/mounting-draft",
|
||||
"category": "Endor"
|
||||
},
|
||||
"03-figures": {
|
||||
"group": "图形档案",
|
||||
"description": "Python 源码与可编辑 SVG 的制图工作流。"
|
||||
"description": "Python 源码与可编辑 SVG 的制图工作流。",
|
||||
"slug": "endor/figures",
|
||||
"category": "Endor"
|
||||
},
|
||||
"04-endor-purpose-draft": {
|
||||
"group": "项目讨论",
|
||||
"description": "从程序入口走向用户与 AI 的对象操作。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "endor/endor-purpose-draft",
|
||||
"category": "Endor"
|
||||
},
|
||||
"05-object-graphs-gc": {
|
||||
"collection": "对象关系与垃圾回收",
|
||||
"group": "运行时基础",
|
||||
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。"
|
||||
"description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。",
|
||||
"slug": "language-and-runtime/object-relations/object-graphs-gc",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"pretext/00-overview": {
|
||||
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
|
||||
"featured": true
|
||||
"featured": true,
|
||||
"slug": "source-studies/pretext/overview",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"language-design/00-overview": {
|
||||
"description": "从名称与作用域到反射与表示,按 271 项议题逐步讨论语言设计。",
|
||||
"featured": true
|
||||
"featured": true,
|
||||
"slug": "language-and-runtime/language-design/overview",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/02-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/names-and-scope",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/03-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/values-and-types",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/04-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/storage-and-lifetimes",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/05-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/objects-and-members",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/06-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/expressions-and-operators",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/07-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/functions-and-calls",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/08-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/control-flow",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/09-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/collections-and-access",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/10-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/errors-and-resources",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/11-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/modules-and-execution",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/12-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/concurrency-and-async",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/13-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/external-interaction",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"language-design/14-questions": {
|
||||
"description": "语言设计讨论底稿;包含问题、验收要求和当前确认状态。",
|
||||
"draft": true
|
||||
"draft": true,
|
||||
"slug": "language-and-runtime/language-design/reflection-and-representation",
|
||||
"category": "语言与运行时"
|
||||
},
|
||||
"pretext/01-chapter": {
|
||||
"slug": "source-studies/pretext/text-measurement",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/02-chapter": {
|
||||
"slug": "source-studies/pretext/prepare-and-layout",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/03-chapter": {
|
||||
"slug": "source-studies/pretext/text-segmentation",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/04-chapter": {
|
||||
"slug": "source-studies/pretext/measurement-cache",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/05-chapter": {
|
||||
"slug": "source-studies/pretext/line-breaking",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/06-chapter": {
|
||||
"slug": "source-studies/pretext/height-only-layout",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/07-chapter": {
|
||||
"slug": "source-studies/pretext/long-text",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/08-chapter": {
|
||||
"slug": "source-studies/pretext/accuracy",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/09-chapter": {
|
||||
"slug": "source-studies/pretext/virtual-lists",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/10-chapter": {
|
||||
"slug": "source-studies/pretext/performance-verification",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/11-chapter": {
|
||||
"slug": "source-studies/pretext/source-reading",
|
||||
"category": "源码研究"
|
||||
},
|
||||
"pretext/12-chapter": {
|
||||
"slug": "source-studies/pretext/applying-the-design",
|
||||
"category": "源码研究"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pnpm dev
|
||||
|
||||
## 添加文档
|
||||
|
||||
在 `content/docs` 下添加 Markdown 文件,例如 `04-runtime.md`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。该文件对应 `/docs/04-runtime`。
|
||||
在 `content/docs` 下添加 Markdown 文件,例如 `04-runtime.md`。文件名使用小写字母、数字和连字符;第一个一级标题是导航标题。在 `content/catalog.json` 的 `documents` 中以源文件路径登记 `slug` 和 `category`,例如 `slug: "endor/runtime"`、`category: "Endor"`。公开地址为 `/docs/endor/runtime`;源文件编号仅用于排序,不进入公开 URL。既有源文件地址保留永久跳转,修改已有公开 slug 时需另行保留旧地址映射。
|
||||
|
||||
开发模式下刷新页面读取修改;本地生产预览需要重新构建。
|
||||
|
||||
|
||||
@@ -16,4 +16,27 @@ pnpm figures
|
||||
|
||||
CI 会重新生成图片并检查与提交版本一致。Python 依赖保留独立缓存,不进入生产运行镜像。
|
||||
|
||||
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局、PNG 转换或 TypeScript SVG 工具。
|
||||
图形应使用本地字体与资源,并明确标注草案状态。未来可按具体需求加入自动布局或 TypeScript SVG 工具。
|
||||
|
||||
## 位图默认发布压缩版本
|
||||
|
||||
SVG 保留可编辑矢量格式;漫画、照片等位图的原始 PNG、JPEG 放在 `tools/figures/assets`,不放进 `public`。浏览器只使用生成的 WebP,正文、点击放大和图片适应屏幕都使用同一个压缩地址。
|
||||
|
||||
共享策略位于 `content/image-policy.json`:默认最大宽度 1600 像素、WebP 质量 82、每张图最多 192 KiB,不放大小图。修改策略后重新生成并检查公式、细线和小字是否清晰。
|
||||
|
||||
以下涉及图像编码与构建产物生成的命令在已确认的远程 CI/CD runner 上执行;未获明确许可时不在用户本机运行。
|
||||
|
||||
```sh
|
||||
pnpm --filter @endor/docs images
|
||||
pnpm --filter @endor/docs test:images
|
||||
```
|
||||
|
||||
原图相对 `tools/figures/assets` 的路径会映射到 `apps/docs/public/figures` 下同名的 `.webp`。例如 `endor-review-gate.png` 对应 `/figures/endor-review-gate.webp`。文档只嵌入后一个地址,制作原稿不随网页发布。
|
||||
|
||||
远程生产构建会运行压缩脚本;CI 先生成 WebP,再重新编码核对产物与原稿、策略是否一致。WebP 是构建派生文件,不必在本机生成或提交;Git 保存原稿、制作记录与共享策略。检查同时拒绝公开目录里的原始位图及 Markdown 图片或 HTML img 直接引用的原始 PNG、JPEG。SVG 无需转成位图,仍按前述流程提交可编辑输出。
|
||||
|
||||
## 同一位置切换多张配图
|
||||
|
||||
把两张或更多 `/figures/endor-` 图片写在同一段,中间换行但不插入空行,正文会将它们呈现为图组。左右按钮和键盘左右方向键可以切换,下方圆点表示当前图片,也可点击直接选择。图片保留点击放大能力。
|
||||
|
||||
图组一次只挂载当前图片,切换后才加载下一张压缩图,不预先下载整组原图。
|
||||
|
||||
@@ -16,6 +16,54 @@
|
||||
|
||||
这一定位希望形成的体验是:**对象池构成持续可用的工作世界,Agent 成为其中执行工作的主体,Endor 语言让对象和行为能够被表达、检查与组合。** 用户与 AI 因而可以在同一套对象基础上发起、接续和检查工作。这是本文的设计方向,不表示所有系统能力已经实现。
|
||||
|
||||
## 让产出成为检查下一次产出的帮手
|
||||
|
||||

|
||||

|
||||
|
||||
两张漫画用检查门与检查台表达同一个目标,可用左右按钮或下方圆点切换。
|
||||
|
||||
上方把 Coding Agent 比作一台不断吐出工作结果的机器。机器可以持续产出,但人的时间和精力没有同步增长;等待检查的材料越积越多。虚线圈出的 **∫dW** 表示累积的待处理工作,门上的 **C₀** 表示在这个对比中保持不变的检查能力。通过门、带上勾的文件才表示已处理的结果。
|
||||
|
||||
下方的机器吐出同样的原始文件,改变的是 Endor 如何组织和接续这些产出。人和检查门直接面对的是这次新增的 **dW**;已经处理的文件被组织成有类型、来源、关系与可调用能力的对象,成为图里的蓝色帮手。帮手的箭头指向检查门,表达它们为后续检查提供支持,而不是让新生成的结果自己证明自己正确。
|
||||
|
||||
例如,已确认的需求可以成为验收条件;稳定的接口约定可以帮助发现不兼容修改;图片发布规则可以检查下一份文档有没有直接嵌入原图。**过去的产出从待处理的负担,逐步变成帮助人处理新工作的能力。** 新结果是否符合要求,仍需要适用的检查逻辑与人的判断。
|
||||
|
||||
门上的 **C₀ + k∫dW** 表示我们希望实现的积累效应:在原有能力上,增加由历史成果转化而来的可复用检查能力。这里的积分、微分和系数 k 是设计隐喻,不是性能公式或实测结论;只有经过确认、适合复用并保持有效的成果才能贡献能力,产出数量不等于正确性,实际检查也可能需要重新覆盖受影响的历史对象。
|
||||
|
||||
> **设计目标**:这张漫画表达希望建立的工作组织方式,不表示当前 Endor 已能自动把任意文件变成检查工具。要兑现它,需要保存来源和依赖、表达检查规则、执行检查,并把失败与待确认事项呈现给人。
|
||||
|
||||
## 从压缩图片想到的继承:让要求成为共同能力
|
||||
|
||||
这张漫画也带来一个具体要求:**文档在浏览器中默认使用压缩图,原图留作制作与归档,不因打开正文或放大图片就下载原图。** 如果很多文档都需要这条规则,用户不应该每次写文档时重新提醒一次。
|
||||
|
||||
单次 Coding Agent 对话中的“记得压缩图片”,通常首先是一项需要读取、理解和落实的指令。指令可以放进项目约定、技能或模板,也可以让 Agent 写出共享压缩脚本;这些都能减少重复劳动。但一句自然语言要求本身,并没有自动建立“这类文档如何派生、谁沿用默认行为、哪个文档允许覆盖”的类型与行为关系。这里讨论的是这种**可持续、可检查的继承**,不是说 Agent 不能编写使用继承的程序。
|
||||
|
||||
### 为什么 Endor 的设计能承载继承
|
||||
|
||||
在现有设计中,文档、图片、类型定义、函数和 Agent 都进入同一对象体系。对象具有可引用的身份,类型可以表达对象的结构与能力,函数可以表达处理行为;继承、实例化与代码依赖可以保存为有不同含义的关联。因此,“默认如何发布文档图片”可以被表达为一个可引用的共同定义,以及读取和执行它的行为,而不只是一段随对话流逝的提醒。
|
||||
|
||||
沿着这个设计方向,可以把图片发布要求放到共享文档类型或其呈现策略上:普通文档沿用“优先压缩图”的默认行为,需要精确查看像素的专业文档再显式采用另一种策略。具体通过类型继承、策略对象委托还是能力组合实现,需要结合语言规则确定;不是因为对象之间有一个链接,就天然获得了继承。
|
||||
|
||||
| 层次 | 压缩图例子中承担的职责 |
|
||||
| --- | --- |
|
||||
| 共同定义 | 保存默认图片尺寸、编码质量、字节预算及浏览器应使用的派生版本 |
|
||||
| 继承或委托关系 | 让采用共同定义的文档持续引用它,明确哪些行为来自共同来源 |
|
||||
| 实际处理能力 | 根据策略生成压缩版本,保留它与原图的来源关系,并让呈现入口选择压缩版本 |
|
||||
| 检查与覆盖 | 检查新产出是否符合默认要求;特例显式选择覆盖,并能说明生效的规则来自哪里 |
|
||||
|
||||
关键不只是“记住用户偏好”,而是**规则有归属,使用者有关系,行为可以执行,结果能够检查**。当 Agent 新建同类文档时,它使用已有文档能力与策略,就能让之前确认的要求继续参与这次工作;人主要判断本次差异和真正的例外。这也是漫画里历史成果增强检查门的一个具体例子。
|
||||
|
||||
定义和依赖是对象,使系统有基础追踪受影响的文档,并通过名称、类型与调用检查发现部分不一致;图片编码与发布仍需实际运行相应能力。“一切皆对象”本身不保证每份文档都调用正确的渲染器,也不自动决定规则修改后是否追溯更新历史文档。
|
||||
|
||||
> **语义边界**:继承与委托、覆盖与冲突规则仍在[语言设计问题集的对象模型部分](/docs/language-design/05-questions)讨论中。父定义更新如何生效、文档是否绑定特定版本、多来源冲突如何处理,需要明确后才能成为稳定承诺。这里解释 Endor 为什么有承载这类继承的共同基础,以及希望形成的体验,不宣称这些规则已全部实现。
|
||||
|
||||
### 本站现在怎样落实这条规则
|
||||
|
||||
Tabularium 目前用共享构建策略落实这项要求:原图保存在非公开的制作目录,脚本统一生成有尺寸、质量与字节预算的 WebP,文档正文和放大查看都使用这个压缩版本。新增文档直接引用生成的图片,不需要逐篇重新实现压缩;构建检查会拒绝公开原始位图或直接嵌入原始 PNG、JPEG。
|
||||
|
||||
这是今天在文档仓库里可以执行和验证的共享规则,具体工作流见[图形与脚本](/docs/03-figures)。它示范了“把一次提醒变成后续工作可复用的能力”,并不表示 Tabularium 这个 Next.js 网站已经运行了 Endor 的继承机制。
|
||||
|
||||
## 一对图:我们希望改变什么
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"maxWidth": 1600,
|
||||
"quality": 82,
|
||||
"maxBytes": 196608
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"lint": "pnpm --filter @endor/docs run lint",
|
||||
"typecheck": "pnpm --filter @endor/docs run typecheck",
|
||||
"figures": "uv run --locked --project tools/figures endor-figures",
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm run figures && pnpm run build",
|
||||
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:auth && pnpm --filter @endor/docs run images && pnpm --filter @endor/docs run test:images && pnpm run figures && pnpm run build",
|
||||
"test:auth": "pnpm --filter @endor/docs run test:auth"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4
-3
@@ -59,6 +59,9 @@ importers:
|
||||
postcss:
|
||||
specifier: 8.5.31
|
||||
version: 8.5.31
|
||||
sharp:
|
||||
specifier: 0.35.5
|
||||
version: 0.35.5(@types/node@22.20.5)
|
||||
tailwindcss:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
@@ -2876,8 +2879,7 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.4.3': {}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
optional: true
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.5':
|
||||
optionalDependencies:
|
||||
@@ -5184,7 +5186,6 @@ snapshots:
|
||||
'@img/sharp-win32-ia32': 0.35.5
|
||||
'@img/sharp-win32-x64': 0.35.5
|
||||
'@types/node': 22.20.5
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
|
||||
@@ -6,6 +6,8 @@ uv sync --project tools/figures --locked
|
||||
pnpm run lint
|
||||
pnpm run typecheck
|
||||
pnpm --filter @endor/docs run test:auth
|
||||
pnpm --filter @endor/docs run images
|
||||
pnpm --filter @endor/docs run test:images
|
||||
cp -R apps/docs/public/figures /tmp/expected-figures
|
||||
pnpm run figures
|
||||
uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ def verify(base: str) -> None:
|
||||
assert status == 200 and json.loads(health) == {"status": "ok"}
|
||||
status, _, login = get("/login")
|
||||
assert status == 200 and "输入动态码" in login.decode()
|
||||
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
|
||||
for path in ("/", "/docs/00-endor-concepts-draft", "/figures/endor-architecture.svg", "/figures/endor-review-gate.webp", "/figures/endor-review-desk.webp", "/docs/pretext/01-chapter", "/materials/pretext/verify.cjs", "/materials/pretext/results.chrome156.json"):
|
||||
status, headers, _ = get(path)
|
||||
assert status == 307, path
|
||||
target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"]))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,14 @@
|
||||
Endor inspection desk comic — approved second slide
|
||||
|
||||
Source: built-in image_gen; physics inspection-bench variant with corrected top-row flow.
|
||||
Original: exec-432e0f3d-d01d-4658-b5d5-0c63db95e472.png
|
||||
The user selected this alongside the gate variant for one carousel.
|
||||
|
||||
The two identical machines emit raw papers. Above, a dashed integral dW boundary
|
||||
encloses the backlog BEFORE one person's fixed-capacity C0 inspection desk.
|
||||
Below, a dashed dW boundary encloses the one new paper being inspected. Earlier
|
||||
checked outputs become structured document helpers that contribute additional
|
||||
magnifiers to the Endor desk, whose capacity is C0 + k integral dW.
|
||||
|
||||
Store this PNG as the production master outside public. The confirmed remote
|
||||
runner generates /figures/endor-review-desk.webp using content/image-policy.json.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,20 @@
|
||||
Endor review gate comic — approved source
|
||||
|
||||
Source: built-in image_gen, physics gate variant, approved by the user on 2026-10-28.
|
||||
Original: exec-380f8db3-6450-4981-97df-473c13a62223.png
|
||||
The PNG is the production master, not a public browser asset.
|
||||
The confirmed remote runner generates the browser WebP with:
|
||||
pnpm --filter @endor/docs images
|
||||
|
||||
Visual specification:
|
||||
Two rows, white background, charcoal outlines, blue accents, identical coding
|
||||
machines emitting identical raw sheets. Top: accumulated unchecked sheets inside
|
||||
a dashed integral dW boundary, one person and a fixed C0 inspection gate, checked
|
||||
output after the gate. Bottom: one new sheet inside a dashed dW boundary directly
|
||||
inspected by a stick figure at an Endor gate. Gate capacity is C0 + k integral dW.
|
||||
Checked output becomes a structured document helper, joins historical helpers,
|
||||
and blue arrows from helpers feed the gate to increase inspection capability.
|
||||
No dialogue or explanatory prose. Math is a conceptual metaphor, not measured data.
|
||||
|
||||
The bitmap master retains the approved artwork, including its mathematical
|
||||
symbols. Browser optimization changes encoding and dimensions only.
|
||||
Reference in New Issue
Block a user