From a48b84c001c885b2dc209bbcef2f6b87a03cc7c4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 21:14:39 +0800 Subject: [PATCH] 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. --- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...8-06-doc-site-carries-its-images.i18n.yaml | 4 +- .../2026-08-06-doc-site-carries-its-images.md | 8 +- ...26-08-06-doc-site-carries-its-images.zh.md | 8 +- .agents/skills/dsh-doc-site-sync/SKILL.md | 1 + docs/user/guide/providers.i18n.yaml | 4 +- docs/user/guide/providers.md | 17 +-- docs/user/guide/providers.zh.md | 17 +-- scripts/project-doc-site.spec.ts | 56 +++++++++- scripts/project-doc-site.ts | 105 ++++++++++++++---- 12 files changed, 178 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 74ded5f605..7fa4d3fbba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 2452c9dfa5..f19d9b309a 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.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. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 6f1c79ac50..cc5e94e709 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.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。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 75018c8374..32b51699e2 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 21593c2cad..9109808874 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.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 `./`; 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 `./` 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 `./` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales). diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index 54aee878a9..d601112e88 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -14,7 +14,11 @@ Status: implemented `rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./`;随后 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` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./` 引用已在 `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` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。 diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 4d88d3f04f..3f93a6560a 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.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. diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 324bcfb5c3..665eae8457 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/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 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index d96cab0fa0..66b6cf25c6 100644 --- a/docs/user/guide/providers.md +++ b/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. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index d413fec2f9..a2c33c90be 100644 --- a/docs/user/guide/providers.zh.md +++ b/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` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index c6402d7fc8..6770381526 100644 --- a/scripts/project-doc-site.spec.ts +++ b/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', { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index ef821bd00e..02a64b023a 100644 --- a/scripts/project-doc-site.ts +++ b/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() + 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() - /** Projected asset path to the source it came from, for collision detection. */ - const assets = new Map() + /** Projected path to the repository file that claimed it, pages and images alike. */ + const claimed = new Map() 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))