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

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', {