diff --git a/README.md b/README.md index d5f1daa..c7fe845 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,10 @@ bun run scripts/docs.ts boot 3.5.16 # Require a cache hit (offline), or force a re-download bun run scripts/docs.ts boot 3.5.16 --no-fetch bun run scripts/docs.ts boot 3.5.16 --refresh + +# Which projects and versions are published at all? +bun run scripts/docs.ts --list +bun run scripts/docs.ts --list framework ``` ```json @@ -133,15 +137,25 @@ Each unpacked tree carries the `manifest.json` from its release: upstream reposi ## Coverage -| Project | Versions | Source | -|---|---|---| -| `boot` | Spring Boot `3.3.0`-`3.x`, `4.0.8`+ | [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases | +Two project keys resolve today, both served from [`pleaseai/spring-docs`](https://github.com/pleaseai/spring-docs) releases: + +- `boot` — Spring Boot reference +- `framework` — Spring Framework reference + +Which versions each key resolves is the catalog's answer, not this file's — the docs repository publishes on its own schedule, and a list written here would be stale the first time it does: + +```bash +node skills/spring-docs/scripts/docs.mjs --list # every project +node skills/spring-docs/scripts/docs.mjs --list boot # just one +``` Not buildable upstream, and therefore absent: Boot 3.2 and older predate the Antora documentation component, and 4.0.0-4.0.7 publish no content archive. Pre-release versions (M, RC, SNAPSHOT) are out of scope. Spring Boot 3.x trees omit the generated appendix — auto-configuration class listings and configuration-property tables are a Gradle build output upstream never publishes. The prose corpus (reference, how-to, tutorial, specification) is complete. -Framework, Security, Data and Cloud are not published yet. When they are, resolving them is the same call with a different project key; BOM-based resolution of one declared Boot version into the whole component matrix belongs to that point, not before it. +Security, Data and Cloud are not published yet. When they are, resolving them is the same call with a different project key. + +Version *detection* remains Boot-only, because Boot is the only component a build file declares — Framework arrives as a transitive dependency and appears in no `build.gradle` or `pom.xml`. Resolving one declared Boot version into the whole component matrix through its BOM is the thing that would close that gap, and it is not built yet. ## Plugin structure diff --git a/scripts/__tests__/docs-cache.test.ts b/scripts/__tests__/docs-cache.test.ts index d3b5bea..ce22977 100644 --- a/scripts/__tests__/docs-cache.test.ts +++ b/scripts/__tests__/docs-cache.test.ts @@ -9,6 +9,7 @@ import { isSafeSegment, lookupTag, parseChecksum, + summarizeCatalog, } from '../lib/docs-cache.ts' const CATALOG = { @@ -156,3 +157,77 @@ describe('isCatalog', () => { expect(isCatalog({ version: '1', projects: { boot: { '3.5.16': { tag: 'boot-3.5.16' } } } })).toBe(false) }) }) + +describe('summarizeCatalog', () => { + const MULTI = { + version: '1', + generated_at: '2026-09-15T13:47:09.470Z', + projects: { + framework: { + '6.2.0': { tag: 'framework-6.2.0', released_at: '2026-01-01T00:00:00Z' }, + }, + boot: { + '3.5.9': { tag: 'boot-3.5.9', released_at: '2026-05-01T00:00:00Z' }, + '3.5.10': { tag: 'boot-3.5.10', released_at: '2026-06-01T00:00:00Z' }, + '4.1.1': { tag: 'boot-4.1.1', released_at: null }, + }, + }, + } + + test('reports every project, with reserved tags kept out of the published list', () => { + expect(summarizeCatalog(MULTI)).toEqual({ + kind: 'coverage', + projects: [ + { project: 'boot', published: ['3.5.9', '3.5.10'], unpublished: ['4.1.1'] }, + { project: 'framework', published: ['6.2.0'], unpublished: [] }, + ], + }) + }) + + test('orders versions numerically, so 3.5.10 follows 3.5.9 instead of preceding it', () => { + const summary = summarizeCatalog(MULTI, 'boot') + expect(summary).toMatchObject({ kind: 'coverage' }) + expect(summary.kind === 'coverage' && summary.projects[0]?.published).toEqual(['3.5.9', '3.5.10']) + }) + + test('narrows to one project when asked', () => { + expect(summarizeCatalog(MULTI, 'framework')).toEqual({ + kind: 'coverage', + projects: [{ project: 'framework', published: ['6.2.0'], unpublished: [] }], + }) + }) + + test('names the known projects when the requested one is absent', () => { + expect(summarizeCatalog(MULTI, 'security')).toEqual({ + kind: 'unknown-project', + project: 'security', + known: ['boot', 'framework'], + }) + }) + + test('orders a shorter version before the longer one it prefixes', () => { + const catalog = { + ...MULTI, + projects: { + boot: { + '4.0': { tag: 'boot-4.0', released_at: '2026-01-01T00:00:00Z' }, + '4.0.8': { tag: 'boot-4.0.8', released_at: '2026-01-01T00:00:00Z' }, + '4': { tag: 'boot-4', released_at: '2026-01-01T00:00:00Z' }, + }, + }, + } + const summary = summarizeCatalog(catalog, 'boot') + expect(summary.kind === 'coverage' && summary.projects[0]?.published).toEqual(['4', '4.0', '4.0.8']) + }) + + test('refuses a catalog schema it does not understand, as lookupTag does', () => { + expect(summarizeCatalog({ ...MULTI, version: '2' })).toEqual({ kind: 'schema', found: '2' }) + }) + + test('reports a project that publishes nothing as empty rather than absent', () => { + expect(summarizeCatalog({ ...MULTI, projects: { boot: {} } })).toEqual({ + kind: 'coverage', + projects: [{ project: 'boot', published: [], unpublished: [] }], + }) + }) +}) diff --git a/scripts/__tests__/docs.test.ts b/scripts/__tests__/docs.test.ts index b37bc98..a331c63 100644 --- a/scripts/__tests__/docs.test.ts +++ b/scripts/__tests__/docs.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { resolveDocs } from '../docs.ts' +import { listDocs, parseArgs, resolveDocs } from '../docs.ts' import { archiveName, archiveUrl, CATALOG_URL, checksumUrl, DOCS_CACHE_SUBDIR, docsCachePath } from '../lib/docs-cache.ts' const PROJECT = 'boot' @@ -586,3 +586,115 @@ describe('resolveDocs', () => { expect(readFileSync(index, 'utf8')).toBe('# First\n') }) }) + +describe('listDocs', () => { + const COVERAGE_CATALOG = JSON.stringify({ + version: '1', + generated_at: '2026-09-15T13:47:09.470Z', + projects: { + boot: { + '3.5.16': { tag: 'boot-3.5.16', released_at: '2026-09-12T12:36:20Z' }, + '4.1.1': { tag: 'boot-4.1.1', released_at: null }, + }, + framework: { '7.0.9': { tag: 'framework-7.0.9', released_at: '2026-09-15T13:43:47Z' } }, + }, + }) + + function serving(body: string, ok = true, status = 200): { fetchImpl: Fetcher, requested: string[] } { + const requested: string[] = [] + const fetchImpl: Fetcher = async (url) => { + requested.push(url) + return respond(body, ok, status) + } + return { fetchImpl, requested } + } + + test('reports every project the catalog publishes, reading only the catalog', async () => { + const { fetchImpl, requested } = serving(COVERAGE_CATALOG) + const result = await listDocs({ fetchImpl }) + + expect(result).toEqual({ + kind: 'coverage', + generatedAt: '2026-09-15T13:47:09.470Z', + projects: [ + { project: 'boot', published: ['3.5.16'], unpublished: ['4.1.1'] }, + { project: 'framework', published: ['7.0.9'], unpublished: [] }, + ], + }) + // No archive and no checksum: a coverage question must never cost a download. + expect(requested).toEqual([CATALOG_URL]) + }) + + test('narrows to one project', async () => { + const { fetchImpl } = serving(COVERAGE_CATALOG) + const result = await listDocs({ fetchImpl, project: 'framework' }) + expect(result).toMatchObject({ kind: 'coverage', projects: [{ project: 'framework' }] }) + }) + + test('names the known projects for one the docs repo does not publish', async () => { + const { fetchImpl } = serving(COVERAGE_CATALOG) + const result = await listDocs({ fetchImpl, project: 'security' }) + expect(result).toEqual({ + kind: 'unavailable', + reason: 'pleaseai/spring-docs publishes no project "security"', + suggestion: 'known projects: boot, framework', + }) + }) + + test('reports an unreachable catalog rather than throwing', async () => { + const { fetchImpl } = serving('not found', false, 404) + const result = await listDocs({ fetchImpl }) + expect(result).toMatchObject({ + kind: 'unavailable', + suggestion: 'check network access to raw.githubusercontent.com', + }) + }) + + test('reports a catalog whose shape it does not recognize', async () => { + const { fetchImpl } = serving(JSON.stringify({ version: '1', projects: [] })) + expect(await listDocs({ fetchImpl })).toEqual({ + kind: 'unavailable', + reason: 'catalog.json does not have the expected shape', + suggestion: 'update the plugin', + }) + }) + + test('reports a catalog schema newer than this plugin', async () => { + const { fetchImpl } = serving(JSON.stringify({ version: '2', generated_at: null, projects: {} })) + expect(await listDocs({ fetchImpl })).toEqual({ + kind: 'unavailable', + reason: 'catalog.json is schema version 2, this plugin understands 1', + suggestion: 'update the plugin', + }) + }) +}) + +describe('parseArgs', () => { + test('reads a resolution', () => { + expect(parseArgs(['boot', '4.1.1', '--refresh'])).toEqual({ + mode: 'resolve', + project: 'boot', + version: '4.1.1', + refresh: true, + noFetch: false, + }) + }) + + test('reads a listing, with and without a project', () => { + expect(parseArgs(['--list'])).toEqual({ mode: 'list' }) + expect(parseArgs(['--list', 'framework'])).toEqual({ mode: 'list', project: 'framework' }) + }) + + test('rejects cache flags on a listing, which reads the catalog fresh either way', () => { + expect(parseArgs(['--list', '--no-fetch'])).toEqual({ error: '--list takes no --refresh or --no-fetch' }) + expect(parseArgs(['--list', '--refresh'])).toEqual({ error: '--list takes no --refresh or --no-fetch' }) + }) + + test('rejects a second positional after --list, rather than silently ignoring it', () => { + expect(parseArgs(['--list', 'boot', '4.1.1'])).toEqual({ error: 'unexpected argument: 4.1.1' }) + }) + + test('still requires a version when resolving', () => { + expect(parseArgs(['boot'])).toEqual({ error: 'missing ' }) + }) +}) diff --git a/scripts/docs.ts b/scripts/docs.ts index 2aa57d3..1333a1e 100644 --- a/scripts/docs.ts +++ b/scripts/docs.ts @@ -14,14 +14,15 @@ * * Usage: * node scripts/docs.ts boot 4.1.1 [--refresh] [--no-fetch] + * node scripts/docs.ts --list [project] * * Exit codes: - * 0 — docs are on disk; `path` in the JSON output says where - * 1 — that version is not published, or it could not be fetched + * 0 — docs are on disk (`path` says where), or coverage was listed + * 1 — that version is not published, or the catalog could not be fetched * 2 — bad arguments, or an unexpected internal error */ -import type { Catalog } from './lib/docs-cache.ts' +import type { Catalog, ProjectCoverage } from './lib/docs-cache.ts' import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' @@ -40,6 +41,7 @@ import { isSafeSegment, lookupTag, parseChecksum, + summarizeCatalog, } from './lib/docs-cache.ts' /** Test-only override for the cache home directory, as in `detect.ts`. */ @@ -156,6 +158,36 @@ async function fetchText(fetchImpl: Fetcher, url: string): Promise { + const text = await fetchText(fetchImpl, CATALOG_URL) + if (typeof text !== 'string') + return { reason: text.error, suggestion: 'check network access to raw.githubusercontent.com' } + + let parsed: unknown + try { + parsed = JSON.parse(text) + } + catch (err) { + return { reason: `catalog.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` } + } + if (!isCatalog(parsed)) + return { reason: 'catalog.json does not have the expected shape', suggestion: 'update the plugin' } + return parsed +} + /** True when `path` holds a documentation tree a caller can actually read from. */ function isUsableTree(path: string): boolean { try { @@ -561,20 +593,10 @@ export async function resolveDocs(options: ResolveOptions): Promise { + const fetchImpl = options.fetchImpl ?? ((url: string) => fetch(url)) + const { project } = options + + const fetched = await fetchCatalog(fetchImpl) + if (!('projects' in fetched)) + return { kind: 'unavailable', ...fetched } + + const summary = summarizeCatalog(fetched, project) + switch (summary.kind) { + case 'schema': + return { + kind: 'unavailable', + reason: `catalog.json is schema version ${summary.found}, this plugin understands 1`, + suggestion: 'update the plugin', + } + case 'unknown-project': + return { + kind: 'unavailable', + reason: `${DOCS_REPO} publishes no project "${summary.project}"`, + suggestion: `known projects: ${summary.known.join(', ') || 'none'}`, + } + } + + return { kind: 'coverage', generatedAt: fetched.generated_at, projects: summary.projects } } +// ------------------------------ CLI ----------------------------------------- + +const USAGE = [ + 'usage: bun run scripts/docs.ts [--refresh] [--no-fetch]', + ' bun run scripts/docs.ts --list [project]', +].join('\n') + +export type ParsedArgs + = | { mode: 'resolve', project: string, version: string, refresh: boolean, noFetch: boolean } + | { mode: 'list', project?: string } + export function parseArgs(argv: string[]): ParsedArgs | { error: string } { const positional: string[] = [] let refresh = false let noFetch = false + let list = false for (const arg of argv) { if (arg === '--refresh') refresh = true else if (arg === '--no-fetch') noFetch = true + else if (arg === '--list') + list = true else if (arg.startsWith('--')) return { error: `unknown argument: ${arg}` } else positional.push(arg) } + + if (list) { + // Neither flag has anything to act on: the catalog is read fresh every + // time and never cached, so accepting them would promise behaviour that + // does not exist. + if (refresh || noFetch) + return { error: '--list takes no --refresh or --no-fetch' } + const [project, ...extra] = positional + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` } + return project === undefined ? { mode: 'list' } : { mode: 'list', project } + } + const [project, version, ...extra] = positional if (!project) return { error: 'missing ' } @@ -706,7 +797,7 @@ export function parseArgs(argv: string[]): ParsedArgs | { error: string } { return { error: 'missing ' } if (extra.length > 0) return { error: `unexpected argument: ${extra[0]}` } - return { project, version, refresh, noFetch } + return { mode: 'resolve', project, version, refresh, noFetch } } async function cli(argv: string[]): Promise { @@ -716,9 +807,9 @@ async function cli(argv: string[]): Promise { return 2 } - let result: ResolveResult + let result: ResolveResult | ListResult try { - result = await resolveDocs(parsed) + result = parsed.mode === 'list' ? await listDocs(parsed) : await resolveDocs(parsed) } catch (err) { process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) @@ -726,7 +817,7 @@ async function cli(argv: string[]): Promise { } process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) - return result.kind === 'ready' ? 0 : 1 + return result.kind === 'unavailable' ? 1 : 0 } if (import.meta.main) { diff --git a/scripts/lib/docs-cache.ts b/scripts/lib/docs-cache.ts index 64d2915..60312be 100644 --- a/scripts/lib/docs-cache.ts +++ b/scripts/lib/docs-cache.ts @@ -203,3 +203,87 @@ export function parseChecksum(contents: string, expectedName: string): string | return undefined return match[1].toLowerCase() } + +/** What one project publishes, as reported by {@link summarizeCatalog}. */ +export interface ProjectCoverage { + project: string + /** Versions with an archive on a release, in natural order. */ + published: string[] + /** Versions whose tag is reserved but carries no archive yet. */ + unpublished: string[] +} + +/** Outcome of reading coverage out of a catalog. */ +export type CoverageResult + = | { kind: 'coverage', projects: ProjectCoverage[] } + | { kind: 'schema', found: string } + | { kind: 'unknown-project', project: string, known: string[] } + +/** + * Report what the catalog publishes, for every project or just one. + * + * The counterpart to {@link lookupTag}: that one answers "can I have this + * version", this one answers "which versions are there at all". Both read the + * same catalog, so a coverage listing can never drift from what a resolution + * would actually find — which is the whole reason the skill stopped carrying a + * hand-written list. + */ +export function summarizeCatalog(catalog: Catalog, project?: string): CoverageResult { + if (catalog.version !== SUPPORTED_CATALOG_VERSION) + return { kind: 'schema', found: catalog.version } + + const names = Object.keys(catalog.projects).sort() + if (project !== undefined && !names.includes(project)) + return { kind: 'unknown-project', project, known: names } + + const wanted = project === undefined ? names : [project] + return { kind: 'coverage', projects: wanted.map(name => coverageOf(catalog, name)) } +} + +function coverageOf(catalog: Catalog, project: string): ProjectCoverage { + const versions = catalog.projects[project] ?? {} + const published: string[] = [] + const unpublished: string[] = [] + for (const [version, entry] of Object.entries(versions)) + (entry.released_at === null ? unpublished : published).push(version) + published.sort(compareVersions) + unpublished.sort(compareVersions) + return { project, published, unpublished } +} + +/** Digit runs and non-digit runs, so `3.5.10` sorts after `3.5.9` rather than before it. */ +const VERSION_CHUNK_RE = /\d+|\D+/g + +/** Whether a chunk from {@link VERSION_CHUNK_RE} is the digit kind. */ +const DIGIT_CHUNK_RE = /^\d/ + +/** + * Order two versions naturally — digit runs compared as numbers. + * + * Display order for a listing, not semver precedence: a prerelease suffix sorts + * after its release (`7.0.0` then `7.0.0-RC1`) because this compares text, not + * semver. Nothing resolves a version through this, so the difference only + * affects where a line appears. + * + * Sorting at all is deliberate. The catalog happens to be generated in order + * today, but that is a property of a generator in another repository, and a + * coverage report that silently reorders itself when that generator changes is + * worse than one that always decides for itself. + */ +function compareVersions(a: string, b: string): number { + const left = a.match(VERSION_CHUNK_RE) ?? [] + const right = b.match(VERSION_CHUNK_RE) ?? [] + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const x = left[i] + const y = right[i] + if (x === undefined) + return -1 + if (y === undefined) + return 1 + if (x === y) + continue + const numeric = DIGIT_CHUNK_RE.test(x) && DIGIT_CHUNK_RE.test(y) + return numeric ? Number(x) - Number(y) : (x < y ? -1 : 1) + } + return 0 +} diff --git a/skills/spring-docs/SKILL.md b/skills/spring-docs/SKILL.md index 31599d4..2c523a5 100644 --- a/skills/spring-docs/SKILL.md +++ b/skills/spring-docs/SKILL.md @@ -3,7 +3,7 @@ name: spring-docs allowed-tools: - Bash(node ${CLAUDE_SKILL_DIR}/scripts/docs.mjs *) - Bash(node ${CLAUDE_SKILL_DIR}/scripts/detect.mjs *) -description: Open the reference documentation for one Spring project and version — Spring Boot 3.3.0-3.x and 4.0.8+. Use when answering a question about Spring behavior, configuration properties, auto-configuration, actuator, testing support or an upgrade path, and whenever the answer must match the version the project actually declares rather than the newest release. Takes " ", e.g. "boot 3.5.16". +description: Open the reference documentation for one Spring project and version — Spring Boot (`boot`) and Spring Framework (`framework`). Use when answering a question about Spring behavior, configuration properties, auto-configuration, actuator, testing support or an upgrade path, and whenever the answer must match the version the project actually declares rather than the newest release. Takes " ", e.g. "boot 3.5.16" or "framework 6.2.12"; `--list` reports which versions are published. --- # Spring reference documentation @@ -52,6 +52,11 @@ node ${CLAUDE_SKILL_DIR}/scripts/detect.mjs . Ask the user only when detection returns `kind: "not-found"` or `"unsupported"`. +Detection covers Spring Boot only, because Boot is what a build file declares. +Spring Framework arrives as a transitive dependency of Boot and appears nowhere +in `build.gradle` or `pom.xml`, so ask the user which Framework version they +mean rather than guessing one from the Boot version. + ## When a version is not published `kind: "unavailable"` is not a failure to work around. The `suggestion` field @@ -63,10 +68,25 @@ which version you are describing. ## Coverage -- `boot` — Spring Boot `3.3.0`-`3.x` and `4.0.8`+. 3.2 and older predate the - Antora documentation component; 4.0.0-4.0.7 publish no content archive. +`pleaseai/spring-docs` publishes on its own schedule, so no version list is +written down here — one would be a claim about another repository that was true +when it was typed. Ask the catalog instead: + +```bash +node ${CLAUDE_SKILL_DIR}/scripts/docs.mjs --list # every project +node ${CLAUDE_SKILL_DIR}/scripts/docs.mjs --list framework # just one +``` + +It prints each project's `published` versions, plus any `unpublished` ones — a +tag reserved with no archive behind it yet. A failed resolution reports the same +thing in its `suggestion`, so the listing is only needed when nothing has been +looked up yet, such as answering "which versions do you have". + +Two gaps belong to upstream rather than to the catalog, and no amount of +rebuilding will close them: + +- Spring Boot 3.2 and older predate the Antora documentation component, and + 4.0.0-4.0.7 publish no content archive. Neither will ever be listed. - Spring Boot 3.x trees omit the generated appendix (auto-configuration class listings, configuration-property tables) because upstream never publishes it. Configuration properties for 3.x therefore have to come from the prose pages. -- Other Spring projects (framework, security, data) are not published yet; - `unknown-project` says so. diff --git a/skills/spring-docs/scripts/docs.mjs b/skills/spring-docs/scripts/docs.mjs index c68b3b6..1dcec08 100644 --- a/skills/spring-docs/scripts/docs.mjs +++ b/skills/spring-docs/scripts/docs.mjs @@ -86,6 +86,44 @@ function parseChecksum(contents, expectedName) { return; return match[1].toLowerCase(); } +function summarizeCatalog(catalog, project) { + if (catalog.version !== SUPPORTED_CATALOG_VERSION) + return { kind: "schema", found: catalog.version }; + const names = Object.keys(catalog.projects).sort(); + if (project !== undefined && !names.includes(project)) + return { kind: "unknown-project", project, known: names }; + const wanted = project === undefined ? names : [project]; + return { kind: "coverage", projects: wanted.map((name) => coverageOf(catalog, name)) }; +} +function coverageOf(catalog, project) { + const versions = catalog.projects[project] ?? {}; + const published = []; + const unpublished = []; + for (const [version, entry] of Object.entries(versions)) + (entry.released_at === null ? unpublished : published).push(version); + published.sort(compareVersions); + unpublished.sort(compareVersions); + return { project, published, unpublished }; +} +var VERSION_CHUNK_RE = /\d+|\D+/g; +var DIGIT_CHUNK_RE = /^\d/; +function compareVersions(a, b) { + const left = a.match(VERSION_CHUNK_RE) ?? []; + const right = b.match(VERSION_CHUNK_RE) ?? []; + for (let i = 0;i < Math.max(left.length, right.length); i++) { + const x = left[i]; + const y = right[i]; + if (x === undefined) + return -1; + if (y === undefined) + return 1; + if (x === y) + continue; + const numeric = DIGIT_CHUNK_RE.test(x) && DIGIT_CHUNK_RE.test(y); + return numeric ? Number(x) - Number(y) : x < y ? -1 : 1; + } + return 0; +} // scripts/docs.ts var CACHE_HOME_ENV_OVERRIDE = "PLEASEAI_SPRING_CACHE_HOME"; @@ -127,6 +165,20 @@ async function fetchText(fetchImpl, url) { return { error: `GET ${url} failed: ${err instanceof Error ? err.message : String(err)}` }; } } +async function fetchCatalog(fetchImpl) { + const text = await fetchText(fetchImpl, CATALOG_URL); + if (typeof text !== "string") + return { reason: text.error, suggestion: "check network access to raw.githubusercontent.com" }; + let parsed; + try { + parsed = JSON.parse(text); + } catch (err) { + return { reason: `catalog.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; + } + if (!isCatalog(parsed)) + return { reason: "catalog.json does not have the expected shape", suggestion: "update the plugin" }; + return parsed; +} function isUsableTree(path) { try { return statSync(join2(path, INDEX_FILE)).isFile(); @@ -286,18 +338,10 @@ async function resolveDocs(options) { } return ready(project, version, tag, path, true); } - const catalogText = await fetchText(fetchImpl, CATALOG_URL); - if (typeof catalogText !== "string") - return unavailable(project, version, catalogText.error, "check network access to raw.githubusercontent.com"); - let parsed; - try { - parsed = JSON.parse(catalogText); - } catch (err) { - return unavailable(project, version, `catalog.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`); - } - if (!isCatalog(parsed)) - return unavailable(project, version, "catalog.json does not have the expected shape", "update the plugin"); - const catalog = parsed; + const fetched = await fetchCatalog(fetchImpl); + if (!("projects" in fetched)) + return unavailable(project, version, fetched.reason, fetched.suggestion); + const catalog = fetched; const lookup = lookupTag(catalog, project, version); switch (lookup.kind) { case "schema": @@ -347,21 +391,59 @@ async function resolveDocs(options) { writePointer(cacheHome, project, version, tag); return ready(project, version, tag, target, false); } -var USAGE = "usage: bun run scripts/docs.ts [--refresh] [--no-fetch]"; +async function listDocs(options = {}) { + const fetchImpl = options.fetchImpl ?? ((url) => fetch(url)); + const { project } = options; + const fetched = await fetchCatalog(fetchImpl); + if (!("projects" in fetched)) + return { kind: "unavailable", ...fetched }; + const summary = summarizeCatalog(fetched, project); + switch (summary.kind) { + case "schema": + return { + kind: "unavailable", + reason: `catalog.json is schema version ${summary.found}, this plugin understands 1`, + suggestion: "update the plugin" + }; + case "unknown-project": + return { + kind: "unavailable", + reason: `${DOCS_REPO} publishes no project "${summary.project}"`, + suggestion: `known projects: ${summary.known.join(", ") || "none"}` + }; + } + return { kind: "coverage", generatedAt: fetched.generated_at, projects: summary.projects }; +} +var USAGE = [ + "usage: bun run scripts/docs.ts [--refresh] [--no-fetch]", + " bun run scripts/docs.ts --list [project]" +].join(` +`); function parseArgs(argv) { const positional = []; let refresh = false; let noFetch = false; + let list = false; for (const arg of argv) { if (arg === "--refresh") refresh = true; else if (arg === "--no-fetch") noFetch = true; + else if (arg === "--list") + list = true; else if (arg.startsWith("--")) return { error: `unknown argument: ${arg}` }; else positional.push(arg); } + if (list) { + if (refresh || noFetch) + return { error: "--list takes no --refresh or --no-fetch" }; + const [project, ...extra] = positional; + if (extra.length > 0) + return { error: `unexpected argument: ${extra[0]}` }; + return project === undefined ? { mode: "list" } : { mode: "list", project }; + } const [project, version, ...extra] = positional; if (!project) return { error: "missing " }; @@ -369,7 +451,7 @@ function parseArgs(argv) { return { error: "missing " }; if (extra.length > 0) return { error: `unexpected argument: ${extra[0]}` }; - return { project, version, refresh, noFetch }; + return { mode: "resolve", project, version, refresh, noFetch }; } async function cli(argv) { const parsed = parseArgs(argv); @@ -381,7 +463,7 @@ ${USAGE} } let result; try { - result = await resolveDocs(parsed); + result = parsed.mode === "list" ? await listDocs(parsed) : await resolveDocs(parsed); } catch (err) { process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)} `); @@ -389,13 +471,14 @@ ${USAGE} } process.stdout.write(`${JSON.stringify(result, null, 2)} `); - return result.kind === "ready" ? 0 : 1; + return result.kind === "unavailable" ? 1 : 0; } if (true) { const code = await cli(process.argv.slice(2)); process.exit(code); } export { + listDocs, parseArgs, resolveDocs };