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 } };
+}