diff --git a/.github/scripts/report-schema-problems.mjs b/.github/scripts/report-schema-problems.mjs new file mode 100644 index 0000000..22c530e --- /dev/null +++ b/.github/scripts/report-schema-problems.mjs @@ -0,0 +1,337 @@ +// Turns the build's schema-problems.json into something a person will notice. +// +// A file the site cannot serve is skipped rather than fatal (see build.mjs), so +// a broken schema otherwise leaves no trace in a green deploy. This closes that +// gap: run annotations always, plus - where the caller sets REPORT_ISSUE=1 and +// the workflow grants issues:write - a single tracking issue. +// +// The issue is a living record, not a log. Its body always shows the current +// state, so someone opening it sees what is wrong now rather than reconstructing +// it from a pile of comments; every change also appends a comment saying what +// moved, so the history is still there for anyone who wants it. The body +// carries the problem set it was written from, which is what lets the next run +// tell "nothing changed" from "different problems" and diff the two. +// +// Node rather than shell: no jq to depend on, and the workflows have already +// set up Node by the time this runs. +// +// report() takes its gh as an argument and reaches for nothing global, so it +// can be driven end to end against a fake. Anything that shells out to the +// real gh lives below the entry-point guard, where a test cannot reach it. +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const LABEL = 'schema-problem'; +const TITLE = 'Schema files under schemas/ are not being served'; +const REPO_URL = 'https://github.com/flashtrace/flashtrace'; +const STATE_OPEN = ' and close the comment + // early. A reason quoting a parser error is not a controlled string. + const json = JSON.stringify({ fingerprint: fingerprint(problems), problems }).replace(/>/g, '\\u003e'); + return `${STATE_OPEN}${json} -->`; +} + +export function readState(body) { + const at = (body ?? '').indexOf(STATE_OPEN); + if (at === -1) return null; + const start = at + STATE_OPEN.length; + const end = body.indexOf('-->', start); + if (end === -1) return null; + try { + return JSON.parse(body.slice(start, end).trim()); + } catch { + return null; + } +} + +// Keyed by path, so a file whose reason changed reads as one changed entry +// rather than as a simultaneous disappearance and arrival. +export function diffProblems(prev, next) { + const was = new Map(prev.map((p) => [p.path, p.reason])); + const now = new Map(next.map((p) => [p.path, p.reason])); + return { + added: next.filter((p) => !was.has(p.path)), + removed: prev.filter((p) => !now.has(p.path)), + changed: next.filter((p) => was.has(p.path) && was.get(p.path) !== p.reason), + }; +} + +// A reason is often a parser's own words, so it can hold a pipe or a newline +// and quietly wreck the row it sits in. +const cell = (s) => String(s).replace(/\s*[\r\n]+\s*/g, ' ').replace(/\|/g, '\\|'); + +// Workflow commands are line-oriented and ::-delimited, so the same uncontrolled +// reason that can wreck a table row can inject commands of its own into the +// run's command stream - ::error::, ::add-mask::, anything. GitHub's escaping +// for this is percent-encoding; property values additionally need : and ,, +// which are what separate the properties from each other and from the message. +const cmdData = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +const cmdProp = (s) => cmdData(s).replace(/:/g, '%3A').replace(/,/g, '%2C'); + +const table = (problems) => [ + '| File | Why it was skipped |', + '|---|---|', + ...problems.map((p) => `| \`schemas/${cell(p.path)}\` | ${cell(p.reason)} |`), +]; + +const runNote = (runUrl, verb) => (runUrl ? [`${verb} by [this run](${runUrl}).`] : []); + +// The version alone can lie about the cause: a schema most often comes back +// because a PR here taught src/schemas.mjs a layout that changed on purpose, +// which moves the site commit and leaves the release untouched. Naming both +// keeps "resolved at v1.2.3" from crediting a release that never changed. +const siteNote = (siteRef) => { + if (!siteRef?.sha) return ''; + const short = siteRef.sha.slice(0, 7); + return siteRef.url ? ` (site [\`${short}\`](${siteRef.url}))` : ` (site \`${short}\`)`; +}; + +export const builtAt = ({ version, siteRef }) => `\`${version}\`${siteNote(siteRef)}`; + +export function issueBody({ version, siteRef, served, problems, runUrl }) { + return [ + 'Some files under `schemas/` are not being served by the site. The site itself deployed normally - this is only about the files below.', + '', + `**To fix:** correct it upstream in [flashtrace/flashtrace](${REPO_URL}) and cut a release, or adjust \`src/schemas.mjs\` here if the layout changed on purpose.`, + '', + `### Not being served, as of ${builtAt({ version, siteRef })}`, + '', + ...table(problems), + '', + `${served} schema(s) went out normally.`, + '', + 'Maintained by the deploy workflow. This table always reflects the most recent build, each change is recorded as a comment below, and the issue closes itself once a build finds nothing wrong. Edits to this body are overwritten.', + ...runNote(runUrl, 'Updated'), + '', + embedState(problems), + ].join('\n'); +} + +export function resolvedBody({ version, siteRef, runUrl }) { + return [ + `**Resolved.** Every file under \`schemas/\` is being served again as of ${builtAt({ version, siteRef })}.`, + '', + 'What was wrong, and when it changed, is in the comments below.', + '', + ...runNote(runUrl, 'Closed'), + ].join('\n'); +} + +// When the previous set is unreadable there is no diff to state, and stating +// one anyway would announce every long-standing problem as newly broken. Say +// what is actually known: the current set, and why it is not a comparison. +export function unknownPreviousComment({ version, siteRef, problems, runUrl }) { + return [ + `Rebuilt at ${builtAt({ version, siteRef })}.`, + '', + 'The previous state could not be read from this issue\'s body, so what changed since the last build cannot be shown. The full current set is below - some of it may have been here all along.', + '', + ...table(problems), + '', + 'The issue body above now shows the full current state, and the next build will be able to diff against it again.', + '', + ...runNote(runUrl, 'Updated'), + ].join('\n'); +} + +export function changeComment({ version, siteRef, diff, runUrl }) { + const lines = [`Rebuilt at ${builtAt({ version, siteRef })}, and the problems changed.`, '']; + if (diff.added.length > 0) { + lines.push(`**No longer served (${diff.added.length})**`, '', ...table(diff.added), ''); + } + if (diff.changed.length > 0) { + lines.push(`**Still not served, for a different reason (${diff.changed.length})**`, '', ...table(diff.changed), ''); + } + if (diff.removed.length > 0) { + lines.push( + `**Served again (${diff.removed.length})**`, + '', + ...diff.removed.map((p) => `- \`schemas/${p.path}\``), + '', + ); + } + lines.push('The issue body above now shows the full current state.', '', ...runNote(runUrl, 'Updated')); + return lines.join('\n'); +} + +// resolved is null when the previous set was unreadable: say so, rather than +// closing with a silent gap where the list of what came back should be. +export function closeComment({ version, siteRef, resolved, runUrl }) { + const lines = [`Rebuilt at ${builtAt({ version, siteRef })} with nothing skipped - closing.`, '']; + if (resolved === null) { + lines.push('The previous state could not be read from this issue\'s body, so which files came back cannot be listed. The comments above are the record.', ''); + } else if (resolved.length > 0) { + lines.push( + `**Served again (${resolved.length})**`, + '', + ...resolved.map((p) => `- \`schemas/${p.path}\``), + '', + ); + } + lines.push(...runNote(runUrl, 'Closed')); + return lines.join('\n'); +} + +// Returns a short tag for what it did, so a caller can assert on the decision +// rather than on log text. +export function report({ data, gh, log = console.log, reportIssue = false, runUrl, siteRef }) { + const { version = 'unknown', schemas: served = 0, problems = [] } = data; + // Deliberately not part of the fingerprint: siteRef moves on every merge to + // main, so folding it in would make every unrelated deploy re-comment on an + // unchanged issue - the exact noise the fingerprint exists to prevent. + const built = { version, siteRef }; + + // Annotations cost no permissions and land on the run itself, so they happen + // whether or not this workflow may touch issues. + for (const p of problems) { + const message = cmdData(cell(`schemas/${p.path} ${p.reason}`)); + log(`::warning title=${cmdProp('Schema not served')}::${message}`); + } + + if (!reportIssue) { + log(`${problems.length} problem(s) in ${version}; issue reporting is off for this workflow`); + return 'annotated'; + } + + const open = JSON.parse(gh('issue', 'list', '--state', 'open', '--label', LABEL, '--limit', '1', '--json', 'number')); + const existing = open[0]?.number; + // The body is kept as written, not just parsed: it is what a failed close + // has to be rolled back to. previous is null when there is no issue or when + // the body carries no state - "cannot diff", kept distinct from "diffed to + // nothing" all the way down, so an unreadable body never announces every + // long-standing problem as newly broken. + const existingBody = existing + ? JSON.parse(gh('issue', 'view', String(existing), '--json', 'body')).body + : null; + const previous = existing ? readState(existingBody)?.problems ?? null : null; + + if (problems.length === 0) { + if (!existing) { + log('clean build - nothing to report'); + return 'clean'; + } + // Body first: a reader arriving from the close notification should not + // find a table of problems that no longer exist. That ordering leaves a + // window, though - if the close fails, an open issue is left claiming to + // be resolved, with the table it should still be showing gone. That state + // is worse than either call simply not having happened, so put the old + // body back and let the next clean build try the whole thing again. + gh('issue', 'edit', String(existing), '--body', resolvedBody({ ...built, runUrl })); + try { + gh('issue', 'close', String(existing), '--comment', + closeComment({ ...built, resolved: previous, runUrl })); + } catch (error) { + try { + gh('issue', 'edit', String(existing), '--body', existingBody); + log(`could not close #${existing} - restored its body, so it still reports the last known problems`); + } catch { + // Nothing left to try, so say precisely what state the issue is in - + // it is one nobody would otherwise expect. + log(`could not close #${existing}, and could not restore its body: it is open and reads as resolved. The comments hold what was wrong; the next failing build rewrites the body.`); + } + throw error; + } + log(`clean build - closed #${existing}`); + return 'closed'; + } + + if (!existing) { + // --force so a missing label is created and an existing one left usable, + // rather than the first report ever filed failing on a label nobody made. + gh('label', 'create', LABEL, '--color', 'd93f0b', '--force', + '--description', 'A schema under schemas/ is not being served'); + gh('issue', 'create', '--title', TITLE, '--label', LABEL, + '--body', issueBody({ ...built, served, problems, runUrl })); + log(`opened a tracking issue for ${problems.length} problem(s)`); + return 'opened'; + } + + if (previous && fingerprint(previous) === fingerprint(problems)) { + log(`#${existing} already reports exactly these ${problems.length} problem(s) - staying quiet`); + return 'unchanged'; + } + + gh('issue', 'edit', String(existing), '--body', issueBody({ ...built, served, problems, runUrl })); + if (previous === null) { + gh('issue', 'comment', String(existing), '--body', unknownPreviousComment({ ...built, problems, runUrl })); + log(`#${existing} carries no readable state - updated it with the current ${problems.length} problem(s), without a diff`); + return 'restated'; + } + gh('issue', 'comment', String(existing), '--body', + changeComment({ ...built, diff: diffProblems(previous, problems), runUrl })); + log(`problem set changed - updated #${existing}`); + return 'updated'; +} + +// Only when run as a program: importing this module must never be able to +// reach the real gh. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const REPORT = 'schema-problems.json'; + if (!existsSync(REPORT)) { + // The build failed before schema discovery. Whatever went wrong, it is not + // this script's story to tell, and staying silent avoids closing a real + // issue on the strength of a build that never looked. + console.log(`no ${REPORT} - the build did not reach schema discovery, so there is nothing to report`); + process.exit(0); + } + const { + GITHUB_SERVER_URL = 'https://github.com', + GITHUB_REPOSITORY, + GITHUB_RUN_ID, + GITHUB_SHA, + } = process.env; + // On a repository_dispatch from an upstream release this is the head of main, + // which is the right answer: it names the site code that did the deploying. + // Outside Actions, fall back to the checkout someone ran the build from. + const localSha = () => { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + } catch { + return ''; // not a checkout, or no git - the version alone will have to do + } + }; + const sha = GITHUB_SHA || localSha(); + report({ + data: JSON.parse(readFileSync(REPORT, 'utf8')), + // Named explicitly rather than inferred from the working directory, so a + // run from outside a checkout - or from one whose origin points elsewhere - + // still reports against the repository being deployed. Appended, because + // the subcommand has to come first. Local runs without the variable keep + // gh's own inference. + gh: (...args) => execFileSync('gh', GITHUB_REPOSITORY ? [...args, '--repo', GITHUB_REPOSITORY] : args, + { encoding: 'utf8' }).trim(), + reportIssue: process.env.REPORT_ISSUE === '1', + runUrl: GITHUB_REPOSITORY && GITHUB_RUN_ID + ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` + : undefined, + siteRef: sha + ? { sha, url: GITHUB_REPOSITORY ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${sha}` : undefined } + : undefined, + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c745cb..378b8dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,15 @@ jobs: env: FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + # annotations only - REPORT_ISSUE is deliberately unset, because a PR + # should not file an issue about upstream content it did not touch. + # Filing belongs to the deploy; here this only makes a skipped schema + # visible while reviewing. + - name: Report skipped schemas + if: always() + continue-on-error: true + run: node .github/scripts/report-schema-problems.mjs + # also exercised here so artifact packaging failures surface in CI, # not first during a deploy - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f82b503..f510624 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,8 @@ permissions: contents: read pages: write id-token: write + # for the schema problem report in the build job below + issues: write concurrency: group: pages @@ -65,6 +67,18 @@ jobs: env: FLASHTRACE_REF: ${{ steps.ref.outputs.tag }} + # The build skips a schema it cannot serve instead of failing, so without + # this a broken schema would ship as a green deploy and nobody would know. + # continue-on-error: reporting the problem must never become a worse + # problem than the one being reported. + - name: Report skipped schemas + if: always() + continue-on-error: true + run: node .github/scripts/report-schema-problems.mjs + env: + REPORT_ISSUE: '1' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: dist diff --git a/.gitignore b/.gitignore index 06ad4d5..8abcbba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ flashtrace/ +schema-problems.json diff --git a/CLAUDE.md b/CLAUDE.md index 967f23e..4d4525f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,10 +13,10 @@ Ask refining questions before you start writing. | Folder | Purpose | |---|---| | src/ | Source code, used from 'build.mjs' to create 'dist/' | -| public/ | Assets that should be included exactly as they are in the final 'dist/' | +| public/ | Assets that should be included exactly as they are in the final 'dist/'; copied last, so a file here silently overwrites a generated file at the same path | | dist/ | Uncommitted, generated build output | | flashtrace/ | Uncommitted, gitignored clone of [flashtrace/flashtrace](https://github.com/flashtrace/flashtrace); its 'docs/' are a required build input | -| .github/ | Continuous integration/deployment workflows | +| .github/ | Continuous integration/deployment workflows, plus the scripts they run | 'build.mjs' renders the tool repo's 'docs/' into the site, so a build needs access to them. It looks for the docs at `$FLASHTRACE_DOCS`, then './flashtrace/docs', then '../flashtrace/docs' - if none exist, clone the tool repo first: `git clone https://github.com/flashtrace/flashtrace`. diff --git a/build.mjs b/build.mjs index ed897f0..733f5e4 100644 --- a/build.mjs +++ b/build.mjs @@ -11,9 +11,11 @@ import { docShell, esc, EXT_ATTRS, GITHUB_URL, highlightTokens, SITE_URL } from import { renderLanding } from './src/landing.mjs'; import { renderImpressum } from './src/impressum.mjs'; import { renderLicense } from './src/license.mjs'; +import { collectSchemas, locateSchemas } from './src/schemas.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); +const schemaProblemsPath = path.join(root, 'schema-problems.json'); // --- locate the tool repo's docs (env → CI checkout → local sibling) ------- @@ -42,6 +44,33 @@ if (!existsSync(licensePath)) { } const licenseText = readFileSync(licensePath, 'utf8'); +// --- schemas: published schemas, sitting next to docs/ in the tool repo ------ + +// A missing folder only warns, and a file the site cannot serve is skipped +// rather than fatal, for the same reason: one broken schema - or a release +// predating schemas/ entirely - must not cost the deploy of the docs, which +// have nothing to do with it. The skips are written to schemaProblemsPath +// below so the trade stays visible; CI turns that into a tracking issue. +const schemasDir = locateSchemas(docsDir); +let schemas = []; +let schemaProblems = []; +if (schemasDir) { + try { + ({ schemas, problems: schemaProblems } = collectSchemas(schemasDir)); + } catch (err) { + console.error(`error: could not read ${schemasDir}: ${err.message}`); + process.exit(1); + } + for (const p of schemaProblems) console.warn(`warn: schemas/${p.path} ${p.reason}`); + // An empty folder is not a problem: schemas/ can land upstream a release + // before the first schema inside it does. + if (schemas.length === 0) { + console.warn(`warn: ${schemasDir} holds no v.json files - nothing to serve under /schemas/.`); + } +} else { + console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/.'); +} + // --- version: release tag from env, else the tool repo's package.json ------ function readVersion() { @@ -193,6 +222,27 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +// Never through the markdown pipeline, whose link rewriting would corrupt the +// identifiers ($id, $ref, $schema) inside them. +for (const schema of schemas) { + const dir = path.join(dist, 'schemas', schema.name); + mkdirSync(dir, { recursive: true }); + for (const version of schema.versions) writeFileSync(path.join(dir, version.file), version.bytes); + // A real file, not a redirect - GitHub Pages has no server-side redirects + // and a meta-refresh means nothing to a JSON fetch. Copied verbatim, $id + // included: a copy fetched from latest.json must still say which version it + // actually is. + writeFileSync(path.join(dir, `latest.${schema.format}`), schema.latest.bytes); +} + +// Outside dist/ - a build artifact for CI to read, not something to publish. +// Written on every build, including a clean one: "no problems" has to be a +// statement the workflow can act on, or it could never close a stale issue. +writeFileSync( + schemaProblemsPath, + `${JSON.stringify({ version, schemas: schemas.length, problems: schemaProblems }, null, 2)}\n`, +); + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), @@ -208,6 +258,8 @@ ${sitePaths.map((p) => ` ${SITE_URL}${p}`).join('\n')} `, ); +// Last, so a file in public/ silently wins against a generated file at the +// same path - check here first if a generated file is not the one being served. cpSync(path.join(root, 'public'), dist, { recursive: true }); cpSync(path.join(root, 'src', 'styles', 'site.css'), path.join(dist, 'site.css')); cpSync(path.join(root, 'src', 'scripts', 'site.js'), path.join(dist, 'site.js')); diff --git a/src/schemas.mjs b/src/schemas.mjs new file mode 100644 index 0000000..6f53f29 --- /dev/null +++ b/src/schemas.mjs @@ -0,0 +1,147 @@ +// Discovery of the machine-readable JSON Schemas the tool repo publishes under +// schemas/. The files are served verbatim - consumers fetch them by $id, so +// the bytes on the site must match the release exactly. +// +// Discovery reports rather than throws: a file it cannot serve is recorded as +// a problem and left out of the result, so the caller can decide whether one +// bad file is worth more than the schemas that are fine. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +// The tool repo mirrors the URL layout it is served at, so the on-disk path is +// also the site path - which stays true only while the site refuses to serve +// anything else. +const VERSION_FILE = /^v(\d+)\.([A-Za-z0-9]+)$/; + +// latest. is ours to generate from the highest version, so an upstream +// copy is ignored rather than served: two sources disagreeing about which +// version is current is the one thing the alias exists to prevent. +const LATEST_FILE = /^latest\.[a-z0-9]+$/i; + +// Serving a format means having decided how it is validated here and how the +// docs generator renders it, so the list is explicit rather than "whatever +// turns up". Adding 'xml' is a one-word change once a release ships one. +const ALLOWED_FORMATS = new Set(['json']); + +// What a schemas/ folder legitimately holds for the tool repo's own sake: a +// README explaining the folder to someone reading that repo, editor dotfiles, +// a licence note. Not served, and not worth mentioning either. +const LOCAL_FILE = /^(?:\.|LICENSE)|\.md$/i; + +// A name that was reaching for the version layout and missed - v1.2.json, +// V0.json, report-v0.json, v0.json.bak. Worth reporting, because from here a +// typo that quietly unpublishes a schema looks exactly like a file that was +// never meant to be one. +const NEAR_MISS = /v\d/i; + +// Sits next to docs/ in the tool repo, like LICENSE does. Absent until the +// release that introduces it, so the caller decides whether that is fatal. +export function locateSchemas(docsDir) { + const dir = path.join(docsDir, '..', 'schemas'); + return existsSync(dir) ? dir : null; +} + +// One entry per (folder, format) pair, so a folder that ever holds both +// v1.json and v1.xml keeps two independent version lines - and two latest.* +// aliases - rather than one muddled one. +export function collectSchemas(schemasDir) { + const schemas = []; + const problems = []; + + for (const entry of readdirSync(schemasDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + collectSchemaDir(path.join(schemasDir, entry.name), entry.name, schemas, problems); + continue; + } + if (!LOCAL_FILE.test(entry.name) && NEAR_MISS.test(entry.name)) { + problems.push({ + path: entry.name, + reason: 'sits outside a schemas// folder, so it is not served', + }); + } + } + + schemas.sort((a, b) => + a.name === b.name ? a.format.localeCompare(b.format) : a.name.localeCompare(b.name), + ); + problems.sort((a, b) => a.path.localeCompare(b.path)); + return { schemas, problems }; +} + +function collectSchemaDir(dir, name, schemas, problems) { + const byFormat = new Map(); + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const found = classifyEntry(dir, entry); + if (!found) continue; // local to the tool repo; not ours to serve or judge + if (found.problem) { + problems.push({ path: `${name}/${entry.name}`, reason: found.problem }); + continue; + } + if (!byFormat.has(found.format)) byFormat.set(found.format, []); + byFormat.get(found.format).push(found.version); + } + + for (const [format, versions] of byFormat) { + // Numeric, not lexical: v10 must sort above v9. + versions.sort((a, b) => a.version - b.version); + // latest follows the highest version that survived validation, so a broken + // v1 leaves the alias on v0 rather than on nothing. Serving the last good + // version is the lesser wrong, and the problem report says why it happened. + schemas.push({ name, format, versions, latest: versions.at(-1) }); + } +} + +// Exactly one of: a { format, version } to serve, a { problem } to report, or +// null for a file that is plainly the tool repo's own and no business of the +// site's. +function classifyEntry(dir, entry) { + const problem = (reason) => ({ problem: reason }); + + if (entry.isDirectory()) { + return problem('is nested deeper than schemas//, so nothing inside it is served'); + } + // withFileTypes reports a link as a link, so this never reaches the copy - + // a symlink would survive the build but not the Pages artifact. + if (entry.isSymbolicLink()) { + return problem('is a symlink, and schemas are published as real files, so it is not served'); + } + if (LATEST_FILE.test(entry.name)) { + return problem('is generated by the site from the highest version, so this copy is ignored'); + } + + const m = VERSION_FILE.exec(entry.name); + if (!m) { + if (LOCAL_FILE.test(entry.name) || !NEAR_MISS.test(entry.name)) return null; + return problem('does not match v., so it is not served'); + } + + const [, num, ext] = m; + const format = ext.toLowerCase(); + if (!ALLOWED_FORMATS.has(format)) { + return problem(`is .${format}, and the site only serves ${[...ALLOWED_FORMATS].join(', ')}`); + } + + // An unreadable file is one more thing to skip, not a crash: it reads the + // same to a visitor as a broken one. + let bytes; + try { + bytes = readFileSync(path.join(dir, entry.name)); + } catch (err) { + return problem(`could not be read: ${err.message}`); + } + + // Parsed to find out whether it parses, and for nothing else: the site + // serves bytes, not a re-serialisation, so keeping the document would only + // create a second representation to fall out of step. Another format, once + // allowed, is served verbatim and this build has no opinion on its contents. + if (format === 'json') { + try { + JSON.parse(bytes.toString('utf8')); + } catch (err) { + return problem(`is not valid JSON: ${err.message}`); + } + } + + return { format, version: { version: Number(num), file: entry.name, bytes } }; +}