Răsfoiți Sursa

fix(scripts): only publish images the repository owns, and keep their suffix

Review found four real gaps in the image placement this PR introduced.

Link rewriting only needs a target to exist, but publication copies its bytes
onto the site: a reference reaching out of the tree through `../..` or a
symlink would put a build-machine file on a published page. Only a regular
file whose real path stays inside the repository is copied now, and anything
else fails the projection naming the page and the target.

A placed reference kept none of its `?query` or `#fragment`, which the GitHub
branch has always carried and which decides what an SVG view fragment or a
Vite query means. The suffix rides along again, and the file name is
percent-encoded because the destination is a Markdown inline target.

Page outputs and placed images now claim projected paths from one map, so the
"fail loud rather than overwrite" invariant covers a page and an image landing
on one path, not only two images. `docsSourceFiles()` reports placed images, so
replacing a screenshot re-projects under `docs:dev` instead of serving the
previous copy until something touches the page.

The guide said to set `agent-loop`'s `agents` to change the default model,
which does nothing for `dsh web`: that default is `api-gateway`'s, and the
shipped composition leaves `agents` empty. It also promised that a catalog
provider needs only an API key, which is false for Bedrock, Vertex, Azure, and
Codex. Both are corrected.

The projection note and the doc-site skill carried the superseded "a
repository image becomes a raw GitHub URL" rule; both now describe what ships.
Yichen Jiang 1 lună în urmă
părinte
comite
a48b84c001

+ 2 - 2
.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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-07-13-documentation-site-projection.md
-2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01
-2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c
+2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0
+2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0

+ 1 - 1
.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md

@@ -18,7 +18,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g
 
 Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching.
 
-The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates.
+The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates.
 
 `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest.
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。
 
-投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。
+投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。
 
 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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-06-doc-site-carries-its-images.md
-2026-08-06-doc-site-carries-its-images.md: 21593c2cadb6b2aaf52350ab61156ad892bc4163
-2026-08-06-doc-site-carries-its-images.zh.md: 54aee878a9d0f16d1fe3b219da7b248fb5148fa3
+2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42
+2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc

+ 6 - 2
.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md

@@ -14,7 +14,11 @@ That works only for a public repository. This one is private, and `raw.githubuse
 
 `rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page.
 
-The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. Two sources that would project onto one path throw, in the same spirit as the existing duplicate-route check, rather than letting whichever copied last win.
+The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. One map claims every projected path — pages and images alike — so a second source for one path throws, in the same spirit as the existing duplicate-route check, rather than letting whichever wrote last win.
+
+Only a regular file whose real path stays inside the repository is copied; anything else fails the projection naming the page and the target. Link rewriting needs to know a target *exists*, but publication copies its bytes onto the site, so a reference escaping the repository — through `../..` or a symlink out of the tree — would put a build-machine file on a published page. The reference's `?query` or `#fragment` rides along to the placed URL exactly as the GitHub branch has always carried it, and the file name is percent-encoded because the destination is a Markdown inline target.
+
+`docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page.
 
 `placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text.
 
@@ -36,4 +40,4 @@ Images referenced from *unpublished* documents are untouched: they still resolve
 
 ## Testing
 
-`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales).
+`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a placed reference keeping its fragment, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `publishableImage` is covered directly: a regular file inside the repository resolves, while a symlink whose target escapes it, a path outside it, and a directory are all refused. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales).

+ 6 - 2
.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md

@@ -14,7 +14,11 @@ Status: implemented
 
 `rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。
 
-副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。两个来源若会投影到同一路径则抛错——与既有的重复路由检查同一个立场——而不是让最后拷贝的那个静默胜出。
+副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/` 与 `en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。一张表登记所有被投影的路径——页面与图片一视同仁——同一路径出现第二个来源就抛错,与既有的重复路由检查同一个立场,而不是让最后写入的那个静默胜出。
+
+只有真实路径位于仓库内的普通文件才会被拷贝,其余一律让投影失败并点名页面与目标。链接改写只需要知道目标**存在**,但发布是把它的字节拷上站点,因此一个逃出仓库的引用——经由 `../..` 或指向树外的符号链接——会把构建机上的文件放到已发布页面上。引用自带的 `?query` 或 `#fragment` 会随安置后的 URL 一同保留,与 GitHub 分支一贯的做法一致;文件名做百分号编码,因为目标位于 Markdown 内联目标的位置。
+
+`docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。
 
 `placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。
 
