Przeglądaj źródła

docs(website): serve index routes at their clean-URL .md addresses

Each index route also emits a parent-level alias twin, projected over
the alias route so its relative links stay correct; llms.txt and the
docs now state the drop-trailing-slash convention exactly. Frontmatter
failures name their page, the twin pass refuses to overwrite existing
build files, the dev middleware documents the deliberate in-page
fetch() divergence, and the per-locale collection order moves to one
shared export. Refs #2846.
Yichen Jiang 1 miesiąc temu
rodzic
commit
17c85209a0

+ 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: 8174d67b43776d7264710d88d11540288bf7b63f
-2026-08-20-doc-site-raw-markdown-twins.zh.md: 1babf957a6bdb1bd0a6ed91d6bbcb44b84aa713c
+2026-08-20-doc-site-raw-markdown-twins.md: 5b45657d13d02bc7211e47cad2143afd6890d4e2
+2026-08-20-doc-site-raw-markdown-twins.zh.md: d62088fe837e3f2eefe9e44e19dcfd3823e79560

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

@@ -14,9 +14,11 @@ 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.
+
 `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.
 
-The dev server serves the same surface. A middleware in the doc-projector plugin projects `.md` requests from their canonical sources per hit and generates `llms.txt` on demand, so `docs:dev` matches production without a rebuild.
+The dev server serves the same surface for navigations and header-less clients. A middleware in the doc-projector plugin projects `.md` requests from their canonical sources per hit and generates `llms.txt` on demand, so `docs:dev` matches production without a rebuild; an in-page `fetch()` (`Sec-Fetch-Dest: empty`) deliberately still reaches Vite in dev, while production static hosting answers it with the raw file.
 
 `verify-doc-site-fragments`, the post-build gate, fails the build when any route's twin or `llms.txt` is missing from the output, so deleting the `buildEnd` wiring cannot pass CI.
 
@@ -28,8 +30,8 @@ The dev server serves the same surface. A middleware in the doc-projector plugin
 
 **Absolute links inside the twins.** platform.claude.com links absolutely because its host is fixed. This site's base varies between local `/` and the Pages subpath, and the projector's relative links resolve in both trees as they are, so absolute rewriting would add a second link grammar without improving resolution.
 
-**Skip locale homes in the raw tree.** Published pages link to `docs/user/index.md`, so omitting the home routes breaks link closure. Their full bodies (H1 plus guide links) cost nothing, and the redirect frontmatter means nothing outside VitePress.
+**Skip locale homes in the raw tree.** Published pages link to `docs/user/index.md`, so omitting the home routes breaks link closure. The body that survives chrome stripping (the H1) costs nothing, and the redirect frontmatter means nothing outside VitePress.
 
 ## Consequences
 
-Agents fetch any page as plain Markdown at `<page URL>.md` and discover the whole set at `/llms.txt`; the rendered site is unchanged. The build output carries one extra Markdown file per route plus image copies beside them — kilobytes against the bundled assets. Twin content keeps GitHub-style heading text while the rendered site slugs punctuation-heavy headings differently; agents resolve headings themselves, so no gate covers raw-tree fragments. Deferred as unneeded for the twins' audience: `llms-full.txt`, and a per-page "view as Markdown" control, which would require a theme directory the stock-theme site deliberately lacks.
+Agents fetch any page as plain Markdown by dropping the URL's trailing slash and appending `.md`, and discover the whole set at `/llms.txt`; the rendered site is unchanged. The build output carries one extra Markdown file per route, an alias per index route, and image copies beside them — kilobytes against the bundled assets. Twin content keeps GitHub-style heading text while the rendered site slugs punctuation-heavy headings differently; agents resolve headings themselves, so no gate covers raw-tree fragments. Deferred as unneeded for the twins' audience: `llms-full.txt`, and a per-page "view as Markdown" control, which would require a theme directory the stock-theme site deliberately lacks.

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

@@ -14,9 +14,11 @@ 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/` 副本);同名冲突使发射失败。
+
 `llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label](<base><route>): <section>`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。
 
-开发服务器提供同一表面。doc-projector 插件中的 middleware 逐请求从 canonical 源投影 `.md` 请求并按需生成 `llms.txt`,`docs:dev` 无需重建即与线上一致。
+开发服务器对导航与无头客户端提供同一表面。doc-projector 插件中的 middleware 逐请求从 canonical 源投影 `.md` 请求并按需生成 `llms.txt`,`docs:dev` 无需重建即与线上一致;页面内 `fetch()`(`Sec-Fetch-Dest: empty`)在 dev 下刻意仍交给 Vite,而生产静态托管对它返回原始文件。
 
 构建后门禁 `verify-doc-site-fragments` 在任一路由的孪生页或 `llms.txt` 缺失时判定构建失败,删掉 `buildEnd` 接线无法通过 CI。
 
