diff --git a/README.md b/README.md index 527d161..06162f4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pnpm dev ```text apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS -content/docs/ Markdown 文档源,文件名成为 /docs/ 路由 +content/docs/ Markdown 文档源,支持目录,路径成为 /docs/ 路由 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/.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/.svg`。不解析原始 HTML。 生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。 main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署。工作流也支持手动触发。 diff --git a/apps/docs/eslint.config.mjs b/apps/docs/eslint.config.mjs index e5d7512..3ca2f51 100644 --- a/apps/docs/eslint.config.mjs +++ b/apps/docs/eslint.config.mjs @@ -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/**"]), ]); diff --git a/apps/docs/public/materials/pretext/results.chrome156.json b/apps/docs/public/materials/pretext/results.chrome156.json new file mode 100644 index 0000000..3d3969f --- /dev/null +++ b/apps/docs/public/materials/pretext/results.chrome156.json @@ -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 + } + ] +} diff --git a/apps/docs/public/materials/pretext/verify.cjs b/apps/docs/public/materials/pretext/verify.cjs new file mode 100644 index 0000000..7c54cd8 --- /dev/null +++ b/apps/docs/public/materials/pretext/verify.cjs @@ -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==='/'?'':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}); diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 719d830..c4c704b 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -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("&", "&"), @@ -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 }); diff --git a/apps/docs/src/components/Icon.tsx b/apps/docs/src/components/Icon.tsx new file mode 100644 index 0000000..ad6424c --- /dev/null +++ b/apps/docs/src/components/Icon.tsx @@ -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: <>, + arrow: , + book: <>, + code: , + menu: , + close: , + }; + return ; +} diff --git a/apps/docs/src/components/Shell.tsx b/apps/docs/src/components/Shell.tsx index 24e593d..08f416f 100644 --- a/apps/docs/src/components/Shell.tsx +++ b/apps/docs/src/components/Shell.tsx @@ -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 ( ); } @@ -79,18 +76,32 @@ export function Shell({ const input = useRef(null); const trigger = useRef(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({