From 327536548942da0b8520d814de22185a0db9fe6a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 26 Aug 2026 13:09:53 +0800 Subject: [PATCH] fix(docs): make site builds idempotent --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 4 +- .../2026-08-20-doc-site-raw-markdown-twins.md | 4 +- ...26-08-20-doc-site-raw-markdown-twins.zh.md | 4 +- package.json | 4 +- scripts/project-doc-site.spec.ts | 43 ++++++++++++++ website/AGENTS.md | 2 + website/build.ts | 57 +++++++++++++++++++ 7 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 website/build.ts 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 b6cb38880f..2b2e377275 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: 5b45657d13d02bc7211e47cad2143afd6890d4e2 -2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 +2026-08-20-doc-site-raw-markdown-twins.md: 85f6cc957d5f3a1675a38abc4675e76448ffb5f5 +2026-08-20-doc-site-raw-markdown-twins.zh.md: df6f0780223dc10fc9b5832f97a360e08e68b46b 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 5b45657d13..85f6cc957d 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 @@ -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 `.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 `.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. `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 1730c9e23e..df6f078022 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 @@ -14,7 +14,9 @@ Status: implemented 一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 -index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。 +index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。 + +每个生产构建入口都会解析 VitePress 配置,要求 `outDir` 必须是站点根目录的严格子目录,并在打包前删除该目录。由项目负责的这一步覆盖了不会清空最终输出目录的 MPA 构建,并避免被移除的路由或资产在重新构建后残留。随后的原始孪生页发射会把当前 VitePress 构建创建的文件视为已占用:孪生页或图片一律不得覆盖这类文件(例如 `public/` 副本),同名冲突会使发射失败。 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 diff --git a/package.json b/package.json index 65b9786b40..bec66ae4c9 100644 --- a/package.json +++ b/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", diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index df1c4dc4a4..5d58509fab 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -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,48 @@ 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') + const fresh = join(outDir, 'index.html') + 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) + mkdirSync(outDir) + writeFileSync(fresh, 'fresh\n') + + expect(readFileSync(fresh, 'utf8')).toBe('fresh\n') + }) + + 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') + }) +}) + describe('publishableImage', () => { it('accepts a regular file inside the repository', () => { const { root } = fixture() diff --git a/website/AGENTS.md b/website/AGENTS.md index 217417660b..65ca9d28e0 100644 --- a/website/AGENTS.md +++ b/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. 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/`. diff --git a/website/build.ts b/website/build.ts new file mode 100644 index 0000000000..dad6e4a5cd --- /dev/null +++ b/website/build.ts @@ -0,0 +1,57 @@ +/** Production documentation-site build with project-owned output preparation. */ + +import { rmSync } from 'node:fs' +import { 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]> + +/** + * Remove one documentation build output without permitting the site root or an outside path. + * @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`. + */ +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)}.`) + } + 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 { + 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(`build-doc-site: 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))) +}