diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml index 2b2e377275..aa50432804 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md -2026-08-20-doc-site-raw-markdown-twins.md: 85f6cc957d5f3a1675a38abc4675e76448ffb5f5 -2026-08-20-doc-site-raw-markdown-twins.zh.md: df6f0780223dc10fc9b5832f97a360e08e68b46b +2026-08-20-doc-site-raw-markdown-twins.md: 7f557d1327ce4d981e37c1281a8d58526303f815 +2026-08-20-doc-site-raw-markdown-twins.zh.md: 0fd7e90cf7119b49576ea68e89e7f73ccecfe4a5 diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md index 85f6cc957d..7f557d1327 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -16,7 +16,7 @@ One projection serves both trees because its site-internal links are relative. ` An index route renders as a directory URL, so "append `.md`" lands on `.md` once the trailing slash is dropped; each index route therefore also emits a parent-level alias twin at that path. The alias is not a copy — a copied `index.md` would carry its relative links one directory too high — but its own projection over the alias route, resolved against the canonical manifest so links keep targeting canonical twins. The root home has no parent to alias into; `/` is documented as `/index.md`. -Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and removes that directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission. +Each production entry point resolves the VitePress configuration, requires `outDir` to be a proper child of the site root, and requires its nearest existing parent to resolve inside the real site root. It unlinks a link-shaped output without traversing its target and removes a real output directory before bundling. This project-owned preparation covers MPA builds, which do not empty their final output directory, and prevents removed routes or assets from surviving a rebuild. The later raw-twin pass treats files created by the current VitePress build as occupied: a twin or image may never overwrite one, such as a `public/` copy, and a name collision fails the emission. `llms.txt` is generated from the publication manifest at the site root: both locale trees in sidebar order, one `- [label]():
` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point. diff --git a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md index df6f078022..0fd7e90cf7 100644 --- a/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -16,7 +16,7 @@ Status: implemented index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。 -每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并在打包前删除该目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 +每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并要求它最近的现有父目录必须解析到站点根目录的真实路径以内。构建会直接解除链接型输出而不遍历其目标,并在打包前删除真实输出目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 5d58509fab..f7062c2659 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -1,7 +1,7 @@ /** Tests for the documentation website projection adapter. */ import { execFileSync } from 'node:child_process' -import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' @@ -80,7 +80,6 @@ describe('documentation site build', () => { roots.push(root) const outDir = join(root, '.dist') const stale = join(outDir, 'stale.md') - const fresh = join(outDir, 'index.html') mkdirSync(outDir) writeFileSync(stale, 'stale\n') @@ -89,10 +88,6 @@ describe('documentation site build', () => { expect(existsSync(stale)).toBe(true) await options.onAfterConfigResolve?.({ outDir } as never) expect(existsSync(outDir)).toBe(false) - mkdirSync(outDir) - writeFileSync(fresh, 'fresh\n') - - expect(readFileSync(fresh, 'utf8')).toBe('fresh\n') }) it('refuses to remove the site root or an outside directory', () => { @@ -111,6 +106,41 @@ describe('documentation site build', () => { expect(readFileSync(join(root, 'keep'), 'utf8')).toBe('root\n') expect(readFileSync(join(outside, 'keep'), 'utf8')).toBe('outside\n') }) + + it('unlinks a link-shaped output without removing its target', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-root-')) + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-link-target-')) + roots.push(root, outside) + const outDir = join(root, '.dist') + const keep = join(outside, 'keep') + writeFileSync(keep, 'outside\n') + symlinkSync(outside, outDir, 'junction') + + cleanDocSiteOutput(root, outDir) + + expect(existsSync(outDir)).toBe(false) + expect(readFileSync(keep, 'utf8')).toBe('outside\n') + }) + + it('refuses output whose nearest existing parent resolves outside the site root', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-root-')) + const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-parent-link-target-')) + roots.push(root, outside) + const linkedParent = join(root, 'linked') + const outDir = join(linkedParent, 'missing', '.dist') + const keep = join(outside, 'keep') + writeFileSync(keep, 'outside\n') + symlinkSync(outside, linkedParent, 'junction') + + try { + expect(() => { + cleanDocSiteOutput(root, outDir) + }).toThrow('must resolve inside site root') + expect(readFileSync(keep, 'utf8')).toBe('outside\n') + } finally { + unlinkSync(linkedParent) + } + }) }) describe('publishableImage', () => { diff --git a/website/AGENTS.md b/website/AGENTS.md index 65ca9d28e0..fce0b95e93 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -10,7 +10,7 @@ Keep canonical prose and generated catalogs in their owning `docs/` tier, then e The projector writes disposable Markdown to the ignored `website/.generated/` directory. Never edit or commit `.generated/`, `.cache/`, or `.dist/`. -Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them. +Production builds remove the configured output directory after VitePress resolves the site configuration and before it writes files. They reject output whose lexical path or nearest existing parent escapes the real site root, and unlink a link-shaped output instead of traversing its target. Raw-Markdown emission then treats files produced by that build as occupied and never overwrites them. The build also emits each route's raw-Markdown twin (with a parent-level alias per index route) and a root `llms.txt` index into `.dist/`, so a page's URL, minus any trailing slash, plus `.md` serves it as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree. diff --git a/website/build.ts b/website/build.ts index dad6e4a5cd..264fbb4c15 100644 --- a/website/build.ts +++ b/website/build.ts @@ -1,25 +1,54 @@ /** Production documentation-site build with project-owned output preparation. */ -import { rmSync } from 'node:fs' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { lstatSync, realpathSync, rmSync, unlinkSync } from 'node:fs' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' import { build } from 'vitepress' const websiteRoot = resolve(import.meta.dirname) type DocSiteBuildOptions = NonNullable[1]> +function escapesRoot(root: string, candidate: string): boolean { + const child = relative(root, candidate) + return child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child) +} + +function nearestExistingAncestor(path: string): string { + let ancestor = path + for (;;) { + if (lstatSync(ancestor, { throwIfNoEntry: false }) !== undefined) return ancestor + const parent = dirname(ancestor) + if (parent === ancestor) { + throw new Error(`website/build: no existing ancestor found for ${JSON.stringify(path)}.`) + } + ancestor = parent + } +} + /** - * Remove one documentation build output without permitting the site root or an outside path. + * Remove one documentation build output without traversing a link-shaped output or an outside parent. * @param siteRoot - VitePress site root that owns the output. * @param outDir - Resolved VitePress output directory. - * @throws When `outDir` is not a proper child of `siteRoot`. + * @throws When `outDir` is not a proper child of `siteRoot` or its existing parent resolves outside it. */ export function cleanDocSiteOutput(siteRoot: string, outDir: string): void { const root = resolve(siteRoot) const output = resolve(outDir) const child = relative(root, output) - if (child === '' || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) { - throw new Error(`build-doc-site: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`) + if (child === '' || escapesRoot(root, output)) { + throw new Error(`website/build: output directory ${JSON.stringify(output)} must be a child of site root ${JSON.stringify(root)}.`) + } + + const realRoot = realpathSync(root) + const realParent = realpathSync(nearestExistingAncestor(dirname(output))) + if (escapesRoot(realRoot, realParent)) { + throw new Error(`website/build: output directory ${JSON.stringify(output)} must resolve inside site root ${JSON.stringify(realRoot)}.`) + } + + const outputStats = lstatSync(output, { throwIfNoEntry: false }) + if (outputStats?.isSymbolicLink()) { + unlinkSync(output) + return } rmSync(output, { recursive: true, force: true }) } @@ -48,7 +77,7 @@ async function buildDocSite(siteRoot: string, mpa: boolean): Promise { function parseMpa(args: string[]): boolean { if (args.length === 0) return false if (args.length === 1 && args[0] === '--mpa') return true - throw new Error(`build-doc-site: expected no arguments or --mpa, got ${JSON.stringify(args)}.`) + throw new Error(`website/build: expected no arguments or --mpa, got ${JSON.stringify(args)}.`) } const invokedPath = process.argv[1] diff --git a/website/package.json b/website/package.json index a2279418bb..3040893a55 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vitepress dev . --host 127.0.0.1 --port 5173", - "build": "vitepress build .", + "build": "tsx build.ts", "preview": "vitepress preview . --host 127.0.0.1 --port 4173" }, "devDependencies": {