Let a plugin declare its own screenshots, and start draining the shared file

The pictures already live in the author's repository. The list of them did not,
and that mismatch is the whole problem.

data/screenshots.json is one file every submission edits by hand, and it is not
sorted — it grew by appending, so there is no correct place for a new key and
everybody puts theirs at the top or the bottom. Two submissions adding entirely
unrelated plugins land on the same lines and collide. Of 32 pull requests that
were green and unmergeable, 21 were stuck on this one file and 17 on nothing
else. 108 open pull requests touch it right now.

This repository already solved this exact problem once. entries.mjs says so at
the top: "One file per entry, keyed by slug, so two submissions never touch the
same file — that is what removes the merge-conflict cascade the
README-as-database layout produced (every submission appended at the same
anchor)." The screenshots map is that layout, still standing.

So: a plugin declares `screenshots.json` beside its own package.json, listing
relative paths. Three shapes are accepted, because all three are what an author
would reach for — a bare array, `{"screenshots": [...]}`, and a map keyed by the
entry URL, which is what xiajiajun516/dsh-config-manager had already written
before any of this existed.

Relative paths are the point. An author who renames an image sees the break in
their own repository, where they can fix it. An absolute URL written into a file
over here can only rot silently, which is how 41 of 773 published screenshots
became 404s. It also means an author changes their screenshots by pushing to
their own repo — no pull request here, no maintainer in the loop.

The old file is a fallback with an end date, not a second home.
prune-legacy-screenshots.mjs removes a key once that repository declares its
own, so the file drains as authors migrate and can finally be deleted. It is
conservative: an unreachable repo, a malformed file or an empty list all leave
the legacy entry exactly where it is, because the alternative is deleting a
working screenshot on the strength of a failed request. Maintainer-run, since
it writes a committed file and the build holds `contents: read`.