@@ -28,8 +30,8 @@ Status: implemented
 
 **孪生页内用绝对链接。** platform.claude.com 用绝对链接是因为其主机固定。本站 base 在本地 `/` 与 Pages 子路径之间变化,而投影器的相对链接在两棵树中原样可解析,绝对化改写只会引入第二套链接语法而不改善解析。
 
-**原始树跳过 locale 首页。** 已发布页面链接到 `docs/user/index.md`,省略首页路由会破坏链接封闭。首页正文(H1 加指南链接)成本为零,重定向 frontmatter 在 VitePress 之外没有意义。
+**原始树跳过 locale 首页。** 已发布页面链接到 `docs/user/index.md`,省略首页路由会破坏链接封闭。剥离版式后剩下的正文(即 H1)成本为零,重定向 frontmatter 在 VitePress 之外没有意义。
 
 ## Consequences
 
-Agent 在 `<page URL>.md` 获取任意页面的纯 Markdown,并在 `/llms.txt` 发现全集;渲染站不变。构建输出为每个路由多带一个 Markdown 文件及旁置图片副本——相对打包资产只是千字节级。孪生页保留 GitHub 风格的标题文本,而渲染站对含标点的标题使用不同 slug;agent 自行解析标题,因此没有门禁覆盖原始树的 fragment。因孪生页受众不需要而暂缓:`llms-full.txt`,以及每页的"查看 Markdown"控件——后者需要 stock-theme 站点刻意不设的 theme 目录。
+Agent 把页面 URL 去掉末尾斜杠再加 `.md` 即获取该页纯 Markdown,并在 `/llms.txt` 发现全集;渲染站不变。构建输出为每个路由多带一个 Markdown 文件、每个 index 路由多带一个别名,外加旁置图片副本——相对打包资产只是千字节级。孪生页保留 GitHub 风格的标题文本,而渲染站对含标点的标题使用不同 slug;agent 自行解析标题,因此没有门禁覆盖原始树的 fragment。因孪生页受众不需要而暂缓:`llms-full.txt`,以及每页的"查看 Markdown"控件——后者需要 stock-theme 站点刻意不设的 theme 目录。

+ 1 - 1
.agents/skills/dsh-doc-site-sync/SKILL.md

@@ -5,7 +5,7 @@ description: Use when publishing, updating, moving, or removing DeepSeek Harness
 
 # Synchronizing the DeepSeek Harness Documentation Site
 
-Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. The build additionally emits a raw-Markdown twin of every route (`<page URL>.md`) and a root `llms.txt` index; both derive from the same manifest and projector, so publishing, moving, or removing a page updates them automatically and `docs:build` fails when one is missing.
+Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. The build additionally emits a raw-Markdown twin of every route (page URL minus any trailing slash, plus `.md`; index routes also get a parent-level alias) and a root `llms.txt` index; both derive from the same manifest and projector, so publishing, moving, or removing a page updates them automatically and `docs:build` fails when one is missing.
 
 Repository translations follow the sibling pairing contract: English `foo.md`, Chinese `foo.zh.md`, and `foo.i18n.yaml` live together. Never create `zh-CN/` or other locale directories for website content. The site route trees are independent of that source layout: `foo.zh.md` projects to the root route and `foo.md` projects to the matching `/en/` route.
 

+ 56 - 7
scripts/project-doc-site.spec.ts

@@ -12,7 +12,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
 import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts'
 import {
   addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage,
-  rawMarkdownPageContent, rawMarkdownRoute, resolveRepositoryRef, rewriteMarkdown,
+  rawMarkdownFiles, rawMarkdownPageContent, rawMarkdownRoute, resolveRepositoryRef, rewriteMarkdown,
 } from './project-doc-site.ts'
 
 const roots: string[] = []