@@ -36,4 +40,4 @@ Status: implemented
 
 ## Testing
 
-`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。
+`scripts/project-doc-site.spec.ts` 覆盖:placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、被安置的引用保留其 fragment、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`publishableImage` 另有直接覆盖:仓库内的普通文件被接受,而目标逃出仓库的符号链接、仓库外的路径与目录一律拒绝。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。

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

@@ -46,6 +46,7 @@ Write normal repository-relative Markdown links in canonical docs. The projector
 
 - A target present in the manifest becomes a site-relative route.
 - An existing target outside the manifest becomes a GitHub source link, including supported line suffixes.
+- An image is the exception: its file is copied into the generated tree and referenced from there, so the site serves it regardless of repository visibility. It must be a regular file inside the repository.
 - External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged.
 - A missing repository-relative target fails projection instead of silently producing a broken link.
 

+ 2 - 2
docs/user/guide/providers.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 docs/user/guide/providers.md
-providers.md: d96cab0fa09583d81d98863169819fdd78d636e7
-providers.zh.md: d413fec2f9d703e31e82e50fcbe83b24bd58ee39
+providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe
+providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39

+ 10 - 7
docs/user/guide/providers.md

@@ -23,6 +23,8 @@ Start `pnpm run dsh web` and open **Settings → Models**.
 
 **Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe.
 
+That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires.
+
 **Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model.
 
 ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
@@ -93,18 +95,19 @@ References resolve from `$DSH_HOME/.env` — what the Models page's key fields w
 
 ## Point an agent at the new provider
 
-A configured route appears in the web model picker and can be switched at any time. To change the default, edit the `agent-loop` entry's `provider` and `model` in `cordis.yml`:
+A configured route appears in the web model picker and can be switched at any time, which is how most people use it.
+
+A new session's default model comes from the `api-gateway` entry (`@deepseek-ai/dsh-host-apiproxy`) and its `provider` and `model`, which ship as `deepseek-official` and `deepseek-v4-flash`. To change that default, override the entry in `$DSH_HOME/config.yaml`:
 
 ```yaml
-- id: agent-loop
-  name: '@deepseek-ai/dsh-agent-loop'
+- id: api-gateway
   config:
-    agents:
-      - id: main
-        provider: acme-gateway
-        model: acme-large
+    provider: acme-gateway
+    model: acme-large
 ```
 
+A patch replaces that entry's whole `config`, so write out every key it needs to keep. A composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead.
+
 ## Troubleshooting
 
 - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable.

+ 10 - 7
docs/user/guide/providers.zh.md

@@ -23,6 +23,8 @@ Harness 出厂就带 DeepSeek,同时挂着一个通用的多提供方适配器
 
 **添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个(anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。
 
+只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex:它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth,只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。
+
 **添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID(请求里点名它、也作为凭据名的小写标识)、API 地址、协议,以及至少一个模型。
 
 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
@@ -93,18 +95,19 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上
 
 ## 让 agent 用上新提供方
 
-配好的路由会出现在 Web 的模型选择器里,随时可切。要改默认值,就在 `cordis.yml` 里改 `agent-loop` 那条的 `provider` 与 `model`:
+配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。
+
+新会话的默认模型来自 `api-gateway` 那条(`@deepseek-ai/dsh-host-apiproxy`)的 `provider` 与 `model`,出厂值是 `deepseek-official` 与 `deepseek-v4-flash`。要改默认值,就在 `$DSH_HOME/config.yaml` 里覆盖该条:
 
 ```yaml
-- id: agent-loop
-  name: '@deepseek-ai/dsh-agent-loop'
+- id: api-gateway
   config:
