ソースを参照

Merge pull request #3117 from deepseek-harness/worktree/3116-docs-mpa-idempotence

fix(docs): make site builds idempotent
Yichen Jiang 1 ヶ月 前
親
コミット
1d00f5b4c0

+ 2 - 2
.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: 5b45657d13d02bc7211e47cad2143afd6890d4e2
-2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4
+2026-08-20-doc-site-raw-markdown-twins.md: 7f557d1327ce4d981e37c1281a8d58526303f815
+2026-08-20-doc-site-raw-markdown-twins.zh.md: 0fd7e90cf7119b49576ea68e89e7f73ccecfe4a5

+ 3 - 1
.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md

@@ -14,7 +14,9 @@ The documentation site serves rendered HTML only, so an agent reading the docs h
 
 One projection serves both trees because its site-internal links are relative. `./sibling.md` renders as a clean URL on the HTML site and resolves file-to-file in the raw tree, so the twins need no second link-rewriting mode. Every route is emitted, including the frontmatter-only locale homes, because published pages link to them and the raw tree must stay link-closed; a spec walks every emitted relative link to pin that closure.
 
-An index route renders as a directory URL, so "append `.md`" lands on `<dir>.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`. A twin or image may never overwrite a file the build already carries, such as a `public/` copy; a name collision fails the emission.
+An index route renders as a directory URL, so "append `.md`" lands on `<dir>.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 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](<base><route>): <section>` row per page, links site-absolute under the deploy-time `DOCS_BASE`. Locale homes stay out — the file itself is the agent entry point.
 

+ 3 - 1
.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md

@@ -14,7 +14,9 @@ Status: implemented
 
 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。
 
-index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。
+index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `<dir>.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。
+
+每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并要求它最近的现有父目录必须解析到站点根目录的真实路径以内。构建会直接解除链接型输出而不遍历其目标,并在打包前删除真实输出目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。
 
 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label](<base><route>): <section>`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。
 

+ 2 - 2
package.json

@@ -94,8 +94,8 @@
     "gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
     "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
     "docs:dev": "pnpm --filter @deepseek-ai/website run dev",
-    "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments",
-    "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments",
+    "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments",
+    "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments",
     "docs:preview": "pnpm --filter @deepseek-ai/website run preview",
     "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build",
     "website:dev": "pnpm run docs:dev",

+ 74 - 1
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'
@@ -9,6 +9,7 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
 import type { Nodes } from 'mdast'
 import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { cleanDocSiteOutput, docSiteBuildOptions } from '../website/build.ts'
 import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
 import {
   addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage,
@@ -70,6 +71,78 @@ describe('website source layout', () => {
   })
 })
 
+describe('documentation site build', () => {
+  it.each([
+    { mode: 'SPA', mpa: false, expectedMpa: undefined },
+    { mode: 'MPA', mpa: true, expectedMpa: 'true' },
+  ])('$mode build removes stale output before writing', async ({ mpa, expectedMpa }) => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-'))
+    roots.push(root)
+    const outDir = join(root, '.dist')
+    const stale = join(outDir, 'stale.md')
+    mkdirSync(outDir)
+    writeFileSync(stale, 'stale\n')
+
+    const options = docSiteBuildOptions(root, mpa)
+    expect(options.mpa).toBe(expectedMpa)
+    expect(existsSync(stale)).toBe(true)
+    await options.onAfterConfigResolve?.({ outDir } as never)
+    expect(existsSync(outDir)).toBe(false)
+  })
+
+  it('refuses to remove the site root or an outside directory', () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-doc-build-root-'))
+    const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-build-outside-'))
+    roots.push(root, outside)
+    writeFileSync(join(root, 'keep'), 'root\n')
+    writeFileSync(join(outside, 'keep'), 'outside\n')
+
+    expect(() => {
+      cleanDocSiteOutput(root, root)
+    }).toThrow('must be a child of site root')
+    expect(() => {
+      cleanDocSiteOutput(root, outside)
+    }).toThrow('must be a child of site root')
+    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', () => {
   it('accepts a regular file inside the repository', () => {
     const { root } = fixture()

+ 2 - 0
website/AGENTS.md

@@ -10,6 +10,8 @@ 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. 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.
 
 Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`.

+ 86 - 0
website/build.ts

@@ -0,0 +1,86 @@
+/** Production documentation-site build with project-owned output preparation. */
+
+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<Parameters<typeof build>[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 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` 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 === '' || 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 })
+}
+
+/**
+ * Create VitePress build options that remove the resolved output directory before bundling.
+ * @param siteRoot - VitePress site root to build.
+ * @param mpa - Whether to use VitePress's multi-page application build.
+ * @returns VitePress options with project-owned output preparation.
+ */
+export function docSiteBuildOptions(siteRoot: string, mpa: boolean): DocSiteBuildOptions {
+  const root = resolve(siteRoot)
+  return {
+    ...mpa ? { mpa: 'true' } : {},
+    onAfterConfigResolve(siteConfig) {
+      cleanDocSiteOutput(root, siteConfig.outDir)
+    },
+  }
+}
+
+async function buildDocSite(siteRoot: string, mpa: boolean): Promise<void> {
+  const root = resolve(siteRoot)
+  await build(root, docSiteBuildOptions(root, mpa))
+}
+
+function parseMpa(args: string[]): boolean {
+  if (args.length === 0) return false
+  if (args.length === 1 && args[0] === '--mpa') return true
+  throw new Error(`website/build: expected no arguments or --mpa, got ${JSON.stringify(args)}.`)
+}
+
+const invokedPath = process.argv[1]
+if (invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href) {
+  await buildDocSite(websiteRoot, parseMpa(process.argv.slice(2)))
+}

+ 1 - 1
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": {