mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
+2
-2
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
@@ -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.
|
||||
|
||||
@@ -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 地址、协议,以及至少一个模型。
|
||||
|
||||

|
||||
@@ -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` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。
|
||||
|
||||
@@ -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('\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('\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('\n', {
|
||||
locale: 'en',
|
||||
sourcePath: 'docs/a.md',
|
||||
route: 'en/a.md',
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`,
|
||||
})).toBe('\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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user