@@ -567,17 +567,21 @@ describe('rawMarkdownPageContent', () => {
   it('keeps the home body the rendered site omits and drops the VitePress frontmatter', () => {
     expect(rawMarkdownPageContent(
       '---\nlayout: false\nhead:\n  - - meta\n    - http-equiv: refresh\n      content: 0; url=./guide/quickstart\n---\n\n# Harness\n\nEnglish | [中文](./index.md)\n\nBody.\n',
+      'docs/user/index.zh.md',
     )).toBe('# Harness\n\nBody.\n')
   })
 
   it('drops the language switcher and repository badge like the rendered site', () => {
     const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)'
-    expect(rawMarkdownPageContent(`# Guide\n\nEnglish | [中文](./x)\n\nBody.\n\n${badge}\n`))
+    expect(rawMarkdownPageContent(`# Guide\n\nEnglish | [中文](./x)\n\nBody.\n\n${badge}\n`, 'docs/guide.md'))
       .toBe('# Guide\n\nBody.\n')
   })
 
-  it('rejects unclosed frontmatter', () => {
-    expect(() => rawMarkdownPageContent('---\nlayout: false\n')).toThrow('unclosed YAML frontmatter')
+  it('rejects unclosed frontmatter and names the page', () => {
+    // The twin pass is the first place an ordinary page's frontmatter is
+    // parsed, so an anonymous error would leave 168 routes to search.
+    expect(() => rawMarkdownPageContent('---\nlayout: false\n', 'docs/broken.md'))
+      .toThrow('project-doc-site: "docs/broken.md" has unclosed YAML frontmatter')
   })
 })
 
@@ -616,6 +620,51 @@ describe('emitRawMarkdownPages', () => {
 
     expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('# Home\n\n[A](./a.md)\n')
   })
+
+  it('emits a parent-level alias for an index route with links recomputed', () => {
+    // A copied alias would carry the index page's relative links one directory
+    // too high, so the alias is its own projection over the alias route.
+    const { root, pages } = fixture()
+    writeFileSync(join(root, 'docs/c.md'), '# C\n\n[A](a.md)\n')
+    pages.push({
+      locale: 'root', contentLocale: 'en-US', source: 'docs/c.md', route: 'guide/index.md',
+      label: 'C', sidebar: 'zh-guide', section: 'Test', order: 3,
+    })
+    const out = mirrorDir()
+
+    emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' })
+
+    expect(readFileSync(join(out, 'guide/index.md'), 'utf8')).toBe('# C\n\n[A](../a.md)\n')
+    expect(readFileSync(join(out, 'guide.md'), 'utf8')).toBe('# C\n\n[A](./a.md)\n')
+  })
+
+  it('refuses to overwrite a file the build already carries', () => {
+    // The twin pass writes into a populated build directory, and VitePress has
+    // already copied `website/public/` there; a page image sharing one of
+    // those names must fail loud instead of silently replacing the site file.
+    const { root, pages } = fixture()
+    writeFileSync(join(root, 'docs/a.md'), '![logo](../packages/logo.svg)\n')
+    const out = mirrorDir()
+    writeFileSync(join(out, 'logo.svg'), 'public copy\n')
+
+    expect(() => {
+      emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' })
+    }).toThrow('would overwrite')
+    expect(readFileSync(join(out, 'logo.svg'), 'utf8')).toBe('public copy\n')
+  })
+})
+
+describe('rawMarkdownFiles', () => {
+  it('lists every route plus a parent alias per index route', () => {
+    const files = rawMarkdownFiles()
+    for (const page of docsPages) expect(files).toContain(page.route)
+    expect(files).toContain('reference.md')
+    expect(files).toContain('en/reference.md')
+    expect(files).toContain('en.md')
+    // The root home has no parent to alias into; `/` is documented as `/index.md`.
+    expect(files).not.toContain('.md')
+    expect(new Set(files).size).toBe(files.length)
+  })
 })
 
 describe('raw Markdown projection of the published manifest', () => {
@@ -630,9 +679,9 @@ describe('raw Markdown projection of the published manifest', () => {
     rmSync(mirror, { recursive: true, force: true })
   })
 
-  it('emits every published route', () => {
-    for (const page of docsPages) {
-      expect(existsSync(join(mirror, page.route)), page.route).toBe(true)
+  it('emits every published route and every index alias', () => {
+    for (const file of rawMarkdownFiles()) {
+      expect(existsSync(join(mirror, file)), file).toBe(true)
     }
   })
 

+ 79 - 22
scripts/project-doc-site.ts

@@ -4,7 +4,8 @@
  * The generated tree is disposable: sources stay in their owning `docs/`
  * tier, while this adapter rewrites cross-source links for the public site.
  * The same projection also emits a raw-Markdown twin of every route into the
- * build output, so `<page URL>.md` serves the page as plain Markdown.
+ * build output, so a page's URL, minus any trailing slash, plus `.md` serves
+ * it as plain Markdown.
  */
 
 import {
@@ -15,7 +16,7 @@ import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
 import { gfm } from 'micromark-extension-gfm'
 import type { Nodes } from 'mdast'
-import { docsPages, orderedPages, type DocsLocale, type DocsPage, type DocsSidebar } from '../website/docs.ts'
+import { docsPages, localeCollections, orderedPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
 import {
   isExternalOrAbsoluteMarkdownUrl,
   markdownDestination,
@@ -333,11 +334,18 @@ function defaultProjectionContext(): ProjectionContext {
   return { pages: docsPages, repoRoot: root, repositoryRef: resolveRepositoryRef(process.env) }
 }
 
-/** Project every page and its images into one target tree. */
+/**
+ * Project every page and its images into one target tree.
+ *
+ * `entries` are what gets emitted; link resolution always reads the canonical
+ * `context.pages`, so an alias entry sharing a source with its index route
+ * emits at its own path while links keep targeting canonical routes.
+ */
 function projectPagesInto(
   targetRoot: string,
   context: ProjectionContext,
   pageContent: (markdown: string, page: DocsPage) => string,
+  entries: DocsPage[] = context.pages,
 ): void {
   const routes = new Set<string>()
   /** Projected path to the repository file that claimed it, pages and images alike. */
@@ -352,10 +360,19 @@ function projectPagesInto(
         + ` both project to ${relative(targetRoot, target).split(sep).join('/')}.`,
       )
     }
+    // A file the projection did not claim is another producer's output — in
+    // the twin pass, the build VitePress just wrote, including `public/`
+    // copies. Overwriting one would silently corrupt the site.
+    if (holder === undefined && existsSync(target)) {
+      throw new Error(
+        `project-doc-site: ${repoPath(sourceAbs, context.repoRoot)} would overwrite existing build file`
+        + ` ${relative(targetRoot, target).split(sep).join('/')}.`,
+      )
+    }
     claimed.set(target, sourceAbs)
   }
 
-  for (const page of context.pages) {
+  for (const page of entries) {
     if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
     routes.add(page.route)
     const sourceAbs = resolve(context.repoRoot, page.source)
@@ -410,41 +427,81 @@ export function projectDocs(): void {
  * Strip the leading YAML frontmatter of a projected page.
  *
  * @param markdown Rewritten canonical Markdown content.
+ * @param source Repository-relative page source, named by the failure.
  * @returns The content after the frontmatter block, or the input when none opens it.
  */
-function withoutFrontmatter(markdown: string): string {
+function withoutFrontmatter(markdown: string, source: string): string {
   if (!markdown.startsWith('---\n')) return markdown
   const closingDelimiter = '\n---\n'
   const closing = markdown.indexOf(closingDelimiter, 4)
-  if (closing === -1) throw new Error('project-doc-site: page has unclosed YAML frontmatter.')
+  if (closing === -1) {
+    throw new Error(`project-doc-site: ${JSON.stringify(source)} has unclosed YAML frontmatter.`)
+  }
   return markdown.slice(closing + closingDelimiter.length).replace(/^\n+/, '')
 }
 
 /**
  * The raw-Markdown twin of one published page.
  *
- * Frontmatter is VitePress rendering configuration, so it is dropped rather
- * than truncated to: a locale home page keeps its body here even though the
- * rendered site replaces it with a redirect.
+ * Frontmatter is VitePress rendering configuration and is dropped. A locale
+ * home page therefore keeps its body here, while the rendered site truncates
+ * it to the frontmatter redirect.
  *
  * @param markdown Rewritten canonical Markdown content.
+ * @param source Repository-relative page source, named by frontmatter failures.
  * @returns Plain Markdown without frontmatter or repository chrome.
  */
-export function rawMarkdownPageContent(markdown: string): string {
-  return withoutRepositoryChrome(withoutFrontmatter(markdown))
+export function rawMarkdownPageContent(markdown: string, source: string): string {
+  return withoutRepositoryChrome(withoutFrontmatter(markdown, source))
+}
+
+/**
+ * Parent-level alias route of an index route, or `undefined` for other routes.
+ *
+ * The rendered site shows an index route as a directory URL, so "append
+ * `.md`" naturally lands on `<dir>.md` once the trailing slash is dropped.
+ * The root `index.md` has no parent to alias into.
+ */
+function indexAliasRoute(route: string): string | undefined {
+  const match = /^(.+)\/index\.md$/.exec(route)
+  return match?.[1] === undefined ? undefined : `${match[1]}.md`
+}
+
+/**
+ * Site-relative Markdown files the raw-Markdown projection emits: every
+ * route, plus one parent-level alias per index route.
+ *
+ * @param pages Pages to project, defaulting to the publication manifest.
+ * @returns The emitted paths, routes first.
+ */
+export function rawMarkdownFiles(pages: DocsPage[] = docsPages): string[] {
+  const aliases = pages.map(page => indexAliasRoute(page.route)).filter(alias => alias !== undefined)
+  return [...pages.map(page => page.route), ...aliases]
 }
 
 /**
  * Emit the raw-Markdown twin of every published route into a built site, so
- * static hosting serves `<page URL>.md` beside each rendered page. Referenced
- * images are copied beside the pages, keeping the same relative URLs valid in
- * both trees. Existing build files stay in place.
+ * static hosting serves the page's URL, minus any trailing slash, plus `.md`
+ * as plain Markdown. Each index route also emits a parent-level alias twin,
+ * projected over the alias route so its relative links stay correct.
+ * Referenced images are copied beside the pages, keeping the same relative
+ * URLs valid in both trees. Existing build files stay in place, and a name
+ * collision with one fails the emission.
  *
  * @param outDir Build output directory to emit into.
  * @param context Manifest and repository inputs, defaulting to this repository.
  */
 export function emitRawMarkdownPages(outDir: string, context: ProjectionContext = defaultProjectionContext()): void {
-  projectPagesInto(outDir, context, markdown => rawMarkdownPageContent(markdown))
+  const aliases = context.pages.flatMap((page) => {
+    const alias = indexAliasRoute(page.route)
+    return alias === undefined ? [] : [{ ...page, route: alias }]
+  })
+  projectPagesInto(
+    outDir,
+    context,
+    (markdown, page) => rawMarkdownPageContent(markdown, page.source),
+    [...context.pages, ...aliases],
+  )
 }
 
 /**
@@ -469,7 +526,7 @@ export function rawMarkdownRoute(route: string, context: ProjectionContext = def
     repoRoot: context.repoRoot,
     repositoryRef: context.repositoryRef,
     placeImage: absPath => `./${encodeURI(basename(absPath))}`,
-  }))
+  }), page.source)
 }
 
 /** Site identity written into llms.txt. */
@@ -483,9 +540,9 @@ export interface LlmsTxtSite {
 }
 
 /** Locale groups llms.txt lists, in the order the site's navigation presents them. */
-const llmsTxtLocales: readonly { heading: string; locale: DocsLocale; collections: readonly DocsSidebar[] }[] = [
-  { heading: '简体中文', locale: 'root', collections: ['zh-guide', 'zh-develop', 'zh-reference'] },
-  { heading: 'English', locale: 'en', collections: ['en-guide', 'en-develop', 'en-reference'] },
+const llmsTxtLocales: readonly { heading: string; locale: DocsLocale }[] = [
+  { heading: '简体中文', locale: 'root' },
+  { heading: 'English', locale: 'en' },
 ]
 
 /**
@@ -504,11 +561,11 @@ export function llmsTxt(site: LlmsTxtSite): string {
     '',
     `> ${site.description}`,
     '',
-    '每个页面的 URL 加 `.md` 后缀即为该页的原始 Markdown。Append `.md` to any page URL for its raw Markdown source.',
+    '页面 URL 去掉末尾斜杠再加 `.md` 即为该页原始 Markdown(根路径用 `/index.md`);下方列表是各页精确地址。Drop any trailing slash and append `.md` to a page URL for its raw Markdown (the site root is `/index.md`); the list below carries the exact addresses.',
   ]
-  for (const { heading, locale, collections } of llmsTxtLocales) {
+  for (const { heading, locale } of llmsTxtLocales) {
     lines.push('', `## ${heading}`, '')
-    for (const collection of collections) {
+    for (const collection of localeCollections[locale]) {
       for (const page of orderedPages(locale, collection)) {
         lines.push(`- [${page.label}](${site.base}${page.route}): ${page.section}`)
       }

+ 4 - 3
scripts/verify-doc-site-fragments.ts

@@ -11,7 +11,7 @@
 import { existsSync, globSync, readFileSync } from 'node:fs'
 import { resolve, sep } from 'node:path'
 import { JSDOM } from 'jsdom'
-import { docsPages } from '../website/docs.ts'
+import { rawMarkdownFiles } from './project-doc-site.ts'
 
 const root = resolve(import.meta.dirname, '..')
 
@@ -154,11 +154,12 @@ export function missingSiteFiles(distRoot: string, expected: readonly string[]):
 function main(): number {
   const distRoot = resolve(root, 'website/.dist')
   const report = inspectSiteFragments(distRoot)
-  const missing = missingSiteFiles(distRoot, [...docsPages.map(page => page.route), 'llms.txt'])
+  const expected = rawMarkdownFiles()
+  const missing = missingSiteFiles(distRoot, [...expected, 'llms.txt'])
   if (report.broken.length === 0 && missing.length === 0) {
     console.log(
       `verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve;`
-      + ` ${docsPages.length} raw-Markdown route(s) and llms.txt emitted.`,
+      + ` ${expected.length} raw-Markdown file(s) and llms.txt emitted.`,
     )
     return 0
   }

+ 11 - 8
website/.vitepress/config.ts

@@ -5,7 +5,7 @@ import { resolve } from 'node:path'
 import type { DefaultTheme, PageData, SiteConfig } from 'vitepress'
 import type { ViteDevServer } from 'vite'
 import { withMermaid } from 'vitepress-plugin-mermaid'
-import { landingLink, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage, type DocsSidebar } from '../docs.ts'
+import { landingLink, localeCollections, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage, type DocsSidebar } from '../docs.ts'
 import { docsSourceFiles, emitRawMarkdownPages, llmsTxt, projectDocs, rawMarkdownRoute } from '../../scripts/project-doc-site.ts'
 
 projectDocs()
@@ -58,14 +58,14 @@ interface GuideModules {
  */
 const guideModules = {
   root: {
-    guide: 'zh-guide',
-    develop: { label: '开发', collection: 'zh-develop' },
-    reference: { label: '参考', collection: 'zh-reference' },
+    guide: localeCollections.root[0],
+    develop: { label: '开发', collection: localeCollections.root[1] },
+    reference: { label: '参考', collection: localeCollections.root[2] },
   },
   en: {
-    guide: 'en-guide',
-    develop: { label: 'Development', collection: 'en-develop' },
-    reference: { label: 'Reference', collection: 'en-reference' },
+    guide: localeCollections.en[0],
+    develop: { label: 'Development', collection: localeCollections.en[1] },
+    reference: { label: 'Reference', collection: localeCollections.en[2] },
   },
 } satisfies Record<DocsLocale, GuideModules>
 
@@ -125,7 +125,10 @@ function serveRawMarkdown(server: ViteDevServer): void {
     // The dev client imports page modules at these same `.md` URLs, and a
     // module script must reach Vite's transform. Browsers declare the purpose:
     // `script` for module imports, `document` for address-bar navigation.
-    // Header-less clients (curl, agents) read the raw twin.
+    // Header-less clients (curl, agents) read the raw twin. In-page fetch()
+    // (`empty`) also passes to Vite — a deliberate dev-only divergence that
+    // keeps Vite's own requests unbroken, while production static hosting
+    // answers such a fetch with the raw file.
     const fetchDest = req.headers['sec-fetch-dest']
     if (fetchDest !== undefined && fetchDest !== 'document') {
       next()

+ 1 - 1
website/AGENTS.md

@@ -10,6 +10,6 @@ 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/`.
 
-The build also emits each route's raw-Markdown twin and a root `llms.txt` index into `.dist/`, so `<page URL>.md` serves the page as plain Markdown. Both derive from the publication manifest at build time; neither is ever a file in this tree.
+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/`.

+ 10 - 0
website/docs.ts

@@ -412,6 +412,16 @@ const reference = [
   }]),
 ]
 
+/**
+ * Sidebar collections of each locale, in the order the site's navigation
+ * presents them. The navigation bar and the llms.txt index both read this
+ * sequence, so a new collection lands in both surfaces together.
+ */
+export const localeCollections = {
+  root: ['zh-guide', 'zh-develop', 'zh-reference'],
+  en: ['en-guide', 'en-develop', 'en-reference'],
+} as const satisfies Record<DocsLocale, readonly DocsSidebar[]>
+
 /** A sidebar group, matched to pages by `label`. */
 export interface DocsSection {
   /** Group heading, equal to the `section` field of every page it holds. */