Nothing changes for consumers. docs/plugins.json carries the same
`screenshots` field of resolved absolute URLs it always did; dsh-market cannot
tell which side a list came from. Nothing changes for entries that predate this
either — verified against all 279: 269 entries, 732 images, byte-identical
counts before and after.
This commit is contained in:
fkysly
2026-08-26 13:49:17 +08:00
parent b54a6baf95
commit f3abe301f0
6 changed files with 337 additions and 59 deletions
+1 -1
View File
@@ -12,4 +12,4 @@
- 📦 Publish to npm — npm installs are prebuilt and skip the `allowBuilds` approval, so users get a one-command install / 发布 npm 包:预构建产物免 `allowBuilds` 授权,用户一条命令装好
- 🔗 Declare official `@deepseek-ai/*` packages as `peerDependencies` (not `dependencies`) — avoids duplicate runtimes inside the profile / 官方 `@deepseek-ai/*` 包用 `peerDependencies` 声明(而非 `dependencies`),避免 profile 里出现重复运行时
- 🖼️ Add screenshots to [`data/screenshots.json`](../blob/main/data/screenshots.json) — they show AppStore-style in plugin markets ([how](../blob/main/contributing.md#screenshots--截图optional-recommended--可选推荐)) / 在 [`data/screenshots.json`](../blob/main/data/screenshots.json) 里附上截图——插件市场会像 App Store 一样展示([说明](../blob/main/contributing.md#screenshots--截图optional-recommended--可选推荐))
- 🖼️ Screenshots go in **your own repository** now: a `screenshots.json` beside your `package.json`, listing image paths. Nothing to add here, and you can change them later without another pull request ([how](../blob/main/contributing.md#screenshots--截图optional-recommended--可选推荐)) / 截图现在放在**你自己的仓库**里:在 `package.json` 旁边放一个 `screenshots.json`,列出图片路径。这个 PR 里不用加任何东西,以后想换也不必再提 PR([说明](../blob/main/contributing.md#screenshots--截图optional-recommended--可选推荐))
+1
View File
@@ -19,6 +19,7 @@ data/tarballs.json
# that list keys the cache, and extending it forces a cold start for every
# probe at once (#1673). This one costs nothing to rebuild — it hits
# raw.githubusercontent.com, not the API quota the gate depends on.
data/screenshots-declared.json
data/screenshots-live.json
.npm-cache/
+32 -11
View File
@@ -151,22 +151,43 @@ If a check fails it says exactly what to change. Push a fix to the same branch
### Screenshots / 截图(optional, recommended / 可选,推荐)
Storefronts (e.g. [dsh-market](https://github.com/dsh-market/dsh-market)'s detail view) show AppStore-style screenshots for your plugin. Add yours to [`data/screenshots.json`](data/screenshots.json), keyed by your entry's GitHub URL — the same URL as your README line — mapping to 1-8 image URLs:
Storefronts (e.g. [dsh-market](https://github.com/dsh-market/dsh-market)'s detail view) show AppStore-style screenshots for your plugin. **Declare them in your own repository**: add a `screenshots.json` next to your `package.json` (inside the subdirectory, for a monorepo entry), listing 1-8 image paths.
在插件市场(如 [dsh-market](https://github.com/dsh-market/dsh-market) 的详情页)中,你的插件可以像 App Store 一样展示截图。在 [`data/screenshots.json`](data/screenshots.json) 里以你条目的 GitHub URL(与 README 行完全一致)为 key,加入 1-8 张图片 URL:
在插件市场(如 [dsh-market](https://github.com/dsh-market/dsh-market) 的详情页)中,你的插件可以像 App Store 一样展示截图。**在你自己的仓库里声明**:在 `package.json` 旁边放一个 `screenshots.json`(monorepo 条目放在对应子目录里),列出 1-8 张图片路径。
```jsonc
{
"https://github.com/owner/repo": [
"https://raw.githubusercontent.com/owner/repo/main/assets/screenshot-1.png",
"https://raw.githubusercontent.com/owner/repo/main/assets/screenshot-2.png"
]
}
// <your repo>/screenshots.json
[
"assets/screenshot-1.png",
"assets/screenshot-2.png"
]
```
- Images must be **https URLs on GitHub hosting** (`raw.githubusercontent.com`, `user-images.githubusercontent.com`, `camo.githubusercontent.com`, `github.com` attachments) — third-party image hosts are rejected by the build for user-privacy reasons. / 图片必须是 **GitHub 托管的 https URL**(`raw.githubusercontent.com` 等)——出于用户隐私考虑,第三方图床会被构建校验拒绝。
- Keep the images in your own repo (an `assets/` folder works well) so they update with your releases. / 建议把图片放在你自己的仓库里(如 `assets/` 目录),随版本一起维护。
- No screenshots? Storefronts fall back to extracting images from your README — a maintained entry here just gives you control over order and selection. / 不提交也没关系:市场会从你的 README 自动抽取——这里的条目只是让你能控制展示的顺序与内容。
Paths are relative to that file, so they point at images already in your repository. `{"screenshots": [...]}` works too if you prefer a named field.
路径相对于该文件本身,指向你仓库里已有的图片。若偏好带字段名,`{"screenshots": [...]}` 同样可用。
**Why in your repo / 为什么放在你自己的仓库**
- You can update screenshots by pushing to your own repository — no pull request here, no waiting on a maintainer. The next nightly build picks them up. / 之后想换截图,推自己的仓库即可,不用再来提 PR、不用等维护者,下一次构建自动生效。
- A relative path breaks visibly in your own repository if you rename the file. An absolute URL written into a file over here can only rot silently — that is how 41 of the 773 published screenshots became 404s. / 相对路径在你自己仓库里改名就能立刻发现;写死在我们这边的绝对 URL 只会悄无声息地烂掉——已发布的 773 张截图里有 41 张就是这样变成 404 的。
- Nobody else edits your file, so screenshot submissions stop colliding with each other. / 没有别人会动你这个文件,截图投稿之间不会再互相冲突。
**Rules / 规则**
- 1-8 images. / 1 到 8 张。
- Absolute URLs are accepted as well, but must be **https on GitHub hosting** (`raw.githubusercontent.com`, `user-images.githubusercontent.com`, `camo.githubusercontent.com`, `github.com` attachments) — third-party image hosts are rejected for user-privacy reasons. / 也接受绝对 URL,但必须是 **GitHub 托管的 https 链接**——出于用户隐私考虑,第三方图床会被拒绝。
- Relative paths may not leave your plugin's directory (no leading `/`, no `..`). / 相对路径不能跳出插件目录(不能以 `/` 开头,不能含 `..`)。
- No screenshots? Storefronts fall back to extracting images from your README — declaring them just gives you control over order and selection. / 不声明也没关系:市场会从你的 README 自动抽取——声明只是让你能控制展示的顺序与内容。
<details>
<summary>Older entries: <code>data/screenshots.json</code> / 旧条目:<code>data/screenshots.json</code></summary>
Entries added before this convention still have their screenshots in [`data/screenshots.json`](data/screenshots.json) here, and those keep working — that file is read whenever a repository declares nothing. It is a fallback with an end date, not a second place to put things: once your repository declares its own `screenshots.json`, your key there is removed as redundant, and the file is deleted when it empties. **Please do not add new keys to it.**
早于这个约定的条目,截图仍然记在本仓库的 [`data/screenshots.json`](data/screenshots.json) 里,照常生效——仓库没有声明时就读它。它是一个有终点的回退,不是第二个存放地:一旦你的仓库自己声明了 `screenshots.json`,那边多余的键会被清理掉,等它空了这个文件就会删除。**请不要再往里面加新的键。**
</details>
### npm package / npm 包(optional / 可选)
+19
View File
@@ -236,6 +236,25 @@ const shotsMap = fs.existsSync(SCREENSHOTS_FILE) ? JSON.parse(fs.readFileSync(SC
// An entry whose shots all die loses the field entirely rather than shipping an
// empty array, which is the state every entry had before screenshots existed.
{
// The author's own repository wins. probe-screenshots.mjs reads
// `screenshots.json` from beside the plugin's package.json and resolves it to
// absolute URLs here; data/screenshots.json above is what every entry that
// predates the convention still uses. An author who adopts the file becomes
// the single source for their own entry — their key in the legacy file is
// then redundant and prune-legacy-screenshots.mjs removes it, so the old file
// drains rather than growing a second, competing copy of the same data.
const DECLARED_FILE = 'data/screenshots-declared.json'
const declaredMap = fs.existsSync(DECLARED_FILE) ? JSON.parse(fs.readFileSync(DECLARED_FILE, 'utf8')) : {}
let adopted = 0
for (const [key, list] of Object.entries(declaredMap)) {
if (!Array.isArray(list) || !list.length) continue
if (shotsMap[key] !== undefined) adopted++
shotsMap[key] = list
}
if (Object.keys(declaredMap).length) {
console.log(`screenshots: ${Object.keys(declaredMap).length} entry/entries declare their own (${adopted} superseding ${SCREENSHOTS_FILE})`)
}
const LIVE_FILE = 'data/screenshots-live.json'
const verdicts = fs.existsSync(LIVE_FILE) ? JSON.parse(fs.readFileSync(LIVE_FILE, 'utf8')) : {}
let dropped = 0
+170 -47
View File
@@ -1,59 +1,150 @@
#!/usr/bin/env node
/**
* Check that every image URL in data/screenshots.json still resolves, and
* record the verdict in data/screenshots-live.json for build-site.mjs.
* Resolve every entry's screenshots and check that the images still exist.
*
* Why this exists. build-site.mjs already validates screenshots, but only for
* shape and host: the key has to name a listed entry, the value has to be 1-8
* strings, and the host has to be GitHub's own so a list PR cannot plant a
* tracking pixel in every storefront user's browser. Nothing ever asks whether
* the image is there. So a URL that 404s passes the PR check, passes the gate,
* merges, and becomes a broken image in the plugin market — silently, because
* a missing picture fails no test.
* Two sources, in this order:
*
* That is not hypothetical. biggerboy/dsh-conversation-anchors landed with
* two screenshots pointing at assets/anchors-hover.png and assets/anchors-wave.png;
* the repository ships assets/anchors-wave1.png and assets/image.png. Both URLs
* were dead the day they merged and every build since has published them.
* 1. `screenshots.json` in the plugin's own repository, beside its
* package.json (inside the subdirectory for a monorepo entry). Optional.
* This is the convention going forward: the pictures already live in the
* author's repository, so the list of them belongs there too. An author
* who adds a screenshot pushes to their own repo and the next build picks
* it up — no pull request here, no waiting on a maintainer.
*
* Same discipline as probe-tarballs.mjs, for the same reason: a dead URL must
* degrade the site, not block anyone. A screenshot that 404s is dropped from
* the published data rather than failing a build, and an entry whose shots all
* die simply has no screenshots — which is the state every entry was in before
* the field existed. Only 404/410 count as dead; a 5xx or a throttle means we
* did not get to look, and "could not check" must never be recorded as "gone".
* 2. data/screenshots.json in this repository — every entry that predates the
* convention. It is a fallback, not a second home: once an author declares
* the file in their own repo, their key here is redundant and
* prune-legacy-screenshots.mjs removes it. The file drains as authors
* migrate and can eventually be deleted.
*
* Relative paths are the point of the first source. `assets/shot.png` is
* resolved against the author's repo at HEAD, so an author who renames the file
* sees the break in their own repository, where they can fix it. The old shape
* — an absolute raw.githubusercontent URL written into a file over here — could
* only rot silently, which is how 41 of 773 published images came to be 404s.
*
* Absolute URLs are still accepted (some authors point at release assets), and
* carry the same host allow-list build-site.mjs enforces: GitHub's own hosting
* only, so a screenshot cannot become a tracking pixel in every storefront
* user's browser.
*
* Liveness is checked for both sources and recorded in data/screenshots-live.json.
* Same discipline as probe-tarballs.mjs: only 404/410 count as dead, an absent
* verdict means live, and nothing here blocks a build — a dead image is dropped
* from the published data, never turned into someone's failure.
*
* Usage: node scripts/probe-screenshots.mjs [--strict]
* --strict exit 1 if any screenshot is dead (for a manual audit; CI does not
* use it, since a dead image must degrade the site, not block it)
*/
import fs from 'node:fs'
import { readEntries } from './lib/entries.mjs'
const SHOTS_FILE = 'data/screenshots.json'
const OUT_FILE = 'data/screenshots-live.json'
const LEGACY_FILE = 'data/screenshots.json'
const DECLARED_FILE = 'data/screenshots-declared.json'
const LIVE_FILE = 'data/screenshots-live.json'
const CONCURRENCY = 8
const STRICT = process.argv.includes('--strict')
const today = new Date().toISOString().slice(0, 10)
// Mirrors build-site.mjs::SCREENSHOT_HOSTS. Kept in step deliberately: an
// author-declared absolute URL has to clear the same bar a maintainer-declared
// one does, or the convention becomes a way around the rule.
const HOSTS = new Set(['raw.githubusercontent.com', 'user-images.githubusercontent.com', 'camo.githubusercontent.com', 'github.com'])
const MAX_SHOTS = 8
if (!fs.existsSync(SHOTS_FILE)) {
console.log(`${SHOTS_FILE} does not exist — nothing to probe`)
process.exit(0)
const today = new Date().toISOString().slice(0, 10)
const entries = readEntries()
/** owner/repo plus the subdirectory a monorepo entry points into. */
function decompose(url) {
const repoPath = url.replace('https://github.com/', '').replace(/\/$/, '')
return {
repo: repoPath.split('/').slice(0, 2).join('/'),
sub: repoPath.includes('/tree/') ? repoPath.split('/tree/')[1].replace(/^[^/]+\//, '') : null,
}
}
const shots = JSON.parse(fs.readFileSync(SHOTS_FILE, 'utf8'))
const urls = [...new Set(Object.values(shots).flat().filter((s) => typeof s === 'string'))]
if (!urls.length) {
console.log('no screenshots declared — nothing to probe')
process.exit(0)
async function fetchText(url) {
const r = await fetch(url, {
headers: { 'user-agent': 'awesome-dsh-plugin-screenshot-probe' },
signal: AbortSignal.timeout(15000),
})
if (!r.ok) return null
return r.text()
}
/**
* Resolve an image without downloading it. A ranged GET rather than HEAD, for
* the reason probe-tarballs.mjs gives: raw.githubusercontent.com and the camo
* proxy both answer HEAD inconsistently, while `Range: bytes=0-0` comes back
* 206 for a live object and 404 for a missing one, at the cost of a byte.
* Read and validate the author's own screenshots.json.
*
* Returns null for "no declaration" — a missing file, unreachable repo, or
* unparseable JSON all mean the same thing to the caller: fall back. A malformed
* declaration is reported so the author can be told, but it never promotes to an
* error: their entry keeps whatever the legacy file holds.
*/
async function declared(entry) {
const { repo, sub } = decompose(entry.url)
const base = `https://raw.githubusercontent.com/${repo}/HEAD/${sub ? sub + '/' : ''}`
let text
try {
text = await fetchText(`${base}screenshots.json`)
} catch {
return null
}
if (text === null) return null
let doc
try {
doc = JSON.parse(text)
} catch (e) {
return { problem: `screenshots.json is not valid JSON (${e.message})` }
}
// Three shapes, because all three are the obvious thing to write and none is
// ambiguous. The third is the one an author reaches for by copying the map
// out of this repository's data/screenshots.json — keyed by their own entry
// URL — which is exactly what xiajiajun516/dsh-config-manager had already
// done before this convention existed. Refusing that would be pedantry.
const list = Array.isArray(doc)
? doc
: Array.isArray(doc?.screenshots)
? doc.screenshots
: Array.isArray(doc?.[entry.url])
? doc[entry.url]
: Object.keys(doc ?? {}).length === 1 && Array.isArray(Object.values(doc)[0])
? Object.values(doc)[0]
: null
if (list === null) {
return { problem: 'screenshots.json must be an array of paths, {"screenshots": [...]}, or a single-key map of entry URL to paths' }
}
if (!list.length) return { problem: 'screenshots.json declares no images' }
if (list.length > MAX_SHOTS) return { problem: `screenshots.json declares ${list.length} images; the cap is ${MAX_SHOTS}` }
if (list.some((s) => typeof s !== 'string' || !s.trim())) return { problem: 'every entry in screenshots.json must be a non-empty string' }
const shots = []
for (const raw of list) {
const s = raw.trim()
if (/^https?:\/\//i.test(s)) {
let parsed = null
try { parsed = new URL(s) } catch { /* reported below */ }
if (parsed === null || parsed.protocol !== 'https:' || !HOSTS.has(parsed.hostname)) {
return { problem: `absolute image URLs must be https on GitHub hosting (${[...HOSTS].join(' / ')}), got: ${s}` }
}
shots.push(s)
continue
}
// A relative path may not climb out of the plugin's own directory: an entry
// is responsible for its own subtree and nothing above it.
if (s.startsWith('/') || s.split('/').includes('..')) {
return { problem: `relative paths must stay inside the plugin directory, got: ${s}` }
}
shots.push(base + s.split('/').map(encodeURIComponent).join('/'))
}
return { shots }
}
/**
* Resolve an image without downloading it — a ranged GET for the reason
* probe-tarballs.mjs gives: GitHub's hosting answers HEAD inconsistently, while
* `Range: bytes=0-0` is 206 for a live object and 404 for a missing one.
*/
async function probe(url) {
try {
@@ -71,11 +162,42 @@ async function probe(url) {
}
}
const previous = fs.existsSync(OUT_FILE) ? JSON.parse(fs.readFileSync(OUT_FILE, 'utf8')) : {}
const out = {}
const dead = []
const unknown = []
// ── 1. collect author declarations ───────────────────────────────────────────
const declaredMap = {}
const malformed = []
for (let i = 0; i < entries.length; i += CONCURRENCY) {
const batch = entries.slice(i, i + CONCURRENCY)
const results = await Promise.all(batch.map(async (e) => [e, await declared(e)]))
for (const [e, d] of results) {
if (d === null) continue
if (d.problem) { malformed.push(`${e.url} — ${d.problem}`); continue }
declaredMap[e.url] = d.shots
}
}
const sortedDeclared = Object.fromEntries(Object.entries(declaredMap).sort(([a], [b]) => a.localeCompare(b)))
fs.writeFileSync(DECLARED_FILE, JSON.stringify(sortedDeclared, null, 1) + '\n')
const legacy = fs.existsSync(LEGACY_FILE) ? JSON.parse(fs.readFileSync(LEGACY_FILE, 'utf8')) : {}
const legacyOnly = Object.keys(legacy).filter((k) => declaredMap[k] === undefined)
console.log(
`screenshots: ${Object.keys(declaredMap).length} entries declare their own, ` +
`${legacyOnly.length} still come from ${LEGACY_FILE}, ` +
`${Object.keys(legacy).length - legacyOnly.length} superseded`,
)
if (malformed.length) {
console.log('repositories whose screenshots.json could not be used (their legacy entry, if any, still applies):')
for (const m of malformed) console.log(` ${m}`)
}
// ── 2. liveness over the union ───────────────────────────────────────────────
const urls = [...new Set([
...Object.values(declaredMap).flat(),
...legacyOnly.flatMap((k) => (Array.isArray(legacy[k]) ? legacy[k] : [])),
].filter((s) => typeof s === 'string'))]
const previous = fs.existsSync(LIVE_FILE) ? JSON.parse(fs.readFileSync(LIVE_FILE, 'utf8')) : {}
const out = {}
const unknown = []
for (let i = 0; i < urls.length; i += CONCURRENCY) {
const batch = urls.slice(i, i + CONCURRENCY)
const results = await Promise.all(batch.map(async (u) => [u, await probe(u)]))
@@ -92,17 +214,18 @@ for (let i = 0; i < urls.length; i += CONCURRENCY) {
}
}
for (const [key, list] of Object.entries(shots)) {
const gone = (Array.isArray(list) ? list : []).filter((u) => out[u]?.ok === false)
const sortedLive = Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)))
fs.writeFileSync(LIVE_FILE, JSON.stringify(sortedLive, null, 1) + '\n')
const dead = []
for (const [key, list] of Object.entries({ ...Object.fromEntries(legacyOnly.map((k) => [k, legacy[k]])), ...declaredMap })) {
const gone = (Array.isArray(list) ? list : []).filter((u) => sortedLive[u]?.ok === false)
if (gone.length) dead.push(`${key}\n ${gone.join('\n ')}`)
}
const sorted = Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)))
fs.writeFileSync(OUT_FILE, JSON.stringify(sorted, null, 1) + '\n')
const live = Object.values(sorted).filter((v) => v.ok).length
const deadCount = Object.values(sorted).filter((v) => v.ok === false).length
console.log(`screenshots: ${urls.length} declared, ${live} live, ${deadCount} dead, ${unknown.length} unchecked`)
const live = Object.values(sortedLive).filter((v) => v.ok).length
const deadCount = Object.values(sortedLive).filter((v) => v.ok === false).length
console.log(`screenshots: ${urls.length} images, ${live} live, ${deadCount} dead, ${unknown.length} unchecked`)
if (dead.length) {
console.log('dead screenshots (dropped from the published data; the entry keeps its other shots):')
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* Remove keys from data/screenshots.json for entries whose repository now
* declares its own `screenshots.json`.
*
* The legacy file is a fallback with an end date, not a second home. Once an
* author adopts the convention, keeping their key here means two copies of the
* same list that can disagree, and it keeps the shared file — the one every
* screenshot pull request collides on — alive for no reason. Pruning as authors
* migrate is what lets the file drain to nothing and finally be deleted.
*
* Conservative on purpose. A key is removed only when the author's declaration
* is present, parses, and yields at least one image. A repository that is
* unreachable, a file that is malformed, an empty list — all leave the legacy
* entry exactly where it is, because the alternative is deleting a working
* screenshot on the strength of a failed request.
*
* Maintainer-run, not CI: this writes a committed file, and the build workflow
* holds `contents: read` so that nothing in it can race a human push.
*
* Usage: node scripts/prune-legacy-screenshots.mjs [--dry-run]
*/
import fs from 'node:fs'
const LEGACY_FILE = 'data/screenshots.json'
const CONCURRENCY = 8
const DRY = process.argv.includes('--dry-run')
const HOSTS = new Set(['raw.githubusercontent.com', 'user-images.githubusercontent.com', 'camo.githubusercontent.com', 'github.com'])
const MAX_SHOTS = 8
if (!fs.existsSync(LEGACY_FILE)) {
console.log(`${LEGACY_FILE} does not exist — nothing to prune`)
process.exit(0)
}
const legacy = JSON.parse(fs.readFileSync(LEGACY_FILE, 'utf8'))
const keys = Object.keys(legacy)
if (!keys.length) {
console.log(`${LEGACY_FILE} is empty — it can be deleted`)
process.exit(0)
}
function decompose(url) {
const repoPath = url.replace('https://github.com/', '').replace(/\/$/, '')
return {
repo: repoPath.split('/').slice(0, 2).join('/'),
sub: repoPath.includes('/tree/') ? repoPath.split('/tree/')[1].replace(/^[^/]+\//, '') : null,
}
}
/** Same acceptance rules as probe-screenshots.mjs — a key is only pruned if the
* declaration would actually be used. */
function usable(text, url) {
let doc
try { doc = JSON.parse(text) } catch { return false }
const list = Array.isArray(doc)
? doc
: Array.isArray(doc?.screenshots)
? doc.screenshots
: Array.isArray(doc?.[url])
? doc[url]
: Object.keys(doc ?? {}).length === 1 && Array.isArray(Object.values(doc)[0])
? Object.values(doc)[0]
: null
if (!Array.isArray(list) || !list.length || list.length > MAX_SHOTS) return false
if (list.some((s) => typeof s !== 'string' || !s.trim())) return false
for (const raw of list) {
const s = raw.trim()
if (/^https?:\/\//i.test(s)) {
let p = null
try { p = new URL(s) } catch { return false }
if (p.protocol !== 'https:' || !HOSTS.has(p.hostname)) return false
} else if (s.startsWith('/') || s.split('/').includes('..')) return false
}
return true
}
async function adopted(url) {
const { repo, sub } = decompose(url)
const at = `https://raw.githubusercontent.com/${repo}/HEAD/${sub ? sub + '/' : ''}screenshots.json`
try {
const r = await fetch(at, { headers: { 'user-agent': 'awesome-dsh-plugin-screenshot-prune' }, signal: AbortSignal.timeout(15000) })
if (!r.ok) return false
return usable(await r.text(), url)
} catch {
return false
}
}
const prune = []
for (let i = 0; i < keys.length; i += CONCURRENCY) {
const batch = keys.slice(i, i + CONCURRENCY)
const results = await Promise.all(batch.map(async (k) => [k, await adopted(k)]))
for (const [k, yes] of results) if (yes) prune.push(k)
}
console.log(`${keys.length} legacy key(s), ${prune.length} now declared by their own repository`)
for (const k of prune) console.log(` ${k}`)
if (!prune.length) process.exit(0)
if (DRY) {
console.log('--dry-run: nothing written')
process.exit(0)
}
// Key order is preserved rather than re-sorted. The file matches no sort — it
// grew by appending — and re-sorting it would rewrite hundreds of lines nobody
// touched, poisoning the diff signal maint-rebase.sh relies on to spot
// cross-entry damage.
const out = {}
for (const [k, v] of Object.entries(legacy)) if (!prune.includes(k)) out[k] = v
fs.writeFileSync(LEGACY_FILE, JSON.stringify(out, null, 1) + '\n')
console.log(`${LEGACY_FILE}: ${keys.length} → ${Object.keys(out).length} key(s)`)
if (!Object.keys(out).length) console.log('the legacy file is now empty and can be removed along with its fallback in build-site.mjs')