From 17c85209a0ce051506d46b05421c7dd96c1bcb09 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:06:14 +0800 Subject: [PATCH] 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. --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 4 +- .../2026-08-20-doc-site-raw-markdown-twins.md | 8 +- ...26-08-20-doc-site-raw-markdown-twins.zh.md | 8 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- scripts/project-doc-site.spec.ts | 63 +++++++++-- scripts/project-doc-site.ts | 101 ++++++++++++++---- scripts/verify-doc-site-fragments.ts | 7 +- website/.vitepress/config.ts | 19 ++-- website/AGENTS.md | 2 +- website/docs.ts | 10 ++ 10 files changed, 174 insertions(+), 50 deletions(-) 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://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) } }) 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. */