From f3ce8218ccd96dca8e47898e0e65cc73e000c019 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 18:57:20 +0800 Subject: [PATCH 1/3] docs(website): serve every page as raw Markdown with an llms.txt index Append .md to any published route for the page as plain Markdown: the build emits a raw-Markdown twin of every route (frontmatter dropped, home bodies kept, images beside pages) plus a manifest-generated llms.txt, the dev server serves both per request, and the post-build gate fails when either is missing. Fixes #2846. --- ...8-20-doc-site-raw-markdown-twins.i18n.yaml | 6 + .../2026-08-20-doc-site-raw-markdown-twins.md | 35 ++++ ...26-08-20-doc-site-raw-markdown-twins.zh.md | 35 ++++ .agents/skills/dsh-doc-site-sync/SKILL.md | 2 +- scripts/project-doc-site.spec.ts | 164 ++++++++++++++++- scripts/project-doc-site.ts | 168 ++++++++++++++++-- scripts/verify-doc-site-fragments.spec.ts | 12 +- scripts/verify-doc-site-fragments.ts | 43 +++-- website/.vitepress/config.ts | 63 ++++++- website/AGENTS.md | 2 + 10 files changed, 493 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md create mode 100644 .agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md 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 new file mode 100644 index 0000000000..8854b962dd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 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 new file mode 100644 index 0000000000..8174d67b43 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.md @@ -0,0 +1,35 @@ +# Agent Note: Raw-Markdown twins and llms.txt on the documentation site + +Status: implemented + +English | [中文](2026-08-20-doc-site-raw-markdown-twins.zh.md) + +## Problem + +The documentation site serves rendered HTML only, so an agent reading the docs has to scrape VitePress markup or fall back to the repository, where links and image references follow source layout rather than public routes. Claude's platform documentation set the convention this feature adopts: append `.md` to any page URL for the page as raw Markdown, with a root `llms.txt` as the agent-facing index. [The site projection](2026-07-13-documentation-site-projection.md) rewrites every page's links for the public site already, so the gap was serving that projection as plain Markdown. + +## Decision + +`vitepress build` ends by emitting a raw-Markdown twin of every published route into the build output. `emitRawMarkdownPages` runs the same manifest-and-projector pass that fills `website/.generated/`, but writes `/` with raw page content: no `editSource`/`outline` projection frontmatter, no locale-home truncation — frontmatter is VitePress rendering configuration, so the twin drops it and keeps the body — and the same repository-chrome stripping as the rendered site. Referenced images are copied beside the twins. + +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. + +`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. + +`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. + +## Alternatives considered + +**`vitepress-plugin-llms`.** Actively maintained, MIT, used by the Vite, Vue, and Vitest sites; it generates per-page Markdown, `llms.txt`, `llms-full.txt`, and dev-server responses. Two hard-coded behaviors break this site. It flattens `dir/index.md` to `dir.md` with no opt-out while leaving in-page links unrewritten, which 404s every relative link into a section landing page — this site publishes seven index routes per locale. And it rewrites image references to root-absolute hashed bundle paths without the site base, which 404s on this subpath GitHub Pages deployment; its adopters deploy at domain roots and never hit either. Its `llms.txt` also reads sidebars only from the top-level theme config, not this site's per-locale ones. Correcting all that means a fork or a post-processing layer coupled to plugin internals — more owned surface than the small emitter reusing the tested projector. + +**Emit through Vite's `publicDir`.** Vite supports one public directory and the site already points it at the tracked `website/public/`; generated twins would land in a tracked tree the layout gate forbids. + +**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. + +## 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. 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 new file mode 100644 index 0000000000..1babf957a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-20-doc-site-raw-markdown-twins.zh.md @@ -0,0 +1,35 @@ +# Agent Note:文档站的纯 Markdown 孪生页与 llms.txt + +Status: implemented + +[English](2026-08-20-doc-site-raw-markdown-twins.md) | 中文 + +## Problem + +文档站只提供渲染后的 HTML,agent 读文档要么抓取 VitePress 标记,要么退回仓库源文件,而后者的链接和图片引用跟随源码布局而非公开路由。Claude 平台文档确立了本特性采纳的约定:任意页面 URL 加 `.md` 后缀即得到该页的原始 Markdown,站根 `llms.txt` 作为面向 agent 的索引。[站点投影](2026-07-13-documentation-site-projection.md)本就为公开站点重写每页链接,缺的只是把这份投影以纯 Markdown 形式对外提供。 + +## Decision + +`vitepress build` 结束时向构建输出发射每个已发布路由的纯 Markdown 孪生页。`emitRawMarkdownPages` 复用填充 `website/.generated/` 的同一趟 manifest 加投影器流程,但以原始页面内容写入 `/`:不带 `editSource`/`outline` 投影 frontmatter,不做 locale 首页截断——frontmatter 是 VitePress 的渲染配置,孪生页丢弃它并保留正文——仓库版式(语言切换行、徽章)的剥离与渲染站一致。页面引用的图片复制到孪生页旁边。 + +一份投影同时服务两棵树,因为站内链接是相对路径。`./sibling.md` 在 HTML 站渲染为 clean URL,在原始树中按文件对文件解析,孪生页不需要第二套链接改写模式。所有路由都被发射,包括仅有 frontmatter 的 locale 首页:已发布页面链接到它们,原始树必须保持链接封闭;一个 spec 遍历发射树中的每条相对链接来钉住这条闭合性。 + +`llms.txt` 由发布 manifest 生成于站根:两棵语言树按侧边栏顺序排列,每页一行 `- [label]():
`,链接为携带部署期 `DOCS_BASE` 的站内绝对路径。locale 首页不列入——这个文件本身就是 agent 的入口。 + +开发服务器提供同一表面。doc-projector 插件中的 middleware 逐请求从 canonical 源投影 `.md` 请求并按需生成 `llms.txt`,`docs:dev` 无需重建即与线上一致。 + +构建后门禁 `verify-doc-site-fragments` 在任一路由的孪生页或 `llms.txt` 缺失时判定构建失败,删掉 `buildEnd` 接线无法通过 CI。 + +## Alternatives considered + +**`vitepress-plugin-llms`。** 维护活跃、MIT 协议,Vite、Vue、Vitest 官方站点在用;能生成每页 Markdown、`llms.txt`、`llms-full.txt` 和开发服务器响应。但两个硬编码行为在本站产生破坏。它把 `dir/index.md` 展平为 `dir.md` 且无法关闭,同时不改写页内链接,所有指向栏目落地页的相对链接全部 404——本站每个语言树发布七个 index 路由。它还把图片引用改写为不带站点 base 的根绝对哈希资产路径,在本站的子路径 GitHub Pages 部署下必然 404;其采用者都部署在域名根,两个问题都碰不到。其 `llms.txt` 只认顶层 theme 配置的侧边栏,认不出本站按 locale 存放的侧边栏。修正这些意味着 fork 或一层耦合插件内部行为的后处理——比复用已测投影器的小发射器拥有更多自有面积。 + +**经 Vite `publicDir` 发射。** Vite 只支持一个 public 目录,本站已将其指向被跟踪的 `website/public/`;生成的孪生页会落入布局门禁禁止的跟踪树。 + +**孪生页内用绝对链接。** platform.claude.com 用绝对链接是因为其主机固定。本站 base 在本地 `/` 与 Pages 子路径之间变化,而投影器的相对链接在两棵树中原样可解析,绝对化改写只会引入第二套链接语法而不改善解析。 + +**原始树跳过 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 目录。 diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 91dd42ae8a..a89e3d6034 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. +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. 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 200263bb5a..b2f5cca429 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -3,11 +3,16 @@ import { execFileSync } from 'node:child_process' import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join, resolve } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { basename, dirname, join, resolve } from 'node:path' +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 { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { - addProjectionFrontmatter, projectedPageContent, publishableImage, resolveRepositoryRef, rewriteMarkdown, + addProjectionFrontmatter, emitRawMarkdownPages, llmsTxt, projectedPageContent, publishableImage, + rawMarkdownPageContent, rawMarkdownRoute, resolveRepositoryRef, rewriteMarkdown, } from './project-doc-site.ts' const roots: string[] = [] @@ -557,3 +562,156 @@ describe('projectedPageContent', () => { .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter') }) }) + +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', + )).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`)) + .toBe('# Guide\n\nBody.\n') + }) + + it('rejects unclosed frontmatter', () => { + expect(() => rawMarkdownPageContent('---\nlayout: false\n')).toThrow('unclosed YAML frontmatter') + }) +}) + +describe('emitRawMarkdownPages', () => { + function mirrorDir(): string { + const out = mkdtempSync(join(tmpdir(), 'dsh-doc-mirror-')) + roots.push(out) + return out + } + + it('writes every route with rewritten links, placed images, and no projection frontmatter', () => { + const { root, pages } = fixture() + writeFileSync(join(root, 'docs/a.md'), '[B](b.md) ![logo](../packages/logo.svg)\n') + const out = mirrorDir() + + // The real path, because image placement proves containment via realpath. + emitRawMarkdownPages(out, { pages, repoRoot: realpathSync(root), repositoryRef: 'abc123' }) + + expect(readFileSync(join(out, 'a.md'), 'utf8')).toBe('[B](./reference-root/b.md) ![logo](./logo.svg)\n') + expect(readFileSync(join(out, 'en/a.md'), 'utf8')).toBe('[B](./reference/b.md) ![logo](./logo.svg)\n') + expect(readFileSync(join(out, 'reference-root/b.md'), 'utf8')).toBe('# B\n') + expect(existsSync(join(out, 'logo.svg'))).toBe(true) + expect(existsSync(join(out, 'en/logo.svg'))).toBe(true) + }) + + it('emits the full body of a locale home page', () => { + const { root, pages } = fixture() + writeFileSync(join(root, 'docs/home.md'), '---\nlayout: false\n---\n\n# Home\n\n[A](a.md)\n') + pages.push({ + locale: 'root', contentLocale: 'zh-CN', source: 'docs/home.md', route: 'index.md', + label: 'Home', sidebar: null, section: 'Home', order: 0, + }) + const out = mirrorDir() + + emitRawMarkdownPages(out, { pages, repoRoot: root, repositoryRef: 'abc123' }) + + expect(readFileSync(join(out, 'index.md'), 'utf8')).toBe('# Home\n\n[A](./a.md)\n') + }) +}) + +describe('raw Markdown projection of the published manifest', () => { + let mirror: string + + beforeAll(() => { + mirror = mkdtempSync(join(tmpdir(), 'dsh-doc-mirror-real-')) + emitRawMarkdownPages(mirror, { pages: docsPages, repoRoot: repositoryRoot, repositoryRef: 'master' }) + }) + + afterAll(() => { + 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 home pages with their bodies instead of the frontmatter stub', () => { + for (const route of ['index.md', 'en/index.md']) { + const home = readFileSync(join(mirror, route), 'utf8') + expect(home.startsWith('---'), route).toBe(false) + expect(home, route).toContain('# DeepSeek Harness') + } + }) + + it('resolves every relative link inside the emitted tree', () => { + // Raw pages are read outside the site, so a relative target that only the + // rendered site serves would strand every agent following it. + const broken: string[] = [] + for (const file of globSync('**/*.md', { cwd: mirror }).sort()) { + for (const target of relativeTargets(readFileSync(join(mirror, file), 'utf8'))) { + if (!existsSync(resolve(mirror, dirname(file), target))) broken.push(`${file}: ${target}`) + } + } + expect(broken).toEqual([]) + }) +}) + +function relativeTargets(markdown: string): string[] { + const tree = fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const targets: string[] = [] + const visit = (node: Nodes): void => { + if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) { + const external = node.url.startsWith('#') + || node.url.startsWith('/') + || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(node.url) + const path = node.url.split(/[?#]/)[0] ?? '' + if (!external && path !== '') targets.push(decodeURIComponent(path)) + } + if ('children' in node) { + for (const child of node.children) visit(child) + } + } + visit(tree) + return targets +} + +describe('llmsTxt', () => { + const site = { base: '/x/', title: 'DeepSeek Harness', description: '插件化 SDK' } + + it('lists every sidebar page as a base-prefixed raw-Markdown link', () => { + const text = llmsTxt(site) + for (const page of docsPages) { + if (page.sidebar === null) expect(text, page.route).not.toContain(`](/x/${page.route})`) + else expect(text, page.route).toContain(`- [${page.label}](/x/${page.route}): ${page.section}`) + } + }) + + it('groups the two locale trees under their own headings', () => { + const text = llmsTxt(site) + expect(text.indexOf('## 简体中文')).toBeGreaterThan(-1) + expect(text.indexOf('## English')).toBeGreaterThan(text.indexOf('## 简体中文')) + }) + + it('carries the site identity and the raw-Markdown convention', () => { + const text = llmsTxt(site) + expect(text.startsWith('# DeepSeek Harness\n')).toBe(true) + expect(text).toContain('> 插件化 SDK') + expect(text).toMatch(/`\.md`/) + }) +}) + +describe('rawMarkdownRoute', () => { + it('projects one published route on demand', () => { + const { root, pages } = fixture() + writeFileSync(join(root, 'docs/a.md'), '# A\n\n[B](b.md)\n') + + expect(rawMarkdownRoute('en/a.md', { pages, repoRoot: root, repositoryRef: 'abc123' })) + .toBe('# A\n\n[B](./reference/b.md)\n') + }) + + it('returns undefined for a path the manifest does not publish', () => { + const { root, pages } = fixture() + expect(rawMarkdownRoute('en/missing.md', { pages, repoRoot: root, repositoryRef: 'abc123' })).toBeUndefined() + }) +}) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 582a306df3..3490a1a445 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -3,6 +3,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. */ import { @@ -13,7 +15,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, type DocsLocale, type DocsPage } from '../website/docs.ts' +import { docsPages, orderedPages, type DocsLocale, type DocsPage, type DocsSidebar } from '../website/docs.ts' import { isExternalOrAbsoluteMarkdownUrl, markdownDestination, @@ -317,34 +319,50 @@ export function docsSourceFiles(): string[] { return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])] } -/** Rebuild the disposable VitePress source tree from the publication manifest. */ -export function projectDocs(): void { +/** Manifest and repository inputs for one projection pass. */ +export interface ProjectionContext { + /** Pages to project. */ + pages: DocsPage[] + /** Repository root every source and placed image must live under. */ + repoRoot: string + /** Public ref used by projected GitHub links. */ + repositoryRef: string +} + +function defaultProjectionContext(): ProjectionContext { + return { pages: docsPages, repoRoot: root, repositoryRef: resolveRepositoryRef(process.env) } +} + +/** Project every page and its images into one target tree. */ +function projectPagesInto( + targetRoot: string, + context: ProjectionContext, + pageContent: (markdown: string, page: DocsPage) => string, +): void { const routes = new Set() /** Projected path to the repository file that claimed it, pages and images alike. */ const claimed = new Map() - const repositoryRef = resolveRepositoryRef(process.env) - rmSync(generatedRoot, { recursive: true, force: true }) /** Reserve one projected path, refusing a second source for it. */ const claim = (target: string, sourceAbs: string): void => { const holder = claimed.get(target) if (holder !== undefined && holder !== sourceAbs) { throw new Error( - `project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}` - + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`, + `project-doc-site: ${repoPath(sourceAbs, context.repoRoot)} and ${repoPath(holder, context.repoRoot)}` + + ` both project to ${relative(targetRoot, target).split(sep).join('/')}.`, ) } claimed.set(target, sourceAbs) } - for (const page of docsPages) { + for (const page of context.pages) { if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) routes.add(page.route) - const sourceAbs = resolve(root, page.source) + const sourceAbs = resolve(context.repoRoot, page.source) if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) { throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) } - const output = resolve(generatedRoot, page.route) + const output = resolve(targetRoot, page.route) // Claimed before the images are placed: a page and an image landing on one // path would otherwise overwrite each other in whichever order they ran. claim(output, sourceAbs) @@ -354,14 +372,14 @@ export function projectDocs(): void { sourcePath: page.source, locale: page.locale, route: page.route, - pages: docsPages, - repoRoot: root, - repositoryRef, + pages: context.pages, + repoRoot: context.repoRoot, + repositoryRef: context.repositoryRef, placeImage: (absPath) => { - const real = publishableImage(absPath, root) + const real = publishableImage(absPath, context.repoRoot) if (real === undefined) { throw new Error( - `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},` + `project-doc-site: ${page.source} references image ${repoPath(absPath, context.repoRoot)},` + ' which is not a regular file inside the repository.', ) } @@ -377,6 +395,124 @@ export function projectDocs(): void { return `./${encodeURI(name)}` }, }) - writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) + writeFileSync(output, pageContent(projected, page)) } } + +/** Rebuild the disposable VitePress source tree from the publication manifest. */ +export function projectDocs(): void { + rmSync(generatedRoot, { recursive: true, force: true }) + projectPagesInto(generatedRoot, defaultProjectionContext(), (markdown, page) => + addProjectionFrontmatter(projectedPageContent(markdown, page), page)) +} + +/** + * Strip the leading YAML frontmatter of a projected page. + * + * @param markdown Rewritten canonical Markdown content. + * @returns The content after the frontmatter block, or the input when none opens it. + */ +function withoutFrontmatter(markdown: 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.') + 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. + * + * @param markdown Rewritten canonical Markdown content. + * @returns Plain Markdown without frontmatter or repository chrome. + */ +export function rawMarkdownPageContent(markdown: string): string { + return withoutRepositoryChrome(withoutFrontmatter(markdown)) +} + +/** + * 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. + * + * @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)) +} + +/** + * Raw Markdown served for one site route. + * + * Dev-server counterpart of {@link emitRawMarkdownPages}: images are not + * copied because the generated tree already serves them beside the page. + * + * @param route Manifest route, including its `.md` suffix. + * @param context Manifest and repository inputs, defaulting to this repository. + * @returns The projected page, or `undefined` when the manifest does not publish the route. + */ +export function rawMarkdownRoute(route: string, context: ProjectionContext = defaultProjectionContext()): string | undefined { + const page = context.pages.find(candidate => candidate.route === route) + if (page === undefined) return undefined + const markdown = readFileSync(resolve(context.repoRoot, page.source), 'utf8') + return rawMarkdownPageContent(rewriteMarkdown(markdown, { + sourcePath: page.source, + locale: page.locale, + route: page.route, + pages: context.pages, + repoRoot: context.repoRoot, + repositoryRef: context.repositoryRef, + placeImage: absPath => `./${encodeURI(basename(absPath))}`, + })) +} + +/** Site identity written into llms.txt. */ +export interface LlmsTxtSite { + /** Site base path, carrying the leading and trailing slashes VitePress requires. */ + base: string + /** Site title. */ + title: string + /** Site description. */ + description: string +} + +/** 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'] }, +] + +/** + * The llms.txt index of every published page's raw-Markdown twin. + * + * Links are site-absolute so an agent resolves them against the host it + * fetched llms.txt from; locale home pages stay out because this file is the + * agent-facing entry point itself. + * + * @param site Site identity and base path. + * @returns llms.txt content listing both locale trees. + */ +export function llmsTxt(site: LlmsTxtSite): string { + const lines = [ + `# ${site.title}`, + '', + `> ${site.description}`, + '', + '每个页面的 URL 加 `.md` 后缀即为该页的原始 Markdown。Append `.md` to any page URL for its raw Markdown source.', + ] + for (const { heading, locale, collections } of llmsTxtLocales) { + lines.push('', `## ${heading}`, '') + for (const collection of collections) { + for (const page of orderedPages(locale, collection)) { + lines.push(`- [${page.label}](${site.base}${page.route}): ${page.section}`) + } + } + } + return `${lines.join('\n')}\n` +} diff --git a/scripts/verify-doc-site-fragments.spec.ts b/scripts/verify-doc-site-fragments.spec.ts index d06d46f53b..a85914cb54 100644 --- a/scripts/verify-doc-site-fragments.spec.ts +++ b/scripts/verify-doc-site-fragments.spec.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { inspectSiteFragments } from './verify-doc-site-fragments.ts' +import { inspectSiteFragments, missingSiteFiles } from './verify-doc-site-fragments.ts' const roots: string[] = [] @@ -85,3 +85,13 @@ describe('inspectSiteFragments', () => { ]) }) }) + +describe('missingSiteFiles', () => { + it('reports the expected files a build did not emit', () => { + const root = fixture() + writeFileSync(join(root, 'guide/start.md'), '# Ready\n') + + expect(missingSiteFiles(root, ['guide/start.md', 'guide/absent.md', 'llms.txt'])) + .toEqual(['guide/absent.md', 'llms.txt']) + }) +}) diff --git a/scripts/verify-doc-site-fragments.ts b/scripts/verify-doc-site-fragments.ts index c086dedccb..3d85a34727 100644 --- a/scripts/verify-doc-site-fragments.ts +++ b/scripts/verify-doc-site-fragments.ts @@ -1,15 +1,17 @@ /** - * Verify fragment links against the HTML emitted by VitePress. Markdown and - * VitePress use different heading-slug algorithms, so source-link validation - * alone cannot prove that a published fragment exists. + * Verify fragment links against the HTML emitted by VitePress, and that the + * build carries the raw-Markdown twin of every route plus llms.txt. Markdown + * and VitePress use different heading-slug algorithms, so source-link + * validation alone cannot prove that a published fragment exists. * * This runs as part of `docs:build` and can also run directly after a build * with `tsx scripts/verify-doc-site-fragments.ts`. */ -import { globSync, readFileSync } from 'node:fs' +import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve, sep } from 'node:path' import { JSDOM } from 'jsdom' +import { docsPages } from '../website/docs.ts' const root = resolve(import.meta.dirname, '..') @@ -138,18 +140,39 @@ export function inspectSiteFragments(distRoot: string): SiteFragmentReport { return { checked, broken } } +/** + * Expected files a build did not emit. + * + * @param distRoot - Directory containing the built site. + * @param expected - Site-relative files the build must carry. + * @returns The absent files, in the given order. + */ +export function missingSiteFiles(distRoot: string, expected: readonly string[]): string[] { + return expected.filter(file => !existsSync(resolve(distRoot, file))) +} + function main(): number { const distRoot = resolve(root, 'website/.dist') const report = inspectSiteFragments(distRoot) - if (report.broken.length === 0) { - console.log(`verify-doc-site-fragments: ${report.checked} internal fragment reference(s) resolve.`) + const missing = missingSiteFiles(distRoot, [...docsPages.map(page => page.route), '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.`, + ) return 0 } - console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`) - for (const item of report.broken) { - const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}` - console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`) + if (report.broken.length > 0) { + console.error(`verify-doc-site-fragments: ${report.broken.length} broken fragment reference(s):`) + for (const item of report.broken) { + const target = item.target === undefined ? 'target route was not built' : `${item.target} has no id ${JSON.stringify(item.fragment)}` + console.error(` ${item.source}: ${JSON.stringify(item.href)} (${target})`) + } + } + if (missing.length > 0) { + console.error(`verify-doc-site-fragments: ${missing.length} expected raw-Markdown file(s) missing from the build:`) + for (const file of missing) console.error(` ${file}`) } return 1 } diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 86e22c7706..28470927ba 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -1,12 +1,12 @@ /** VitePress configuration for the locally projected documentation site. */ -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' -import type { DefaultTheme, PageData } from 'vitepress' +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 { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts' +import { docsSourceFiles, emitRawMarkdownPages, llmsTxt, projectDocs, rawMarkdownRoute } from '../../scripts/project-doc-site.ts' projectDocs() @@ -111,6 +111,43 @@ function watchCanonicalDocs(server: ViteDevServer): void { }) } +/** + * Serve the raw-Markdown twin of each route and llms.txt during development, + * matching what `buildEnd` emits into the static build. Pages project from + * their canonical sources per request, so an edit shows without a rebuild. + */ +function serveRawMarkdown(server: ViteDevServer): void { + server.middlewares.use((req, res, next) => { + if (req.url === undefined || (req.method !== 'GET' && req.method !== 'HEAD')) { + next() + return + } + // 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. + const fetchDest = req.headers['sec-fetch-dest'] + if (fetchDest !== undefined && fetchDest !== 'document') { + next() + return + } + const pathname = req.url.split(/[?#]/, 1)[0] ?? '' + const sitePath = pathname.startsWith(base) ? pathname.slice(base.length) : pathname.replace(/^\//, '') + if (sitePath === 'llms.txt') { + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.end(llmsTxt({ base, ...siteIdentity })) + return + } + const content = sitePath.endsWith('.md') ? rawMarkdownRoute(sitePath) : undefined + if (content === undefined) { + next() + return + } + res.setHeader('Content-Type', 'text/markdown; charset=utf-8') + res.end(content) + }) +} + function escapeVueInterpolation(html: string): string { return html.replaceAll('{{', '{{').replaceAll('}}', '}}') } @@ -163,6 +200,12 @@ const sharedTheme: Pick` would freeze the mark at the colors the file declares. @@ -247,9 +290,14 @@ function siteTitle(previewTag: string): string { } export default withMermaid({ - title: 'DeepSeek Harness', - description: '用于构建 Agent Harness 的插件化 SDK', + title: siteIdentity.title, + description: siteIdentity.description, base, + /** Emit the raw-Markdown twin of every route plus llms.txt beside the rendered site. */ + buildEnd(siteConfig: SiteConfig) { + emitRawMarkdownPages(siteConfig.outDir) + writeFileSync(resolve(siteConfig.outDir, 'llms.txt'), llmsTxt({ base, ...siteIdentity })) + }, head: [ // VitePress leaves head hrefs untouched, so the base belongs here explicitly. ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}favicon.svg` }], @@ -322,7 +370,10 @@ export default withMermaid({ plugins: [ { name: 'deepseek-harness-doc-projector', - configureServer: watchCanonicalDocs, + configureServer(server) { + watchCanonicalDocs(server) + serveRawMarkdown(server) + }, }, ], }, diff --git a/website/AGENTS.md b/website/AGENTS.md index 333041fd28..7b2778982d 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -10,4 +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. + Run `pnpm docs:check` after changing this subtree; the gate rejects additional non-ignored Markdown under `website/`. From 17c85209a0ce051506d46b05421c7dd96c1bcb09 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:06:14 +0800 Subject: [PATCH 2/3] 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. */ From 68d24f323707e692fee5ea5b1dd0ca5817dfb4ac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 22:29:32 +0800 Subject: [PATCH 3/3] docs(notes): localize zh note links and widen the closure walk timeout The localized-link pairing rule that landed on master requires Chinese notes to link Chinese counterparts; fix this PR's note and the composer-edit-range note the rule landed after, so the merge ref passes the gate. The full-manifest closure walk gets an explicit timeout: coverage instrumentation on a loaded runner pushes it past vitest's 5s default. Refs #2846. --- .../2026-08-20-composer-edit-range-from-selection.i18n.yaml | 2 +- .../2026-08-20-composer-edit-range-from-selection.zh.md | 2 +- .../2026-08-20-doc-site-raw-markdown-twins.i18n.yaml | 2 +- .../process/2026-08-20-doc-site-raw-markdown-twins.zh.md | 2 +- scripts/project-doc-site.spec.ts | 6 ++++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml index d84448b9d2..51652f6906 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md 2026-08-20-composer-edit-range-from-selection.md: f836fe4de297746d35f7343cd215e8522a0116d0 -2026-08-20-composer-edit-range-from-selection.zh.md: 56f7044ee7f60b72d32226451d663b3f18371c69 +2026-08-20-composer-edit-range-from-selection.zh.md: 86e65e4567c6d061b458e77be856aa1942cf0fe3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md index 56f7044ee7..86e65e4567 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md @@ -14,7 +14,7 @@ Status: implemented 此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。 -这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 +这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.zh.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 ## 决策 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 de5b7e65d0..b6cb38880f 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 @@ -3,4 +3,4 @@ # 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: d62088fe837e3f2eefe9e44e19dcfd3823e79560 +2026-08-20-doc-site-raw-markdown-twins.zh.md: 1730c9e23ee7abc72350943370a0a346ddc4e7f4 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 d62088fe83..1730c9e23e 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 @@ -6,7 +6,7 @@ Status: implemented ## Problem -文档站只提供渲染后的 HTML,agent 读文档要么抓取 VitePress 标记,要么退回仓库源文件,而后者的链接和图片引用跟随源码布局而非公开路由。Claude 平台文档确立了本特性采纳的约定:任意页面 URL 加 `.md` 后缀即得到该页的原始 Markdown,站根 `llms.txt` 作为面向 agent 的索引。[站点投影](2026-07-13-documentation-site-projection.md)本就为公开站点重写每页链接,缺的只是把这份投影以纯 Markdown 形式对外提供。 +文档站只提供渲染后的 HTML,agent 读文档要么抓取 VitePress 标记,要么退回仓库源文件,而后者的链接和图片引用跟随源码布局而非公开路由。Claude 平台文档确立了本特性采纳的约定:任意页面 URL 加 `.md` 后缀即得到该页的原始 Markdown,站根 `llms.txt` 作为面向 agent 的索引。[站点投影](2026-07-13-documentation-site-projection.zh.md)本就为公开站点重写每页链接,缺的只是把这份投影以纯 Markdown 形式对外提供。 ## Decision diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 11827fafe8..05ff7356f1 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -670,10 +670,12 @@ describe('rawMarkdownFiles', () => { describe('raw Markdown projection of the published manifest', () => { let mirror: string + // Coverage instrumentation on a loaded CI runner stretches the full-manifest + // emission and the 181-file link walk past vitest's 5s default. beforeAll(() => { mirror = mkdtempSync(join(tmpdir(), 'dsh-doc-mirror-real-')) emitRawMarkdownPages(mirror, { pages: docsPages, repoRoot: repositoryRoot, repositoryRef: 'master' }) - }) + }, 60_000) afterAll(() => { rmSync(mirror, { recursive: true, force: true }) @@ -693,7 +695,7 @@ describe('raw Markdown projection of the published manifest', () => { } }) - it('resolves every relative link inside the emitted tree', () => { + it('resolves every relative link inside the emitted tree', { timeout: 60_000 }, () => { // Raw pages are read outside the site, so a relative target that only the // rendered site serves would strand every agent following it. const broken: string[] = []