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.
This commit is contained in:
Yichen Jiang
2026-08-06 21:14:39 +08:00
parent c826966181
commit a48b84c001
12 changed files with 178 additions and 50 deletions
@@ -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
@@ -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.
@@ -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,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
@@ -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).
@@ -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`)。
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))