Expand document collections with Pretext tutorial and simplify library navigation
Verify and deploy Tabularium / verify-and-deploy (push) Successful in 5m47s

This commit is contained in:
2026-10-28 13:42:51 +08:00
parent 1f3ab5a7ee
commit 2b3b3a1ae4
28 changed files with 1174 additions and 761 deletions
+5 -3
View File
@@ -17,7 +17,7 @@ pnpm dev
```text
apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS
content/docs/ Markdown 文档源,文件名成为 /docs/<slug> 路由
content/docs/ Markdown 文档源,支持目录,路径成为 /docs/<path> 路由
tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
```
@@ -30,11 +30,13 @@ tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图
- `pnpm start`:在本地预览已构建站点。
- `pnpm check`:依次执行 lint、类型检查、制图与构建。
站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/Ctrl K`、方向键与 Enter;代码块提供复制与反馈。桌面提供分组导航和滚动高亮的页内目录,小屏可展开文档菜单与页内目录。首页入口、草案提示、前后篇导航均引用现有 Markdown 内容。
站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/Ctrl K`、方向键与 Enter;代码块提供复制与反馈。桌面提供分组导航和滚动高亮的页内目录,小屏可展开文档菜单与页内目录。首页入口、草案提示、前后篇导航均引用现有 Markdown 内容和资料目录配置。
参考设计截图与研究记录保存在 `design/references`;浏览器验收截图与检查说明保存在 `design/verification`。这些文件不作为站点静态资源发布。
文档标题取首个 `# 标题`,文档排序按文件名。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/<name>.svg`。不解析原始 HTML。
文档标题取首个 `# 标题`,文档排序按相对路径。支持多层目录(例如 `content/docs/pretext/01-chapter.md` 对应 `/docs/pretext/01-chapter`);旧文档地址保持可用。`content/catalog.json` 管理合集、分类、摘要、草案与推荐,新增资料无需修改页面代码。首页推荐最多3篇,目录每页8条,可按合集与标题筛选;全站搜索仍覆盖全文。相邻篇目限于当前合集。
Pretext 教程位于 `content/docs/pretext`,包含导读与12章,来自本地 Pretext 研究成果。源码链接锁定上游研究版本;实验脚本与原始记录位于 `apps/docs/public/materials/pretext`,和正文一样经过登录验证。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/<name>.svg`。不解析原始 HTML。
生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。
main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署。工作流也支持手动触发。
+1 -1
View File
@@ -5,5 +5,5 @@ import nextTs from "eslint-config-next/typescript";
export default defineConfig([
...nextVitals,
...nextTs,
globalIgnores([".next/**", "out/**", "next-env.d.ts"]),
globalIgnores([".next/**", "out/**", "next-env.d.ts", "public/materials/**"]),
]);
@@ -0,0 +1,149 @@
{
"note": "Original exploratory run. Fixed order, repeated fixtures, headless DPR 1; not an official benchmark or accuracy baseline. Date: 2026-10-28 Asia/Shanghai.",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
"iterations": 3000,
"rounds": 5,
"out": [
{
"name": "latin",
"chars": 1428,
"first": {
"calls": 62,
"units": 529
},
"second": {
"calls": 0,
"units": 0
},
"layoutCalls": 0,
"fastPath": true,
"widths": [
80,
121,
200,
319,
320,
321,
480,
800
],
"heights": [
3744,
2592,
1440,
864,
864,
864,
576,
336
],
"domHeights": [
3744,
2592,
1440,
864,
864,
864,
576,
336
],
"pretextMedianMs": 1.7000000476837158,
"domInterleavedMedianMs": 86,
"sink": 46530000
},
{
"name": "cjk",
"chars": 408,
"first": {
"calls": 53,
"units": 67
},
"second": {
"calls": 0,
"units": 0
},
"layoutCalls": 0,
"fastPath": true,
"widths": [
80,
121,
200,
319,
320,
321,
480,
800
],
"heights": [
2016,
1440,
864,
528,
504,
504,
336,
216
],
"domHeights": [
2016,
1440,
864,
528,
504,
504,
336,
216
],
"pretextMedianMs": 1.5,
"domInterleavedMedianMs": 57.80000019073486,
"sink": 26433000
},
{
"name": "pre-wrap",
"chars": 888,
"first": {
"calls": 50,
"units": 497
},
"second": {
"calls": 0,
"units": 0
},
"layoutCalls": 0,
"fastPath": false,
"widths": [
80,
121,
200,
319,
320,
321,
480,
800
],
"heights": [
2592,
1728,
864,
864,
864,
864,
576,
576
],
"domHeights": [
2592,
1728,
864,
864,
864,
864,
576,
576
],
"pretextMedianMs": 6.299999952316284,
"domInterleavedMedianMs": 93.90000009536743,
"sink": 36828000
}
]
}
@@ -0,0 +1,89 @@
// Tutorial mechanism probe, not the official performance or accuracy gate.
// Run from any directory; see README.md for runtime and Playwright setup.
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const {stripTypeScriptTypes} = require('node:module');
const assert = require('node:assert/strict');
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const src = path.resolve(__dirname, '../../src');
const outputFlag = process.argv.indexOf('--output');
if (outputFlag !== -1 && !process.argv[outputFlag + 1]) throw new Error('--output requires a file path');
const modules = new Map();
function compile(dir) {
for (const entry of fs.readdirSync(dir,{withFileTypes:true})) {
const file = path.join(dir,entry.name);
if(entry.isDirectory()) compile(file);
else if(file.endsWith('.ts') && !file.endsWith('.test.ts')) {
modules.set('/'+path.relative(src,file).replace(/\.ts$/,'.js'),stripTypeScriptTypes(fs.readFileSync(file,'utf8'),{mode:'transform'}));
}
}
}
compile(src);
const server = http.createServer((req,res)=>{
res.setHeader('Content-Type',req.url==='/'?'text/html':'text/javascript');
if(req.url !== '/' && !modules.has(req.url)) { res.statusCode=404; res.end('Missing module'); return; }
res.end(req.url==='/'?'<html lang="en"><body></body></html>':modules.get(req.url));
});
(async()=>{
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve)});
let browser;
try {
browser = await chromium.launch({headless:true,...(process.env.CHROME_EXECUTABLE ? {executablePath:process.env.CHROME_EXECUTABLE} : {})});
const page = await browser.newPage();
await page.goto(`http://127.0.0.1:${server.address().port}/`);
const result = await page.evaluate(async()=>{
const api = await import('/layout.js');
let calls=0,units=0;
const original=OffscreenCanvasRenderingContext2D.prototype.measureText;
OffscreenCanvasRenderingContext2D.prototype.measureText=function(text){calls++;units+=text.length;return original.call(this,text)};
const font='16px "Helvetica Neue", "PingFang SC", "Geeza Pro"';
const cases=[
{name:'latin',text:'The quick brown fox jumps over the lazy dog. Accurate text layout needs cached measurements and careful line breaking. '.repeat(12)},
{name:'cjk',text:'高性能文本布局的关键,是复用字体测量结果,并正确处理中文标点和换行。'.repeat(12)},
{name:'pre-wrap',text:'A long line with\ttabs\n preserved spaces and a discretionary soft\u00adhyphen.\n'.repeat(12),options:{whiteSpace:'pre-wrap'}},
];
const out=[];
for(const c of cases){
api.clearCache();calls=0;units=0;
const p=api.prepare(c.text,font,c.options);
const first={calls,units};calls=0;units=0;
api.prepare(c.text,font,c.options);
const second={calls,units};calls=0;
const widths=[80,121,200,319,320,321,480,800];
const heights=widths.map(w=>api.layout(p,w,24).height);
const layoutCalls=calls;
const el=document.createElement('div');
Object.assign(el.style,{font,lineHeight:'24px',overflowWrap:'break-word',whiteSpace:c.options?.whiteSpace||'normal',position:'absolute'});
el.textContent=c.text;document.body.appendChild(el);
const domHeights=widths.map(w=>{el.style.width=w+'px';return el.getBoundingClientRect().height});
let sink=0;
for(let i=0;i<3000;i++)sink+=api.layout(p,widths[i%8],24).height;
const pretextMs=[],domMs=[];
for(let r=0;r<5;r++){
let t=performance.now();
for(let i=0;i<3000;i++)sink+=api.layout(p,widths[i%8],24).height;
pretextMs.push(performance.now()-t);
t=performance.now();
for(let i=0;i<3000;i++){el.style.width=widths[i%8]+'px';sink+=el.getBoundingClientRect().height;}
domMs.push(performance.now()-t);
}
el.remove();
const withSegments=api.prepareWithSegments(c.text,font,c.options);
const lineApiCounts=widths.map(w=>api.layoutWithLines(withSegments,w,24).lines.length);
const median=a=>a.sort((a,b)=>a-b)[2];
out.push({name:c.name,chars:c.text.length,first,second,layoutCalls,fastPath:p.simpleLineWalkFastPath,widths,heights,domHeights,lineApiCounts,pretextMedianMs:median(pretextMs),domInterleavedMedianMs:median(domMs),sink});
}
return {userAgent:navigator.userAgent,devicePixelRatio,language:document.documentElement.lang,font,iterations:3000,rounds:5,out};
});
for(const row of result.out){
assert.ok(row.first.calls > 0, row.name+': first preparation must measure');
assert.equal(row.second.calls,0,row.name+': repeated preparation must reuse Canvas metrics');
assert.equal(row.layoutCalls,0,row.name+': layout must not measure');
assert.deepEqual(row.heights,row.domHeights,row.name+': heights must match DOM for these fixtures');
assert.deepEqual(row.lineApiCounts,row.heights.map(h=>h/24),row.name+': line APIs must agree');
}
if(outputFlag !== -1) fs.writeFileSync(path.resolve(process.argv[outputFlag + 1]),JSON.stringify(result,null,2)+'\n');
console.log(JSON.stringify(result,null,2));
} finally {if(browser)await browser.close();server.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
+15 -2
View File
@@ -141,6 +141,9 @@ async function main() {
"/",
"/docs/00-endor-concepts-draft",
"/figures/endor-architecture.svg",
"/docs/pretext/01-chapter",
"/materials/pretext/verify.cjs",
"/materials/pretext/results.chrome156.json",
`/_next/data/${buildId}/index.json`,
`/_next/data/${buildId}/docs/00-endor-concepts-draft.json`,
]) {
@@ -155,7 +158,7 @@ async function main() {
path,
);
}
for (const path of ["/api/private", "/api/unknown"])
for (const path of ["/api/private", "/api/unknown", "/api/search"])
assert.equal((await request(path)).status, 401, path);
assert.equal(
(
@@ -176,13 +179,18 @@ async function main() {
const headers = {
Authorization: "Bearer " + signSession(config, undefined, 600),
};
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")));
let home = "";
for (const route of routes) {
const response = await request(route, { headers });
assert.equal(response.status, 200, route);
if (route === "/") home = await response.text();
}
assert.ok(home.includes("TABULARIUM"));
assert.ok(home.includes("资料库"));
assert.ok(home.includes("/docs/pretext/00-overview"));
const assets = new Set(
[...home.matchAll(/(?:src|href)="(\/_next\/static\/[^" ]+)"/g)].map((m) =>
m[1].replaceAll("&amp;", "&"),
@@ -191,6 +199,11 @@ async function main() {
assert.ok(assets.size);
for (const path of assets)
assert.equal((await request(path, { headers })).status, 200, path);
for (const name of ["verify.cjs", "results.chrome156.json"]) {
const response = await request("/materials/pretext/" + name, { headers });
assert.equal(response.status, 200, name);
assert.ok((await response.text()).length > 100);
}
for (const name of readdirSync(join(__dirname, "public/figures"))) {
if (!name.endsWith(".svg")) continue;
const response = await request("/figures/" + name, { headers });
+17
View File
@@ -0,0 +1,17 @@
import type { CSSProperties } from "react";
// One stroke style for controls and navigation; labels carry the meaning.
export function Icon({ name, size = 18, style }: {
name: "search" | "arrow" | "book" | "code" | "menu" | "close";
size?: number; style?: CSSProperties;
}) {
const paths = {
search: <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4 4" /></>,
arrow: <path d="M5 12h14m-6-6 6 6-6 6" />,
book: <><path d="M12 5c-3-2-6-2-9-1v15c3-1 6-1 9 1 3-2 6-2 9-1V4c-3-1-6-1-9 1Z" /><path d="M12 5v15" /></>,
code: <path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16" />,
menu: <path d="M4 6h16M4 12h16M4 18h16" />,
close: <path d="m6 6 12 12M6 18 18 6" />,
};
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={style}>{paths[name]}</svg>;
}
+31 -20
View File
@@ -1,7 +1,8 @@
import Link from "next/link";
import { Icon } from "@/components/Icon";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { DocLink } from "@/lib/docs";
import { sectionFor } from "@/lib/navigation";
export function Brand() {
return (
@@ -28,7 +29,7 @@ export function Navigation({
current?: string;
onNavigate?: () => void;
}) {
const groups = [...new Set(docs.map((d) => sectionFor(d.slug).group))];
const groups = [...new Set(docs.map((d) => d.group))];
return (
<nav aria-label="文档导航">
<Link
@@ -36,13 +37,13 @@ export function Navigation({
className={`nav-home ${!current ? "selected" : ""}`}
href="/"
>
▦ <span>文档概览</span>
<Icon name="book" size={17} /> <span>资料库</span>
</Link>
{groups.map((group) => (
<div className="nav-group" key={group}>
<p>{group}</p>
{docs
.filter((d) => sectionFor(d.slug).group === group)
.filter((d) => d.group === group)
.map((d) => (
<Link
onClick={onNavigate}
@@ -52,17 +53,13 @@ export function Navigation({
key={d.slug}
>
{d.title}
{sectionFor(d.slug).label.includes("草案") && (
{d.draft && (
<span className="tiny-dot" aria-label="草案" />
)}
</Link>
))}
</div>
))}
<div className="nav-note">
<span className="status-dot" /> 与代码一起演进
<p>文字、设计与图形的共同档案。</p>
</div>
</nav>
);
}
@@ -79,18 +76,32 @@ export function Shell({
const input = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const [searchDocs, setSearchDocs] = useState(docs);
const [searchLoaded, setSearchLoaded] = useState(false);
const [searchStatus, setSearchStatus] = useState("");
const [selected, setSelected] = useState(0);
const [menu, setMenu] = useState(false);
const results = docs.filter((d) =>
`${d.title} ${d.searchText}`
const results = searchDocs.filter((d) =>
`${d.title} ${d.description} ${d.searchText ?? ""}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
);
function openSearch() {
async function openSearch() {
setQuery("");
setSelected(0);
dialog.current?.showModal();
input.current?.focus();
if (searchLoaded) return;
setSearchStatus("正在加载全文索引…");
try {
const response = await fetch("/api/search");
if (!response.ok) throw new Error("Search unavailable");
setSearchDocs(await response.json());
setSearchLoaded(true);
setSearchStatus("");
} catch {
setSearchStatus("全文搜索暂时不可用,当前仅搜索标题和摘要。关闭后重新打开可重试。");
}
}
function closeSearch() {
dialog.current?.close();
@@ -100,8 +111,7 @@ export function Shell({
function keyboard(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
dialog.current?.showModal();
input.current?.focus();
trigger.current?.click();
}
}
document.addEventListener("keydown", keyboard);
@@ -115,7 +125,7 @@ export function Shell({
<header className="site-header">
<Brand />
<nav className="top-nav" aria-label="主导航">
<Link href="/">文档概览</Link>
<Link href="/">资料库</Link>
<Link href="/docs/00-endor-concepts-draft">架构设计</Link>
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
仓库 ↗
@@ -127,7 +137,7 @@ export function Shell({
aria-label="搜索文档"
onClick={openSearch}
>
<span aria-hidden="true">⌕</span>
<Icon name="search" />
<span>搜索文档…</span>
<kbd>⌘ K</kbd>
</button>
@@ -154,7 +164,7 @@ export function Shell({
aria-expanded={menu}
aria-controls="mobile-navigation"
>
☰ <span>文档目录</span>
<Icon name="menu" size={16} /> <span>文档目录</span>
<span>{menu ? "−" : "+"}</span>
</button>
</div>
@@ -178,7 +188,7 @@ export function Shell({
onClose={() => trigger.current?.focus()}
>
<div className="search-input-row">
<span aria-hidden="true">⌕</span>
<Icon name="search" />
<input
ref={input}
value={query}
@@ -214,6 +224,7 @@ export function Shell({
Esc
</button>
</div>
{searchStatus && <p className="search-status" role="status">{searchStatus}</p>}
<div id="search-results" className="search-results">
<p className="eyebrow" role="status">
{query ? `${results.length} 篇匹配文档` : "浏览文档"}
@@ -228,11 +239,11 @@ export function Shell({
key={d.slug}
href={`/docs/${d.slug}`}
>
<span>{sectionFor(d.slug).group}</span>
<span>{d.group}</span>
<strong>
{d.title} <span aria-hidden="true">↗</span>
</strong>
<small>{sectionFor(d.slug).description}</small>
<small>{d.description}</small>
</Link>
))}
{!results.length && (
+30 -13
View File
@@ -1,27 +1,44 @@
import fs from "node:fs/promises";
import path from "node:path";
import catalog from "../../../../content/catalog.json";
const directory = path.resolve(process.cwd(), "../../content/docs");
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type DocLink = { slug: string; title: string; searchText: string };
const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
type Metadata = { group?: string; description?: string; draft?: boolean; featured?: boolean };
export type DocLink = {
slug: 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 title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug;
return { slug, title, content, searchText: content };
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,
group: metadata.group ?? collection.title, collection: collection.title,
description: metadata.description ?? collection.description,
draft: metadata.draft ?? false, featured: metadata.featured ?? false,
};
}
export async function listDocs(): Promise<DocLink[]> {
const files = (await fs.readdir(directory))
.filter((file) => file.endsWith(".md") && validSlug.test(file.slice(0, -3)))
.sort();
return Promise.all(
files.map(async (file) => {
const { slug, title, searchText } = await readDoc(file.slice(0, -3));
return { slug, title, searchText };
}),
);
async function walk(folder: string): Promise<string[]> {
const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true });
const files = await Promise.all(entries.map(async (entry) => {
const relative = folder ? `${folder}/${entry.name}` : entry.name;
if (entry.isDirectory()) return walk(relative);
if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3)))
return [relative.slice(0, -3)];
return [];
}));
return files.flat();
}
return Promise.all((await walk("")).sort().map(async (slug) => {
const { content: _, searchText: __, ...doc } = await readDoc(slug);
void _; void __;
return doc;
}));
}
-42
View File
@@ -1,45 +1,3 @@
export type Section = {
group: string;
description: string;
label: string;
symbol: string;
};
export function sectionFor(slug: string): Section {
if (slug.includes("endor-purpose"))
return {
group: "项目讨论",
label: "讨论草案",
symbol: "○",
description: "逐步讨论 Endor 的意义:从程序入口走向用户与 AI 的对象操作。",
};
if (slug.includes("endor-concepts"))
return {
group: "架构设计",
label: "系统架构",
symbol: "◇",
description: "从全球入口、区域运行时到对象数据,了解 Endor 的系统架构。",
};
if (slug.includes("mounting"))
return {
group: "架构设计",
label: "讨论草案",
symbol: "◇",
description: "探索节点、对象与统一接入协议,记录仍在演进的设计。",
};
if (slug.includes("figures"))
return {
group: "图形档案",
label: "工具与工作流",
symbol: "⌘",
description: "从 Python 源码到可编辑 SVG,让图形与文档一起演进。",
};
return {
group: "开始使用",
label: "指南",
symbol: "↗",
description: "搭建本地工作台,添加第一篇文档,了解检查与发布流程。",
};
}
export type Heading = { id: string; text: string; level: number; line: number };
export function headingsFor(content: string): Heading[] {
let fenced = false;
+16
View File
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { listDocs, readDoc } from "@/lib/docs";
// The proxy authenticates this endpoint, including index content.
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
response.setHeader("Cache-Control", "private, no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const docs = await listDocs();
const index = await Promise.all(docs.map(async (doc) => ({
...doc, searchText: (await readDoc(doc.slug)).content,
})));
response.status(200).json(index);
}
@@ -18,15 +18,15 @@ import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { ArchitectureFigure } from "@/components/ArchitectureFigure";
import { CodeBlock } from "@/components/CodeBlock";
import { headingsFor, sectionFor } from "@/lib/navigation";
import { headingsFor } from "@/lib/navigation";
type Props = { doc: Doc; docs: DocLink[] };
export const getStaticPaths: GetStaticPaths = async () => ({
paths: (await listDocs()).map(({ slug }) => ({ params: { slug } })),
paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })),
fallback: false,
});
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
const slug = params?.slug;
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 };
@@ -47,8 +47,9 @@ export default function DocPage({
}: InferGetStaticPropsType<typeof getStaticProps>) {
const headings = headingsFor(doc.content);
const [active, setActive] = useState("");
const section = sectionFor(doc.slug);
const index = docs.findIndex((d) => d.slug === doc.slug);
const siblings = docs.filter((d) => d.collection === doc.collection);
const index = siblings.findIndex((d) => d.slug === doc.slug);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
@@ -80,7 +81,7 @@ export default function DocPage({
<>
<Head>
<title>{doc.title} · Tabularium</title>
<meta name="description" content={section.description} />
<meta name="description" content={doc.description} />
</Head>
<Shell key={doc.slug} docs={docs} current={doc.slug}>
<div className="reading-layout">
@@ -91,13 +92,8 @@ export default function DocPage({
<div className="breadcrumb">
<Link href="/">文档</Link>
<span>/</span>
<span>{section.group}</span>
</div>
<div className="article-meta">
<span className="eyebrow">{section.group}</span>
{section.label.includes("草案") && (
<span className="draft-badge">讨论草案</span>
)}
<span>{doc.group}</span>
{doc.draft && <span className="draft-badge">草案</span>}
</div>
<article className="prose prose-neutral max-w-none">
<ReactMarkdown
@@ -183,7 +179,7 @@ export default function DocPage({
blockquote: ({ children }) => (
<blockquote
className={
section.label.includes("草案")
doc.draft
? "callout callout-draft"
: "callout"
}
@@ -200,7 +196,7 @@ export default function DocPage({
</ReactMarkdown>
</article>
<div className="article-end">
<span>文档与源码共同维护</span>
<a
href={`https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs/src/branch/main/content/docs/${doc.slug}.md`}
>
@@ -209,9 +205,9 @@ export default function DocPage({
</div>
<nav className="pagination" aria-label="相邻文档">
{index > 0 ? (
<Link href={`/docs/${docs[index - 1].slug}`}>
<Link href={`/docs/${siblings[index - 1].slug}`}>
<small>← 上一篇</small>
<strong>{docs[index - 1].title}</strong>
<strong>{siblings[index - 1].title}</strong>
</Link>
) : (
<Link href="/">
@@ -219,31 +215,20 @@ export default function DocPage({
<strong>文档概览</strong>
</Link>
)}
{index < docs.length - 1 && (
{index < siblings.length - 1 && (
<Link
className="next-page"
href={`/docs/${docs[index + 1].slug}`}
href={`/docs/${siblings[index + 1].slug}`}
>
<small>下一篇 →</small>
<strong>{docs[index + 1].title}</strong>
<strong>{siblings[index + 1].title}</strong>
</Link>
)}
</nav>
<footer className="article-footer">
ENDOR / TABULARIUM <span>记录思考,连接系统。</span>
</footer>
</main>
<aside className="toc">
<p className="eyebrow">本页内容</p>
{toc}
<div className="toc-note">
<span className="status-dot" /> {section.label}
<p>
{section.label.includes("草案")
? "这里记录正在讨论的设计,尚不代表现有实现。"
: "每一次理解,都成为下一步的起点。"}
</p>
</div>
</aside>
</div>
</Shell>
+51 -160
View File
@@ -1,168 +1,59 @@
import type { GetStaticProps, InferGetStaticPropsType } from "next";
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
import { listDocs, type DocLink } from "@/lib/docs";
import { Shell, Navigation } from "@/components/Shell";
import { CodeBlock } from "@/components/CodeBlock";
import { sectionFor } from "@/lib/navigation";
import { Icon } from "@/components/Icon";
export const getStaticProps: GetStaticProps<{
docs: DocLink[];
}> = async () => ({ props: { docs: await listDocs() } });
export default function Home({
docs,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<Head>
<title>Tabularium · Endor 文档</title>
<meta
name="description"
content="Endor 的知识档案。探索开发指南、架构设计与可复现的图形工作流。"
/>
</Head>
<Shell docs={docs}>
<div className="home-layout">
<aside className="sidebar">
<Navigation docs={docs} />
</aside>
<main id="content" className="home-main">
<section className="home-hero">
<div className="hero-copy">
<p className="eyebrow">
<span className="status-dot" /> ENDOR KNOWLEDGE ARCHIVE
</p>
<h1>
理解系统。
<br />
构建下一步。
</h1>
<p className="hero-description">
欢迎来到 <strong>Tabularium</strong>,Endor 的知识档案。
<br className="desktop-break" />
从第一篇文档到系统设计,让想法有迹可循。
</p>
<div className="hero-actions">
<Link
className="primary-button"
href="/docs/01-getting-started"
>
开始阅读 <span>→</span>
</Link>
<a className="text-link" href="#explore">
探索档案 <span>↓</span>
</a>
</div>
<p className="hero-footnote">
文档 · 架构 · 图形 <span>持续生长的工作台</span>
</p>
export const getStaticProps: GetStaticProps<{ docs: DocLink[] }> = async () => ({
props: { docs: await listDocs() },
});
const PAGE_SIZE = 8;
export default function Home({ docs }: InferGetStaticPropsType<typeof getStaticProps>) {
const [collection, setCollection] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const collections = [...new Set(docs.map((doc) => doc.collection))];
const filtered = docs.filter((doc) => (!collection || doc.collection === collection) &&
`${doc.title} ${doc.description}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const featured = docs.filter((doc) => doc.featured).slice(0, 3);
return <>
<Head><title>Tabularium · 资料库</title><meta name="description" content="Endor 文档、源码研究与学习资料。" /></Head>
<Shell docs={docs}>
<div className="home-layout">
<aside className="sidebar"><Navigation docs={docs} /></aside>
<main id="content" className="home-main">
<section className="library-intro">
<h1>资料库</h1>
<p>系统设计、源码研究与学习笔记。</p>
</section>
<section className="featured-section" aria-labelledby="featured-title">
<h2 id="featured-title">推荐阅读</h2>
<div className="featured-grid">{featured.map((doc) => <Link className="featured-link" href={`/docs/${doc.slug}`} key={doc.slug}>
<Icon name={doc.collection === "Endor 文档" ? "book" : "code"} size={23} />
<h3>{doc.title.replace("(草案)", "")}</h3>
<p>{doc.description}</p>
<Icon name="arrow" />
</Link>)}</div>
</section>
<section className="library-section" id="library" aria-labelledby="library-title">
<h2 id="library-title">全部资料</h2>
<div className="library-tools">
<div className="collection-filters" role="group" aria-label="资料分类">
{["", ...collections].map((name) => <button key={name} aria-pressed={collection === name} onClick={() => { setCollection(name); setPage(0); }}>{name || "全部"}</button>)}
</div>
<div className="archive-visual" aria-hidden="true">
<div className="archive-grid" />
<div className="archive-caption">TABULARIUM / 001</div>
<div className="archive-sheet sheet-back">
<span>03 — FIGURES</span>
<i />
<i />
<i />
</div>
<div className="archive-sheet sheet-middle">
<span>02 — ARCHITECTURE</span>
<i />
<i />
<i />
</div>
<div className="archive-sheet sheet-front">
<span className="sheet-label">01 / ENDOR</span>
<div className="sheet-emblem">
E<span>↗</span>
</div>
<div className="sheet-rule" />
<b>Ideas, documented.</b>
<small>知识在这里连接。</small>
</div>
<div className="archive-coordinate">
文字与图形,一起演进 <span>↗</span>
</div>
</div>
</section>
<section className="explore-section" id="explore">
<div className="section-heading">
<div>
<p className="eyebrow">EXPLORE THE ARCHIVE</p>
<h2>从这里开始探索</h2>
</div>
<span className="section-count">
{String(docs.length).padStart(2, "0")} 篇文档
</span>
</div>
<div className="doc-cards">
{docs.map((doc, index) => {
const section = sectionFor(doc.slug);
return (
<Link
className="doc-card"
key={doc.slug}
href={`/docs/${doc.slug}`}
>
<div className="card-top">
<span className={`card-icon icon-${index}`}>
{section.symbol}
</span>
<span className="card-number">0{index + 1}</span>
</div>
<p className="card-group">{section.group}</p>
<h3>
{doc.title.replace("(草案)", "")}
<span aria-hidden="true">↗</span>
</h3>
<p className="card-description">{section.description}</p>
<div className="card-bottom">
<span>{section.label}</span>
{section.label.includes("草案") && (
<span className="draft-badge">草案</span>
)}
</div>
</Link>
);
})}
</div>
</section>
<section className="quickstart">
<div>
<p className="eyebrow">LOCAL WORKSPACE</p>
<h2>一个命令,继续探索。</h2>
<p>
在本地启动文档工作台,
<br />
让文字和可复现的图形留在同一个仓库。
</p>
<Link
className="text-link"
href="/docs/01-getting-started#启动"
>
查看完整启动指南 <span>→</span>
</Link>
</div>
<CodeBlock
language="sh"
code={
"pnpm install --frozen-lockfile\nuv sync --project tools/figures --locked\npnpm dev\n"
}
/>
</section>
<footer className="site-footer">
<span>
Endor / <strong>Tabularium</strong>
</span>
<span>记录思考,连接系统。</span>
<a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">
在仓库中探索 ↗
</a>
</footer>
</main>
</div>
</Shell>
</>
);
<label className="library-search"><Icon name="search" /><input aria-label="筛选资料" placeholder="筛选资料…" value={query} onChange={(event) => {setQuery(event.target.value); setPage(0);}} /></label>
</div>
<ul className="library-list">{filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE).map((doc) => <li key={doc.slug}>
<Link href={`/docs/${doc.slug}`}><span className="library-row-title">{doc.title.replace("(草案)", "")}{doc.draft && <span className="draft-badge">草案</span>}</span><span className="library-row-group">{doc.group}</span><Icon name="arrow" size={16} /></Link>
</li>)}</ul>
{filtered.length === 0 && <p className="library-empty">没有找到相关资料。</p>}
{filtered.length > PAGE_SIZE && <nav className="library-pagination" aria-label="资料分页"><button disabled={page === 0} onClick={() => setPage(page - 1)}>上一页</button><span>{page + 1} / {Math.ceil(filtered.length / PAGE_SIZE)}</span><button disabled={(page + 1) * PAGE_SIZE >= filtered.length} onClick={() => setPage(page + 1)}>下一页</button></nav>}
</section>
<footer className="site-footer"><span>Tabularium</span><a href="https://gitea.entroia.com/Zayden-Jung/endorphin-tabularium-docs">文档仓库 ↗</a></footer>
</main>
</div>
</Shell>
</>;
}
+61 -488
View File
@@ -189,7 +189,7 @@ button {
}
.nav-group > p {
font-weight: 600;
font-size: 11px;
font-size: 13px;
color: #363f39;
margin: 0 10px 10px;
}
@@ -220,348 +220,12 @@ button {
background: #c2a56e;
flex-shrink: 0;
}
.nav-note {
border-top: 1px solid var(--line);
margin: 45px 10px 0;
padding-top: 20px;
font-size: 10px;
color: #78847b;
line-height: 1.8;
}
.nav-note p {
color: #a0a8a1;
margin-top: 4px;
font-size: 10px;
}
.status-dot {
display: inline-block;
width: 5px;
height: 5px;
background: #378467;
border-radius: 50%;
margin-right: 7px;
vertical-align: middle;
}
.home-main {
min-width: 0;
padding: 0 48px;
}
.home-hero {
display: grid;
grid-template-columns: 1.3fr 1fr;
align-items: center;
gap: 28px;
padding: 66px 0 58px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
font-size: 10px;
font-weight: 500;
letter-spacing: 1.8px;
color: #849087;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
margin: 0;
}
.hero-copy h1 {
font-size: clamp(36px, 3.5vw, 52px);
line-height: 1.28;
letter-spacing: -2px;
font-weight: 550;
margin: 25px 0 19px;
}
.hero-description {
font-size: 15px;
line-height: 2;
color: #637066;
}
.hero-description strong {
font-weight: 500;
color: #4e5f54;
}
.hero-actions {
display: flex;
align-items: center;
gap: 26px;
margin-top: 26px;
}
.primary-button {
display: flex;
gap: 26px;
align-items: center;
color: white;
background: #2c7057;
border: 1px solid #24644d;
box-shadow: 0 2px 3px #204d5010;
border-radius: 6px;
padding: 10px 15px;
font-size: 12px;
}
.primary-button:hover {
background: #235c47;
}
.text-link {
color: #5b6d60;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 12px;
}
.text-link:hover {
color: var(--green);
}
.hero-footnote {
color: #a3aba4;
font-size: 9px;
margin-top: 34px;
letter-spacing: 0.6px;
}
.hero-footnote span {
border-left: 1px solid #dce3dc;
margin-left: 12px;
padding-left: 12px;
}
.archive-visual {
height: 300px;
position: relative;
background: radial-gradient(ellipse, #edf3eb 0, transparent 70%);
overflow: hidden;
}
.archive-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(#d8e1d530 1px, transparent 1px),
linear-gradient(90deg, #d8e1d530 1px, transparent 1px);
background-size: 24px 24px;
mask-image: radial-gradient(ellipse, black, transparent 75%);
}
.archive-caption {
position: absolute;
top: 1px;
left: 12px;
font:
8px ui-monospace,
monospace;
letter-spacing: 2px;
color: #9aab99;
}
.archive-sheet {
position: absolute;
width: 170px;
height: 205px;
left: calc(50% - 85px);
top: 52px;
border: 1px solid #d1dbcd;
border-radius: 5px;
background: #fff;
box-shadow: 0 12px 25px #6073550c;
padding: 20px;
}
.sheet-back {
transform: translate(35px, -16px) rotate(12deg);
background: #edf1e8;
color: #a7b19e;
}
.sheet-middle {
transform: translate(16px, -5px) rotate(5deg);
background: #f6f8f0;
color: #9ba890;
}
.archive-sheet > span:first-child {
font:
8px ui-monospace,
monospace;
letter-spacing: 1px;
}
.archive-sheet i {
display: block;
width: 80%;
height: 1px;
background: #d5decf;
margin: 15px 0;
}
.sheet-front {
transform: rotate(-7deg);
background: linear-gradient(140deg, #fffef8, #f4f6eb);
}
.sheet-label {
color: #839679;
}
.sheet-emblem {
font-size: 65px;
font-family: Georgia, serif;
color: #56764b;
line-height: 1.2;
margin: 12px 0;
}
.sheet-emblem span {
font:
28px ui-sans-serif,
system-ui;
vertical-align: top;
margin-left: 14px;
color: #9aaa8c;
}
.sheet-rule {
height: 1px;
background: #d9e0d0;
margin-bottom: 13px;
}
.archive-sheet b {
font:
12px Georgia,
serif;
color: #637759;
}
.archive-sheet small {
display: block;
color: #a1ad96;
font-size: 8px;
margin-top: 5px;
}
.archive-coordinate {
position: absolute;
bottom: 2px;
left: 15px;
right: 10px;
font-size: 8px;
color: #9aaa98;
letter-spacing: 1px;
display: flex;
justify-content: space-between;
}
.explore-section {
padding: 35px 0;
scroll-margin-top: 90px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 22px;
}
.section-heading h2,
.quickstart h2 {
margin: 10px 0 0;
font-size: 20px;
font-weight: 550;
letter-spacing: -0.6px;
}
.section-count {
font:
10px ui-monospace,
monospace;
color: #9ca59d;
}
.doc-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
}
.doc-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 21px;
display: flex;
flex-direction: column;
background: linear-gradient(#fff, #fcfdfb);
transition:
border-color 0.2s,
transform 0.2s;
}
.doc-card:hover {
border-color: #a5bdac;
transform: translateY(-3px);
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-icon {
width: 31px;
height: 31px;
display: grid;
place-items: center;
font-size: 18px;
background: #eef4ef;
border: 1px solid #e2eae1;
border-radius: 7px;
color: #587d5d;
}
.icon-1 {
color: #8b7e60;
background: #f6f3ec;
border-color: #eee8dd;
}
.icon-2 {
color: #6f8291;
background: #eff3f6;
border-color: #e5eaf0;
}
.card-number {
font:
9px ui-monospace,
monospace;
color: #b1b8b1;
}
.card-group {
font-size: 11px;
color: #73816f;
margin: 22px 0 6px;
}
.doc-card h3 {
font-size: 16px;
font-weight: 550;
letter-spacing: -0.3px;
margin: 0;
display: flex;
justify-content: space-between;
gap: 8px;
}
.doc-card h3 > span {
color: #a0aaa1;
font-size: 13px;
}
.card-description {
color: #69766b;
font-size: 13px;
line-height: 1.9;
margin: 10px 0 22px;
flex: 1;
}
.card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #a1aaa1;
border-top: 1px solid #eef1ed;
padding-top: 14px;
}
.draft-badge {
color: #9a7c40;
background: #faf6e9;
border: 1px solid #eee4c9;
border-radius: 4px;
padding: 2px 6px;
font-size: 9px;
white-space: nowrap;
}
.quickstart {
border-top: 1px solid var(--line);
padding: 35px 0 42px;
display: grid;
grid-template-columns: 1fr 1.2fr;
gap: 35px;
align-items: center;
}
.quickstart p:not(.eyebrow) {
font-size: 12px;
color: #6d796d;
line-height: 1.9;
margin: 13px 0 20px;
}
.eyebrow { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0; }
.draft-badge { color: #8c733e; background: #faf6e9; border: 1px solid #eee4c9; border-radius: 4px; padding: 2px 6px; font-size: 12px; white-space: nowrap; }
.site-footer {
border-top: 1px solid var(--line);
display: flex;
@@ -569,7 +233,7 @@ button {
gap: 15px;
padding: 24px 0;
color: #a1aaa1;
font-size: 9px;
font-size: 13px;
}
.site-footer strong {
font-weight: 500;
@@ -598,17 +262,6 @@ button {
.breadcrumb > span:nth-child(2) {
color: #c6cec7;
}
.article-meta {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.article-meta .eyebrow {
font-size: 10px;
color: var(--green);
letter-spacing: 1px;
}
.prose {
font-size: 16px;
line-height: 1.95;
@@ -779,7 +432,7 @@ button {
background: transparent;
color: #5b6f55;
font:
11px/2.2 ui-monospace,
13px/1.9 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
@@ -805,9 +458,6 @@ button {
color: #337c57;
font-weight: 600;
}
.quickstart .code-block {
margin: 0;
}
.toc {
padding: 47px 25px 30px 5px;
position: sticky;
@@ -817,8 +467,8 @@ button {
font-size: 13px;
}
.toc > .eyebrow {
letter-spacing: 1px;
font-size: 9px;
letter-spacing: 0;
font-size: 13px;
margin-bottom: 18px;
}
.toc nav {
@@ -840,19 +490,6 @@ button {
.toc nav a.toc-nested {
padding-left: 25px;
}
.toc-note {
font-size: 10px;
margin-top: 35px;
color: #83947f;
border-top: 1px solid var(--line);
padding-top: 20px;
line-height: 1.9;
}
.toc-note p {
font-size: 10px;
color: #a5aea3;
margin-top: 7px;
}
.article-end {
display: flex;
gap: 15px;
@@ -887,28 +524,13 @@ button {
margin-bottom: 7px;
}
.pagination strong {
font-size: 12px;
font-size: 14px;
font-weight: 500;
color: #526c55;
}
.next-page {
text-align: right;
}
.article-footer {
display: flex;
justify-content: space-between;
gap: 15px;
border-top: 1px solid var(--line);
padding: 24px 0;
color: #a7b0a5;
font:
8px ui-monospace,
monospace;
letter-spacing: 1px;
}
.article-footer span {
letter-spacing: 0;
}
.search-dialog {
padding: 0;
border: 1px solid var(--line);
@@ -1005,9 +627,6 @@ button {
.home-main {
padding: 0 70px;
}
.home-hero {
padding: 85px 0 70px;
}
.reading-layout {
grid-template-columns: 250px minmax(0, 1fr) 235px;
}
@@ -1031,23 +650,6 @@ button {
.reading-main {
padding: 30px 40px 0;
}
.home-hero {
gap: 10px;
}
.archive-sheet {
width: 145px;
height: 190px;
left: calc(50% - 72px);
}
.hero-copy h1 {
font-size: 38px;
}
.doc-card {
padding: 16px;
}
.card-description {
font-size: 13px;
}
.top-nav a:nth-child(2) {
display: none;
}
@@ -1121,19 +723,9 @@ button {
.mobile-navigation .nav-group {
margin-top: 15px;
}
.mobile-navigation .nav-note {
display: none;
}
.home-main {
padding: 0 30px;
}
.home-hero {
padding: 45px 0;
grid-template-columns: 1.2fr 1fr;
}
.hero-copy h1 {
font-size: 42px;
}
.reading-main {
max-width: 800px;
padding: 28px 35px 0;
@@ -1173,72 +765,6 @@ button {
.home-main {
padding: 0 22px;
}
.home-hero {
grid-template-columns: 1fr;
gap: 26px;
padding: 35px 0 30px;
}
.hero-copy h1 {
font-size: 42px;
letter-spacing: -1.5px;
margin: 23px 0 18px;
}
.hero-description {
font-size: 14px;
}
.hero-actions {
margin-top: 23px;
}
.hero-footnote {
margin-top: 24px;
}
.archive-visual {
height: 240px;
margin: 0 10px;
}
.archive-sheet {
top: 30px;
height: 180px;
}
.sheet-emblem {
font-size: 52px;
margin: 8px 0;
}
.archive-coordinate {
bottom: 0;
}
.section-heading h2,
.quickstart h2 {
font-size: 19px;
}
.doc-cards {
grid-template-columns: 1fr;
gap: 12px;
}
.doc-card {
padding: 20px;
}
.card-group {
margin-top: 14px;
}
.card-description {
font-size: 12px;
margin-bottom: 17px;
}
.doc-card h3 {
font-size: 17px;
}
.quickstart {
grid-template-columns: 1fr;
gap: 23px;
padding: 30px 0;
}
.quickstart p:not(.eyebrow) br {
display: none;
}
.quickstart p:not(.eyebrow) {
font-size: 12px;
}
.site-footer {
flex-wrap: wrap;
font-size: 9px;
@@ -1264,9 +790,6 @@ button {
.prose h2 {
font-size: 20px;
}
.article-meta {
margin-bottom: 14px;
}
.pagination {
gap: 10px;
}
@@ -1292,9 +815,6 @@ button {
padding: 13px;
font-size: 13px;
}
.article-footer {
font-size: 7px;
}
.eyebrow {
font-size: 9px;
}
@@ -1532,3 +1052,56 @@ button {
.logout-button:hover {
color: var(--green);
}
/* A bounded reading shelf and paged catalog keep the home stable as it grows. */
.home-main { max-width: 1120px; width: 100%; margin: 0 auto; }
.library-intro { padding: 64px 0 42px; }
.library-intro h1 { margin: 0 0 14px; font-size: 38px; font-weight: 600; letter-spacing: -1px; }
.library-intro p { margin: 0; font-size: 16px; color: var(--muted); }
.featured-section h2, .library-section h2 { font-size: 19px; font-weight: 550; margin: 0 0 22px; }
.featured-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.featured-link { position: relative; border: 1px solid var(--line); border-radius: 8px; padding: 26px; }
.featured-link:hover { border-color: #a4b7aa; background: #fafcfb; }
.featured-link > svg:first-child { color: var(--green); margin-bottom: 18px; }
.featured-link h3 { font-size: 18px; font-weight: 550; line-height: 1.6; margin: 0 0 10px; padding-right: 18px; }
.featured-link p { color: var(--muted); font-size: 14px; line-height: 1.8; margin: 0; }
.featured-link > svg:last-child { position: absolute; top: 28px; right: 24px; color: var(--muted); }
.library-section { padding: 46px 0 32px; }
.library-tools { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
.collection-filters { display: flex; gap: 6px; flex-wrap: wrap; }
.collection-filters button { font-size: 14px; padding: 8px 12px; border-radius: 5px; color: var(--muted); }
.collection-filters button[aria-pressed="true"] { background: #eef4f0; color: var(--green); }
.library-search { display: flex; align-items: center; gap: 8px; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 9px 12px; }
.library-search input { min-width: 0; width: 145px; outline: 0; font-size: 14px; }
.library-search:focus-within { border-color: var(--green); }
.library-list { padding: 0; margin: 0; list-style: none; border-top: 1px solid var(--line); }
.library-list li { border-bottom: 1px solid var(--line); }
.library-list a { display: flex; align-items: center; gap: 18px; padding: 20px 4px; }
.library-list a:hover { color: var(--green); background: #fafcfb; }
.library-row-title { flex: 1; font-size: 15px; line-height: 1.7; }
.library-row-title .draft-badge { margin-left: 10px; }
.library-row-group { font-size: 13px; color: var(--muted); }
.library-list svg { flex-shrink: 0; color: #87948c; }
.library-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 22px; margin: 22px 0 0; font-size: 14px; color: var(--muted); }
.library-pagination button { padding: 8px; }
.library-pagination button:disabled { opacity: .35; cursor: default; }
.library-empty { padding: 30px 0; color: var(--muted); }
.site-footer { font-size: 13px; color: var(--muted); }
.article-end { font-size: 13px; }
.search-trigger svg { flex-shrink: 0; }
@media (max-width: 560px) {
.library-intro { padding: 36px 0; }
.library-intro h1 { font-size: 32px; }
.featured-grid { grid-template-columns: 1fr; gap: 12px; }
.featured-link { padding: 22px; }
.featured-link h3 { font-size: 17px; }
.library-section { padding-top: 36px; }
.library-tools { align-items: stretch; }
.library-search { width: 100%; }
.library-search input { width: 100%; }
.library-list a { gap: 10px; padding: 16px 0; }
.library-row-group { display: none; }
.pagination strong { font-size: 13px; }
.site-footer { font-size: 13px; }
}
.search-status { padding: 12px 20px 0; font-size: 13px; color: var(--muted); }
+44
View File
@@ -0,0 +1,44 @@
{
"collections": [
{
"path": "pretext",
"title": "Pretext 源码教程",
"description": "从浏览器排版基础到缓存、换行算法与虚拟列表。"
},
{
"path": "",
"title": "Endor 文档",
"description": "系统设计、项目讨论与工作指南。"
}
],
"documents": {
"00-endor-concepts-draft": {
"group": "架构设计",
"description": "Endor 的全球入口、区域运行时与对象数据。",
"draft": true,
"featured": true
},
"01-getting-started": {
"group": "开始使用",
"description": "本地开发、文档编写与发布。"
},
"02-mounting-draft": {
"group": "架构设计",
"description": "节点、对象与统一接入协议。",
"draft": true
},
"03-figures": {
"group": "图形档案",
"description": "Python 源码与可编辑 SVG 的制图工作流。"
},
"04-endor-purpose-draft": {
"group": "项目讨论",
"description": "从程序入口走向用户与 AI 的对象操作。",
"draft": true
},
"pretext/00-overview": {
"description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。",
"featured": true
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# 从浏览器排版到 Pretext:高性能文本布局源码教程
这份教程回答一个问题:**Pretext 为什么能很快地算出一段文字的换行与高度?**
读完后,你应该能解释它的性能来源,沿着函数调用读懂核心源码,并知道如何在自己的界面中使用它。只需要了解 JavaScript 函数、数组和基本 DOM;字体、字素、回流等概念会逐步解释。
分析基于上游提交 [`0d102a5cd933611e86a7e60bda7b4baa5995c6b0`](https://github.com/chenglou/pretext/tree/0d102a5cd933611e86a7e60bda7b4baa5995c6b0),研究日期为 2026 年 10 月 28 日。本文描述这个版本,不把早期实现或未来版本的行为混在一起。源码链接指向本次研究的上游版本;实验脚本和记录随文保存在本站。
## 阅读路线
1. [第一章:为什么算文字高度也会慢](/docs/pretext/01-chapter)
2. [第二章:先看懂 prepare 与 layout 的分工](/docs/pretext/02-chapter)
3. [第三章:分析阶段如何把文字变成可布局的分段](/docs/pretext/03-chapter)
4. [第四章:测量缓存如何减少浏览器工作](/docs/pretext/04-chapter)
5. [第五章:换行算法为什么能变成数值循环](/docs/pretext/05-chapter)
6. [第六章:为什么只求高度比拿到每行文字便宜](/docs/pretext/06-chapter)
7. [第七章:如何避免长文本拖垮算法](/docs/pretext/07-chapter)
8. [第八章:准确性怎样影响性能设计](/docs/pretext/08-chapter)
9. [第九章:把它接进界面和虚拟列表](/docs/pretext/09-chapter)
10. [第十章:亲自验证性能机制](/docs/pretext/10-chapter)
11. [第十一章:按什么顺序读源码](/docs/pretext/11-chapter)
12. [第十二章:把这些设计迁移到自己的代码](/docs/pretext/12-chapter)
第一次阅读建议顺着读到第六章,再看第九章。第四、七、八章适合对照源码细读;第十章提供实验与复现方法。
+45
View File
@@ -0,0 +1,45 @@
# 第一章:为什么算文字高度也会慢
### 1.1 字符数不能直接决定高度
假设一段话放在宽 320px 的聊天气泡中。它会占几行,至少取决于文字内容、字体、字号、可用宽度、字间距和换行规则。
同样长度的 `iiii` 和 `WWWW` 宽度可能差很多。中文通常能在许多字之间断行,英文更常在空格处换行;标点又有自己的限制。所以“字符数乘平均字宽”只能估计,无法可靠地预测真正的行数。
传统方案是让浏览器先排一遍,再读取结果:
```js
element.textContent = text
element.style.width = '320px'
const height = element.getBoundingClientRect().height
```
写入文字或宽度后,之前的排版结果可能失效。此时立刻读取几何信息,浏览器必须先保证这个结果是最新的;如果有待处理的样式或布局工作,就可能同步完成这些工作。
这就是这里关心的“回流”或“布局”:确定元素的尺寸与位置。**一次几何读取不一定总触发昂贵回流,关键在于读取时是否存在需要更新的布局。**
### 1.2 写与读交替,会放大成本
```js
for (const row of rows) {
row.element.style.width = width + 'px'
heights.push(row.element.getBoundingClientRect().height)
}
```
这个循环可能不断要求浏览器“写一点、立即排好、读出来”。把写操作与读操作分批,通常能改善它。但虚拟列表还有另一个问题:大多数行根本没有挂载,无法直接读取那些行的 DOM 高度。
Pretext 的切入点是:**如果能在应用代码中算出文本行数,就能在创建那些 DOM 之前知道高度。** 浏览器最终仍然负责把字画出来。
### 1.3 区分布局和绘制
把一段文字显示到屏幕上,可以粗略拆成:
```text
文字与样式 → 确定字形和宽度 → 决定换行与位置 → 绘制文字
```
Pretext 借助 Canvas 的字体测量,自己完成一部分换行与布局计算。它不是一个自动让任意页面绘制都变快的开关,也不承担完整的浏览器 CSS 排版。
本章检查:如果宽度变化,文字本身的字体宽度一定要重新测量吗?通常不需要。变的是“每行能装多少”,这就是下一章的设计基础。
+61
View File
@@ -0,0 +1,61 @@
# 第二章:先看懂 prepare 与 layout 的分工
### 2.1 同一份输入,有不同的变化频率
聊天消息的文字和字体可能很久不变,但容器宽度会在拖动窗口时连续变化。把所有工作绑在一个函数里,每次 resize 都会重复做许多相同的事情。
Pretext 将它拆成两步:
```js
import { prepare, layout } from '@chenglou/pretext'
const prepared = prepare('Hello 世界,今天一起读源码。', '16px Arial')
const narrow = layout(prepared, 200, 24)
const wide = layout(prepared, 400, 24)
```
这里 `200`、`400` 是内容宽度,`24` 是调用者指定的每行高度,单位都是 CSS 像素。
| 阶段 | 输入 | 主要工作 | 什么时候重做 |
| --- | --- | --- | --- |
| `prepare()` | 文字、字体、选项及语言环境 | 分析断行机会、测量、记录修正数据 | 相关输入变化 |
| `layout()` | prepared、宽度、行高 | 根据已知宽度决定换行并计数 | 容器宽度或行高变化 |
prepared 可以理解为“已经整理好的排版材料”。它与容器宽度无关,但并不包含任意宽度下现成的布局答案。
### 2.2 沿源码走一遍
打开 [`src/layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts),定位 `prepareInternal()`:
```text
读取引擎规则与语言
→ analyzeText(...)
→ measureAnalysis(...)
→ prepared handle
```
再定位 `layout()`,其核心逻辑是:
```js
const lineCount = countPreparedLines(internal, normalizedWidth)
return { lineCount, height: lineCount * lineHeight }
```
这是省略类型与内部转换的源码摘义。它没有逐行调用 Canvas,也没有先创建 DOM 再读取高度。
### 2.3 一段文字被布局多次,省下多少工作
用 `P` 表示准备成本,用 `L` 表示一次布局成本,一共布局 `k` 次:
```text
复用 prepared:P + k × L
每次重新 prepare:k × (P + L)
```
两种写法相差 `(k - 1) × P`。这解释了为什么使用方式会直接决定收益:在 resize 回调中重新 prepare,会让原本可以省掉的分析工作再次发生。
另外,重复 prepare 可能命中测量缓存,但仍要分析文本、组装新的 handle。它依然比直接复用已有 handle 做得更多。
本章检查:`layout()` 是 O(1) 吗?不是。它通常还要扫描分段。快来自减少每一步要做的工作,而不是完全不看文本。
+54
View File
@@ -0,0 +1,54 @@
# 第三章:分析阶段如何把文字变成可布局的分段
### 3.1 三个容易混淆的单位
| 名称 | 含义 | 例子 |
| --- | --- | --- |
| UTF-16 码元 | JavaScript 字符串索引使用的存储单位 | 一些 emoji 的 `length` 大于 1 |
| 字素簇,简称字素 | 用户通常感知的一个文字单位 | 字母加组合音标、由多个码点组成的 emoji |
| 分段 segment | Pretext 为测量与换行建立的单位 | 一个词、一段文本、空格或特殊控制段 |
分段并不总等于单词。字素也不总等于一个 JavaScript 字符。理解这两个区别,后面的超长单词断行和 emoji 处理才容易读懂。
### 3.2 先找哪里允许断行
对 `hello world`,可以先想象成:
```text
['hello', ' ', 'world']
```
这个示意足以解释普通英文,但真实的中文标点、日文、泰文、软连字符和不可断空格需要更多规则。
[`analysis.ts` 的 `analyzeText()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts)先处理空白与源文本,再按引擎选择扫描器,最后形成分段及标志。其中主要的引擎对应关系是:
| 浏览器引擎 | 主要实现入口 |
| --- | --- |
| Blink,例如 Chrome | `line-breaks.ts` 中的 `getBlinkLineBreaks()` |
| WebKit,例如 Safari | `line-breaks.ts` 中的 `getWebKitLineBreaks()` |
| Gecko,例如 Firefox | `gecko-line-breaks.ts` 中的 `getGeckoLineBreaks()` |
选择由 `measurement.ts` 的 `getEngineProfile()` 完成,并缓存 profile。未知引擎采用 Blink 的模型。
### 3.3 表驱动扫描是什么意思
可以把扫描器想象成一个不断读取字符的机器:
```text
当前状态 + 当前字符类别 → 新状态 + 是否出现边界
```
字符属于哪一类、某种状态遇到某一类应该怎么走,都放在表里。运行时主要是读字符、查类别、查转换表、更新状态。
规则数据来自对应引擎及其 Unicode/ICU 数据,保存在 [`src/generated/`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/generated)。数据经过压缩;页面按需解开自己的引擎所用的表。这样能把复杂规则变成较直接的扫描,但也增加了包体积与初始化成本。这是有代价的工程选择。
[`graphemes.ts` 的 `findGraphemeEnds()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts)也采用类似方式寻找字素边界。这个版本不是对所有文字都调用 `Intl.Segmenter`:词典分词主要留给泰语、老挝语、高棉语、缅甸语等特定文字的运行片段。
### 3.4 为什么空白处理要先做
`white-space: normal` 下,连续普通空格通常被折叠;`pre-wrap` 则保留空格、换行与制表符的相关行为。若测量的是原始字符串,绘制时浏览器却折叠了它,预测自然会错。
所以分析阶段必须建立与目标 CSS 相符的文本视图。这个版本对孤立 `\r` 有明确限制:`pre-wrap` 下绘制前也应把它规范成 `\n`,详见根目录 README。
本章检查:为什么不能直接 `text.split(' ')`?因为断行机会远不止空格,而且许多语言没有用空格分隔每一个词。
+56
View File
@@ -0,0 +1,56 @@
# 第四章:测量缓存如何减少浏览器工作
### 4.1 Canvas 提供的是字体测量能力
浏览器已经知道如何选择字体、塑造字形、计算宽度。Pretext 通过:
```js
context.font = '16px Arial'
const width = context.measureText('hello').width
```
获得字体事实。它优先创建 `OffscreenCanvas`;没有该能力时,创建不挂载到文档的普通 Canvas。
`OffscreenCanvas` 这个名字不表示测量会自动进入另一个线程。代码在哪里调用,工作就在哪里执行;想在 worker 中使用,需要应用自己安排,还有第八章的准确性限制。
### 4.2 两层复用各解决什么问题
第一层是应用保留 prepared。第二层在库内部,以字体等测量条件组织缓存,再按分段文本查找:
```text
字体 A 的缓存
'hello' → 测量结果
'world' → 测量结果
字体 B 的缓存
'hello' → 另一份测量结果
```
在 [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts) 搜索 `getSegmentMetrics()`,会看到这一核心表达式:
```js
measurement.metrics.get(seg) ?? addMetrics(...)
```
命中就读取结果;未命中才调用 Canvas 并存入缓存。相同词语出现在不同消息里,也有机会复用,不要求整段消息完全相同。
实际缓存不只有一个数字。`SegmentMetrics` 还会保留拆词适配、emoji 统计或其他修正所需的信息。语言变化和字距造成的 shaping 模式变化也受到专门处理;不能把它简单描述成全局 `(text, font)` 永久不变的表。
### 4.3 命中缓存后,连字体设置都尽量省
`getFontMeasurement()` 先找到字体对应的数据;真正测量时才通过 `getContext()` 设置 Canvas 的字体和测量模式。
为什么把两者拆开?富文本可能反复查询许多 item 的字体,但这些 item 的文字全都命中缓存。既然无需测量,也就不必为了查询而触碰 Canvas。
源码同时照顾了字体后加载等浏览器行为,因此不是无条件地“字体字符串相同就永不赋值”。读这一段时应一起看注释,而不要只把它当成普通 setter 缓存。
### 4.4 prepared 与全局测量缓存的寿命不同
全局缓存为了复用测量,prepared 为了一段具体文本的后续布局。二者不应混为一谈。
`MAX_CACHED_SEGMENTS = 1 << 18`,即 262,144。所有字体缓存的分段数超过边界后,下一次准备开始时会清理测量缓存。它不是每次插入都逐出一个条目的 LRU,因此单次准备期间可能超过阈值。
已经创建的 handle 保留自己的布局数据,不会因为这次缓存清理突然失效。不过,如果实际字体或语言变了,旧 handle 也不会自动更新,仍需要重新准备。
本章检查:清理全局缓存会自动把旧 prepared 换成新字体宽度吗?不会。缓存清理和重新创建 handle 是两步。
+60
View File
@@ -0,0 +1,60 @@
# 第五章:换行算法为什么能变成数值循环
### 5.1 用一组虚构宽度手算
下面只是教学数据,不代表某种真实字体:
```text
分段:hello 空格 world 空格 again
宽度: 40 4 45 4 42
可用宽度:90
```
从左到右累计:
| 操作 | 当前宽度 | 决定 |
| --- | ---: | --- |
| 放入 hello | 40 | 留在第一行 |
| 放入空格 | 44 | 继续 |
| 放入 world | 89 | 继续 |
| 尝试后面的空格 | 93 | 第一行结束,行尾空格按规则处理 |
| 放入 again | 42 | 第二行 |
最终两行;若行高是 24px,高度就是 48px。
### 5.2 核心思路简单,实现处理边界
[`line-break.ts` 的 `countPreparedLines()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts)为普通文本专门实现数值循环。以下是教学版,不是可替代源码的完整算法:
```js
for (const segment of segments) {
if (currentWidth + segment.width <= availableWidth) {
currentWidth += segment.width
} else {
finishCurrentLine()
startNextLineWith(segment)
}
}
```
真实代码还处理行首空白、行尾修正、零宽空格、放不下一整个分段时的字素断行。关键是这些判断都基于提前准备的数据,不再重新问 Canvas 这个词有多宽。
### 5.3 为什么需要简单路径和完整路径
准备阶段设置两个相关标志:
- `simpleLineWalkFastPath`:适合简单逐行遍历。
- `simpleLineCountFastPath`:适合简单行数计算;某些边界仍可能回退处理。
普通文本、普通空格等可以走简单路径。保留空白、制表符和软连字符等内容需要更完整的规则,进入 `walkPreparedComplexLines()` 等路径。
源码在 `countPreparedLines()` 前的注释记录:完整 walker 每个分段的成本约为简单路径的 3~5 倍。**这是项目记录的局部测量依据,不是所有设备上的固定倍数。** 分开实现的目的,是让常见输入少支付复杂状态管理的成本。
### 5.4 高度计算为何不经过“每行对象”
仅求高度时,简单计数循环使用局部数值变量,直接累加行数,没有每行游标对象和逐行回调。`layout()` 最后仍会返回一个结果对象,所以准确说法是“避免每行分配”,而不是“零分配”。
这也是为什么一个看似更通用、更加抽象的 walker,未必适合最高频的行数路径。
本章检查:为什么文本支持更多规则,不一定让普通英文同样变慢?因为准备阶段能判断适用路径,复杂处理不必全部进入普通文本的热循环。
+52
View File
@@ -0,0 +1,52 @@
# 第六章:为什么只求高度比拿到每行文字便宜
### 6.1 计算结果越详细,需要的工作越多
假设一个段落有 50 行。高度只需要一个行数;返回每行文字则还要确定范围、切字符串、拼接内容并创建结果对象。这些临时对象最终还需要垃圾回收。
因此选择 API 时应先问:调用者实际要什么?
| 需求 | 准备方式 | 布局 API |
| --- | --- | --- |
| 高度、行数 | `prepare()` | `layout()` |
| 行数、最宽行 | `prepare()` | `measureLineStats()` |
| 每行范围与宽度 | `prepare()` | `walkLineRanges()` |
| 每行文字 | `prepareWithSegments()` | `layoutWithLines()` |
| 逐行取文字,可按需停止 | `prepareWithSegments()` | `layoutNextLine()` |
普通 `prepare()` 的最终 handle 不保留分段字符串;准备过程中仍会使用文字,不能因此说它完全没有字符串处理。
### 6.2 范围与文字分开生成
一行可以先表示为:
```text
从第几个 segment 的第几个字素开始
到第几个 segment 的第几个字素结束
这一行有多宽
```
这是 range。需要显示那一行时,再把 range 变成实际字符串,这一步叫 materialize,即生成具体内容。
`walkLineRanges()` 不生成行字符串,但公共回调接收的 range 及其游标仍然会分配对象。它比“每行完整文字”做得少,也不应被描述成零分配。
[`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts)还用 WeakMap 按 handle 缓存所需分段的字素结束位置。只有行边界切进一个分段内部、确实需要定位字素时,才会补上相关数据。
### 6.3 流式逐行适合什么情境
如果只显示前三行摘要,逐行 API 可以在取到所需行数时停止;如果每行可用宽度不同,也可以逐次传入不同宽度。
```js
let cursor
for (let i = 0; i < 3; i++) {
const line = layoutNextLine(preparedWithSegments, 320, cursor)
if (!line) break
console.log(line.text)
cursor = line.end
}
```
这个片段假设已经导入 `layoutNextLine` 并准备好 `preparedWithSegments`。它演示逐行读取,不包含省略号处理。高度用途直接选 `layout()` 会更合适。
本章检查:虚拟列表只想知道 10,000 条消息各有多高,是否需要为每条创建所有行字符串?不需要。
+38
View File
@@ -0,0 +1,38 @@
# 第七章:如何避免长文本拖垮算法
### 7.1 一次调用的成本也取决于字符串长度
假设为了知道单词的每个位置能否断开,依次测量:
```text
a
ab
abc
abcd
……
```
虽然只调用了 n 次 Canvas,总共提交的字符量却是:
```text
1 + 2 + … + n = n(n + 1) / 2
```
因此只数 API 调用次数还不够,也要数测量请求总共处理了多少文字。
### 7.2 96 个字素的边界
[`measurement.ts` 的 `getSegmentFit()`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts)按引擎与输入情况使用字素求和、相邻组合、前缀等测量模型。
前缀测量限定在 `MAX_PREFIX_FIT_GRAPHEMES = 96` 以内。超过这一规模,相关路径改用相邻字素组合,限制提交给 Canvas 的文字量随长度增长的放大程度。
这并不等于严格证明浏览器内部 shaping 总是线性的。它限制的是库自己构造测量请求所造成的成本;浏览器字体引擎还有自己的工作。
### 7.3 逐行继续时,不从头重扫
逐行 API 将上一行结束的游标传给下一行,遍历从那里继续。若每取下一行都从全文开头重新定位,行数多时会累积出大量重复工作。
[`RESEARCH.md` 的 Keeping Work Bounded](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md#keeping-work-bounded)记录了许多实际陷阱,包括增长字符串的反复处理、从每行起点重扫到文本结尾、重复搜索断行片段。阅读性能代码时,除了看一次循环的内容,也要问:这个循环会被重复启动多少次?
本章检查:每个前缀只测一次,能否保证总成本是 O(n)?不能,因为每次测量的输入长度在增长。
+41
View File
@@ -0,0 +1,41 @@
# 第八章:准确性怎样影响性能设计
### 8.1 字符宽度不总能独立相加
字偶距 kerning 会让相邻字形距离发生变化;连字可能把多个字母组成一个字形;阿拉伯文字母还会随上下文改变形态。
所以 `width('A') + width('V')` 未必等于 `width('AV')`。一个词整体测得的宽度,也未必直接告诉你从词中间截断后每一行的正确宽度。
Pretext 的策略是测量完整分段,同时提前记录紧急断词所需的 advances,以及行首、行尾的修正信息。复杂性主要支付在准备阶段,布局阶段读取结果。
### 8.2 相同 Unicode 文本,各浏览器也可能不同
浏览器引擎在空白、标点、行边界与拆词等方面存在差异。Pretext 的 engine profile 选择对应扫描规则、拟合模式与容差,而不是仅靠一套理想化 Unicode 换行实现预测所有浏览器。
这份模型有已知缺口,尤其在特殊字体设置、窄到不常见的内容宽度、某些复杂文字与平台 bug 上。它的目标是匹配支持范围内的浏览器行为;“算法能给出一个结果”并不能证明所有浏览器都一定这样画。
### 8.3 “无 DOM”有需要说明的例外
`layout()` 本身不做 DOM 测量。准备阶段则可能:
- 读取 `<html lang>`,除非调用者通过 `setLocale()` 给出了语言。
- 在需要修正 emoji 宽度时,按字体使用 DOM span 探测。
- 缺少 OffscreenCanvas 时,创建不挂载的 Canvas 元素。
因此应理解成它避免了“每个文本块依赖 DOM 测量”的架构,而不是声称整个库绝对不会访问 DOM。worker 中缺少 emoji 探测条件,准确性也可能不同。
### 8.4 重新准备的条件
| 变化 | 通常怎样处理 |
| --- | --- |
| 只有宽度变化 | 复用 handle,重新 `layout()` |
| 只有调用者指定行高变化 | 复用 handle,传入新行高 |
| 文字、字体、字号、字距、whiteSpace、wordBreak 变化 | 重新 `prepare()` |
| 页面语言变化 | 重新准备;旧 handle 保留旧规则与宽度 |
| 先前测过后,目标字体才加载完成 | 清理缓存并重新准备,避免继续用回退字体数据 |
| macOS emoji 相关 DPR 变化 | 按 README 要求清理并重新准备含 emoji 文本 |
最终行为受字体、语言和 CSS 的共同约束。根目录 [README 的 Caveats](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/README.md#caveats)、[PLATFORM_BUGS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/PLATFORM_BUGS.md)和 [ENGINE_FOLLOWUPS.md](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/ENGINE_FOLLOWUPS.md)是理解边界的重要入口。
本章检查:`height = lineCount × lineHeight` 是否能概括带任意大小图片的富文本?不能,图片或不同字体可能把某一行撑高,需要调用者处理行高模型。
+94
View File
@@ -0,0 +1,94 @@
# 第九章:把它接进界面和虚拟列表
### 9.1 一个完整的浏览器模块例子
先在你的前端项目中安装:
```sh
npm install @chenglou/pretext
```
下面是供 Vite 等支持 npm 模块导入的环境使用的 JavaScript 模块。直接打开一个 HTML 文件并不能解析这个包名。
```js
import { prepare, layout } from '@chenglou/pretext'
const text = 'Hello 世界,今天一起读源码。'.repeat(8)
const font = '16px "Helvetica Neue", "PingFang SC", Arial, sans-serif'
const lineHeight = 24
document.documentElement.lang = 'zh-CN'
await document.fonts.load(font, text)
const prepared = prepare(text, font)
const block = document.createElement('div')
block.textContent = text
Object.assign(block.style, {
font,
lineHeight: lineHeight + 'px',
overflowWrap: 'break-word',
wordBreak: 'normal',
whiteSpace: 'normal',
lineBreak: 'auto',
letterSpacing: '0px',
wordSpacing: '0px',
fontKerning: 'auto',
textWrap: 'wrap',
hyphens: 'manual',
textRendering: 'auto',
boxSizing: 'content-box',
padding: '0',
border: '0',
})
document.body.appendChild(block)
function setContentWidth(width) {
const predicted = layout(prepared, width, lineHeight)
block.style.width = width + 'px'
return predicted
}
const predicted = setContentWidth(320)
console.log('预测高度:', predicted.height)
console.log('实际高度:', block.getBoundingClientRect().height)
// 模拟容器变宽:复用相同的 prepared。
setContentWidth(480)
```
这个例子最后的 DOM 高度读取是开发期核对。生产中如果每次布局都再读一次 DOM,仍会承担那次读取可能触发的工作。示例也假设没有其他 CSS 规则覆盖这些值,所用字体在目标设备上可用;跨平台项目应配置自己的字体。
### 9.2 让模型与显示使用相同输入
`layout(prepared, 320, 24)` 中的 320 是内容宽度。如果外框总宽 320px、左右各有 12px padding,再加边框,真正给文字使用的宽度就小于 320px。
可以由应用维护一个尺寸模型:先算出内容宽度,把同一个数同时传给 Pretext 和渲染层。这样可以减少“CSS 用一个值,JS 猜另一个值”的误差。
准备前等待具体文字所需字体加载很重要。只有 `document.fonts.ready` 不一定覆盖尚未使用、由 unicode-range 分片加载的字体;因此例子把 text 也传给 `document.fonts.load()`。
### 9.3 虚拟列表从哪里获得收益
假设有 10,000 条消息,屏幕上只显示 20 条:
```text
每条消息保留 prepared
→ 按当前内容宽度求各条高度
→ 累计高度,确定每条消息顶部的位置
→ 按滚动位置找可见范围
→ 只挂载可见消息与少量缓冲区
```
Pretext 解决的是“没挂载的文字怎样求高度”。累计位置、查找可见范围、滚动锚定、图片高度、选择与无障碍行为,仍由应用或虚拟列表库负责。
窗口变宽时,每条消息通常仍需要重新布局。因此大量消息的整表 resize 成本不会凭空消失。准备与布局分离,让这次更新的单位成本更低。
### 9.4 编辑与富文本如何处理
长文本编辑时,可以按段落保留 prepared,只重新准备被编辑影响的段落;有跨段结构变化时,应用还要更新相应块。
混合字体、提及标签和行内图片使用 `@chenglou/pretext/rich-inline`。这个版本把富文本作为一个段落分析,保留 item 与分段的映射,然后复用布局基础设施,而不是把每个 span 完全独立换行。
富文本还要考虑垂直尺寸:字体大小、上升部与下降部、行内盒子高度都可能影响真实行高。不要直接把所有富文本的行数乘一个固定值,当成任意 CSS 的精确高度。可继续读项目自己的 [Markdown 聊天案例说明](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/pages/demos/markdown-chat.md)。
本章检查:Pretext 是否已经替你完成虚拟列表?没有,但它提供了此前很难在挂载前获得的文本几何信息。
+75
View File
@@ -0,0 +1,75 @@
# 第十章:亲自验证性能机制
### 10.1 先把问题变成可观察的证据
本教程关心四件事:
1. 首次准备是否调用 Canvas?
2. 相同文本再次准备是否复用测量?
3. 改变宽度做布局时是否继续调用 Canvas?
4. 这些样本的预测高度是否与 DOM 相符?
下载脚本后,将它放进研究版本的 Pretext 仓库 `tutorials/zh-CN/verify.cjs`(按需新建目录),因为它以该位置查找 `../../src`。
随文的 [`verify.cjs`](/materials/pretext/verify.cjs)用真实 Chromium Canvas,在 `measureText()` 外包一层计数器,同时统计提交字符串的 UTF-16 码元总数。然后测试英文、中文、保留空白的文本。
实验不使用伪造的平均字宽。源码通过 Node 的 TypeScript 转换功能转换为浏览器模块;没有修改库实现。
### 10.2 第一次研究保存的结果
原始记录在 [`results.chrome156.json`](/materials/pretext/results.chrome156.json)。环境为本机 macOS、Headless Chrome 156、DPR 1,设置 `<html lang="en">`,使用记录脚本中的命名字体及其回退链。三段文本都重复了 12 次,刻意让缓存效果可观察。
| 样本 | 字符串 length | 首次 Canvas 次数 | 首次提交码元数 | 再次准备 Canvas 次数 | 变宽布局 Canvas 次数 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 英文 | 1,428 | 62 | 529 | 0 | 0 |
| 中文 | 408 | 53 | 67 | 0 | 0 |
| 保留空白、tab、软连字符 | 888 | 50 | 497 | 0 | 0 |
每段分别在 80、121、200、319、320、321、480、800px 下求高度,24 组结果与 DOM 高度一致。这只能证明这些样本的高度一致,不能证明所有语言与断行位置都完全一致。
### 10.3 怎样理解计时数据
预先准备后,每轮做 3,000 次宽度切换,热身后取 5 轮中位数。原始记录为:
| 样本 | Pretext 布局总时间 | DOM 改宽后立即测高总时间 |
| --- | ---: | ---: |
| 英文 | 1.7ms | 86.0ms |
| 中文 | 1.5ms | 57.8ms |
| 保留空白等 | 6.3ms | 93.9ms |
这些数字展示“用已测宽度计算”与“改变 DOM 宽度后同步求几何信息”的成本差异。它们不包含 Pretext 首次准备成本,也不包含完整页面绘制。
这不是严谨的产品性能排名:样本很少且重复,固定先测 Pretext 再测 DOM,没有记录硬件与电源条件,短样本受计时分辨率影响,也没有与批量 DOM 测量对照。结果不应被宣传成“任意页面快几十倍”。更可靠的机制证据是调用次数和这组样本的结果核对。
### 10.4 复现方法
要求 Node 23.10+ 或支持 `module.stripTypeScriptTypes(..., { mode: 'transform' })` 的较新 Node、Playwright,以及可用 Chromium。
为避免改动本仓库的依赖,在单独目录安装实验依赖:
```sh
mkdir -p /tmp/pretext-study-deps
npm install --prefix /tmp/pretext-study-deps playwright
/tmp/pretext-study-deps/node_modules/.bin/playwright install chromium
```
在本仓库根目录运行:
```sh
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
node tutorials/zh-CN/verify.cjs
```
默认打印 JSON,不覆盖已归档结果。若要保存本次输出:
```sh
PLAYWRIGHT_MODULE=/tmp/pretext-study-deps/node_modules/playwright \
node tutorials/zh-CN/verify.cjs --output /tmp/pretext-study-result.json
```
也可以通过 `CHROME_EXECUTABLE` 指定已安装 Chrome 的可执行文件路径;两种方式选一种即可。脚本只监听本机回环地址,结束后关闭服务器与浏览器。
脚本会检查 24 组高度、重复准备和布局的 Canvas 调用次数,以及行文本 API 与行数 API 的行数一致性。失败时返回非零状态。它不设置速度阈值,因为这种微型计时不适合承担性能回归门禁。
项目正式验证还要看 [`harness/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/README.md) 与 [`harness/bench/README.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/harness/bench/README.md):区分新文本、已见文本、resize、生成行文本与最坏情况;同时处理浏览器自身缓存、JIT、GC 和跨轮噪声。本教程没有运行完整的固定浏览器 harness,也没有把 headless 结果当成其正式准确性基线。
+21
View File
@@ -0,0 +1,21 @@
# 第十一章:按什么顺序读源码
不要从庞大的规则数据开始。先建立主流程,再读会影响这个流程的细节。
| 顺序 | 文件与函数 | 阅读时要回答的问题 |
| --- | --- | --- |
| 1 | [`layout.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/layout.ts):`prepareInternal`、`prepare`、`layout` | 两个阶段分别接收什么、返回什么? |
| 2 | [`line-break.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-break.ts):`PreparedLineBreakData` | 布局真正需要哪些提前准备的数据? |
| 3 | 同文件:`countPreparedLines` | 普通文本怎样被一个循环计数? |
| 4 | [`analysis.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/analysis.ts):`analyzeText` | 空白、语言与引擎怎样影响分段? |
| 5 | [`prepare.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/prepare.ts):`measureAnalysis` | 宽度、适配数据与快路径标志在哪里建立? |
| 6 | [`measurement.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/measurement.ts):`getFontMeasurement`、`getSegmentMetrics`、`getContext` | 命中缓存时到底跳过哪些浏览器工作? |
| 7 | 同文件:`getSegmentFit`、`measureFitAdvances` | 一个放不下的词怎样预先准备拆分信息? |
| 8 | [`line-text.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-text.ts) 与 `layoutWithLines` | 哪些成本只在要文字时出现? |
| 9 | `stepPreparedSimpleLineGeometry`、`walkPreparedComplexLines` | 按行输出和复杂规则怎样衔接? |
| 10 | [`line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/line-breaks.ts)、[`gecko-line-breaks.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/gecko-line-breaks.ts)、[`graphemes.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/graphemes.ts) | 扫描器怎样使用表和状态? |
| 11 | [`rich-inline.ts`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/src/rich-inline.ts) | item 与整段文本的映射怎样建立? |
| 12 | [`RESEARCH.md`](https://github.com/chenglou/pretext/blob/0d102a5cd933611e86a7e60bda7b4baa5995c6b0/RESEARCH.md) 的 Keeping Work Bounded、Evaluation Traps 与 Decisions Log | 哪些看似更简单的实现被测过,又为何没保留? |
做一个小练习:对 `hello world` 写下你认为需要的 prepared 数据,然后依次改变宽度、字体和 whiteSpace,判断哪些数据能保留。能解释这些变化,就已经掌握主设计,不需要先记住所有 Unicode 规则。
+27
View File
@@ -0,0 +1,27 @@
# 第十二章:把这些设计迁移到自己的代码
### 12.1 先按变化频率拆工作
找出输入中很少变的部分与高频变化的部分。这里文字和字体较稳定,宽度频繁变化,于是准备与布局分离。类似思路也适用于图表几何、搜索索引和格式化数据。
预计算需要内存,也会增加第一次处理的成本。只有被复用、能减少更贵的工作时,它才值得。
### 12.2 让 API 允许调用者少做事
如果只要计数,就提供计数路径;只要范围,就不强迫构造字符串;只要可见几行,就允许提前停止。
这比在一个总会生成全部结果的 API 内微调循环,通常更能减少实际工作。
### 12.3 给常见输入一条清晰的快路径
先通过准备阶段建立可验证的前提,再让热循环使用这些前提。完整路径仍负责其余情况。快路径的正确性应来自明确条件,而不是凭输入“看起来简单”猜测。
### 12.4 衡量总工作量
除了总耗时,还观察测量次数、提交字符量、扫描范围、对象分配和重复启动次数。它们能解释为什么快,也能暴露短样本看不到的二次增长。
### 12.5 把结论说到证据能支持的位置
这次源码研究与实验支持的是:Pretext 用准备阶段的测量和规则数据,使后续宽度变化主要成为数值布局;缓存、专用计数循环和按需生成结果进一步减少工作。完整页面是否获益、获益多少,还取决于首次准备量、复用频率、字体与 CSS 是否匹配,以及应用剩余的 DOM 和绘制成本。
读完后可以用三个问题检验理解:哪些工作被移到准备阶段?哪些结果被跨调用复用?调用者只要高度时,哪些工作根本没有发生?把这三个问题顺着源码回答出来,就能解释 Pretext 的主要性能来源。
+1 -1
View File
@@ -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"):
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"):
status, headers, _ = get(path)
assert status == 307, path
target = urllib.parse.urlparse(urllib.parse.urljoin(base, headers["Location"]))