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 8854b962dd..de5b7e65d0 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: 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
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 8174d67b43..5b45657d13 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,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 `
.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](): ` 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 `.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.
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 1babf957a6..d62088fe83 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,9 +14,11 @@ Status: implemented
一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。
+index 路由在渲染站上呈现为目录 URL,"加 `.md`"在去掉末尾斜杠后落在 `.md` 上;因此每个 index 路由还发射一个父级别名孪生页。别名不是拷贝——拷贝的 `index.md` 会让相对链接整体上移一层——而是以别名 route 为基准的独立投影,链接解析仍针对 canonical manifest,始终指向 canonical 孪生页。根首页没有可放别名的父级;`/` 在文档中写明用 `/index.md`。孪生页与图片一律不得覆盖构建目录中已存在的文件(例如 `public/` 副本);同名冲突使发射失败。
+
`llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label](): `,链接为携带部署期 `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 在 `.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 目录。
diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md
index a89e3d6034..e4a7dede73 100644
--- a/.agents/skills/dsh-doc-site-sync/SKILL.md
+++ b/.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 (`.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.
diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts
index b2f5cca429..11827fafe8 100644
--- a/scripts/project-doc-site.spec.ts
+++ b/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://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'), '\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)
}
})
diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts
index 3490a1a445..1efac68ec5 100644
--- a/scripts/project-doc-site.ts
+++ b/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 `.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()
/** 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 `.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 `.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}`)
}
diff --git a/scripts/verify-doc-site-fragments.ts b/scripts/verify-doc-site-fragments.ts
index 3d85a34727..5e1ba882cc 100644
--- a/scripts/verify-doc-site-fragments.ts
+++ b/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
}
diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts
index 28470927ba..d069c52ce8 100644
--- a/website/.vitepress/config.ts
+++ b/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
@@ -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()
diff --git a/website/AGENTS.md b/website/AGENTS.md
index 7b2778982d..89af73f6b3 100644
--- a/website/AGENTS.md
+++ b/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 `.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/`.
diff --git a/website/docs.ts b/website/docs.ts
index 7acd14092c..15217647b4 100644
--- a/website/docs.ts
+++ b/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
+
/** A sidebar group, matched to pages by `label`. */
export interface DocsSection {
/** Group heading, equal to the `section` field of every page it holds. */