-    agents:
-      - id: main
-        provider: acme-gateway
-        model: acme-large
+    provider: acme-gateway
+    model: acme-large
 ```
 
+补丁会整体替换该条的 `config`,所以要把这条需要保留的键一并写出。自行组装的 `cordis.yml`(例如 headless)改的则是 `agent-loop` 的 `agents`。
+
 ## 排错
 
 - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。

+ 51 - 5
scripts/project-doc-site.spec.ts

@@ -1,12 +1,14 @@
 /** Tests for the documentation website projection adapter. */
 
 import { execFileSync } from 'node:child_process'
-import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { docsPages, type DocsPage } from '../website/docs.ts'
-import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
+import {
+  addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown,
+} from './project-doc-site.ts'
 
 const roots: string[] = []
 const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -63,6 +65,32 @@ describe('website source layout', () => {
   })
 })
 
+describe('publishableImage', () => {
+  it('accepts a regular file inside the repository', () => {
+    const { root } = fixture()
+    const real = realpathSync(join(root, 'packages/logo.svg'))
+    expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real)
+  })
+
+  it('refuses a target whose real path escapes the repository', () => {
+    // Publication copies the bytes onto the site, so a reference reaching a
+    // build-machine file must not be treated as an image the repository owns.
+    const { root } = fixture()
+    const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-'))
+    roots.push(outside)
+    writeFileSync(join(outside, 'secret.png'), 'not really a png\n')
+    symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png'))
+
+    expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined()
+    expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined()
+  })
+
+  it('refuses a directory', () => {
+    const { root } = fixture()
+    expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined()
+  })
+})
+
 describe('rewriteMarkdown', () => {
   it('maps published pages and pins unpublished source links', () => {
     const { root, pages } = fixture()
@@ -107,7 +135,9 @@ describe('rewriteMarkdown', () => {
 
   it('hands an image to the placer and uses the URL it returns', () => {
     // A raw GitHub URL cannot serve a private repository, so the site build
-    // carries images itself; the placer is what puts them there.
+    // carries images itself; the placer is what puts them there. The stand-in
+    // derives its URL the way the real one does, so a placer that stopped
+    // returning the basename would fail here rather than pass on a constant.
     const { root, pages } = fixture()
     const placed: string[] = []
     expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
@@ -118,13 +148,29 @@ describe('rewriteMarkdown', () => {
       repoRoot: root,
       repositoryRef: 'abc123',
       placeImage: (absPath) => {
-        placed.push(absPath.split('/').pop() ?? '')
-        return './logo.svg'
+        const name = absPath.split('/').pop() ?? ''
+        placed.push(name)
+        return `./${name}`
       },
     })).toBe('![logo](./logo.svg)\n')
     expect(placed).toEqual(['logo.svg'])
   })
 
+  it('keeps a placed image\u2019s query or fragment', () => {
+    // An SVG view fragment and a Vite query both change what the reference
+    // means, and the GitHub branch has always carried them.
+    const { root, pages } = fixture()
+    expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', {
+      locale: 'en',
+      sourcePath: 'docs/a.md',
+      route: 'en/a.md',
+      pages,
+      repoRoot: root,
+      repositoryRef: 'abc123',
+      placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`,
+    })).toBe('![logo](./logo.svg#view)\n')
+  })
+
   it('leaves a published page link to the route even when a placer exists', () => {
     const { root, pages } = fixture()
     expect(rewriteMarkdown('[B](b.md)\n', {

+ 86 - 19
scripts/project-doc-site.ts

@@ -5,7 +5,9 @@
  * tier, while this adapter rewrites cross-source links for the public site.
  */
 
-import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import {
+  copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
+} from 'node:fs'
 import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'
 import { fromMarkdown } from 'mdast-util-from-markdown'
 import { gfmFromMarkdown } from 'mdast-util-gfm'
@@ -234,7 +236,9 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
     const nextUrl = page !== undefined
       ? routeTarget(options.route, page.route, suffix)
       : node.type === 'image' && options.placeImage !== undefined
-        ? options.placeImage(absPath)
+        // The suffix rides along exactly as the GitHub branch keeps it: an SVG
+        // view fragment or a Vite query changes what the reference means.
+        ? `${options.placeImage(absPath)}${suffix}`
         : githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
 
     const start = node.position?.start.offset
@@ -302,19 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string {
   return markdown.slice(0, closing + closingDelimiter.length)
 }
 
-/** Canonical Markdown files watched by the local VitePress dev server. */
+/**
+ * The repository file one image reference resolves to, or `undefined` when the
+ * target is not a local file this build may publish.
+ * @param absPath - resolved image target.
+ * @param repoRoot - repository root every published image must stay inside.
+ * @returns the file's real path, or `undefined` when it must not be copied.
+ *
+ * Only a regular file whose real path stays inside the repository qualifies.
+ * Publication copies the bytes into the site, so a reference escaping the
+ * repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree —
+ * would put a build-machine file on the site; `existsSync` alone, which is all
+ * link resolution needs, does not answer that.
+ */
+export function publishableImage(absPath: string, repoRoot: string): string | undefined {
+  const real = realpathSync(absPath)
+  const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`)
+  return inside && statSync(real).isFile() ? real : undefined
+}
+
+/** Every local image a published page references, resolved to its repository file. */
+function referencedImages(): string[] {
+  const found = new Set<string>()
+  for (const page of docsPages) {
+    const sourceAbs = resolve(root, page.source)
+    if (!existsSync(sourceAbs)) continue
+    rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), {
+      sourcePath: page.source,
+      locale: page.locale,
+      route: page.route,
+      pages: docsPages,
+      repoRoot: root,
+      repositoryRef: 'master',
+      placeImage: (absPath) => {
+        const real = publishableImage(absPath, root)
+        if (real !== undefined) found.add(real)
+        return ''
+      },
+    })
+  }
+  return [...found]
+}
+
+/**
+ * Files watched by the local VitePress dev server: every canonical Markdown
+ * source, plus the images they publish. Without the images, replacing a
+ * screenshot leaves the previous copy in the generated tree until something
+ * touches the Markdown beside it.
+ */
 export function docsSourceFiles(): string[] {
-  return [...new Set(docsPages.map(page => resolve(root, page.source)))]
+  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 {
   const routes = new Set<string>()
-  /** Projected asset path to the source it came from, for collision detection. */
-  const assets = new Map<string, string>()
+  /** Projected path to the repository file that claimed it, pages and images alike. */
+  const claimed = new Map<string, string>()
   const repositoryRef = process.env.GITHUB_SHA ?? 'master'
   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('/')}.`,
+      )
+    }
+    claimed.set(target, sourceAbs)
+  }
+
   for (const page of docsPages) {
     if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
     routes.add(page.route)
@@ -323,6 +386,9 @@ export function projectDocs(): void {
       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)
+    // 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)
     mkdirSync(dirname(output), { recursive: true })
     const markdown = readFileSync(sourceAbs, 'utf8')
     const projected = rewriteMarkdown(markdown, {
@@ -333,22 +399,23 @@ export function projectDocs(): void {
       repoRoot: root,
       repositoryRef,
       placeImage: (absPath) => {
-        // Beside the page that references it, under its own basename: each
-        // locale's route tree gets its own copy, so one relative URL is correct
-        // from both. Two sources that would land on one name are a collision
-        // rather than a silent overwrite of whichever copied last.
-        const name = basename(absPath)
-        const target = resolve(dirname(output), name)
-        const claimed = assets.get(target)
-        if (claimed !== undefined && claimed !== absPath) {
+        const real = publishableImage(absPath, root)
+        if (real === undefined) {
           throw new Error(
-            `project-doc-site: ${repoPath(absPath, root)} and ${repoPath(claimed, root)}`
-            + ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`,
+            `project-doc-site: ${page.source} references image ${repoPath(absPath, root)},`
+            + ' which is not a regular file inside the repository.',
           )
         }
-        assets.set(target, absPath)
-        copyFileSync(absPath, target)
-        return `./${name}`
+        // Beside the page that references it, under its own basename: each
+        // locale's route tree gets its own copy, so one relative URL is correct
+        // from both.
+        const name = basename(real)
+        const target = resolve(dirname(output), name)
+        claim(target, real)
+        copyFileSync(real, target)
+        // Encoded because the destination is a Markdown inline target, where an
+        // unescaped space would end it early.
+        return `./${encodeURI(name)}`
       },
     })
     writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))