From e459e3b4e3184867289e8575efdec4663def8127 Mon Sep 17 00:00:00 2001 From: LennarX Date: Mon, 20 Jul 2026 14:10:47 +0200 Subject: [PATCH 1/5] feat: serve and document flashtrace JSON Schemas Copies schemas/** from the tool repo to /schemas/**, byte for byte, and generates a /docs/schemas// page per schema from the schema itself. Both come from the same release checkout that /docs/ is built from. The files are machine-consumed, so they never touch the markdown pipeline: the strings inside them ($id, $ref, $schema) are identifiers, not links. latest.json is a real byte copy of the highest version, not a redirect - GitHub Pages has no server-side redirects and a meta-refresh means nothing to a JSON fetch. Its $id keeps pointing at the canonical version, so a consumer that vendors the file still knows which version it holds. The highest version is picked by parsing vN numerically, so v10 beats v9. Version files are re-copied on every build rather than treated as final: schemaVersion only moves on breaking changes, so v1.json keeps receiving additive updates within version 1. The renderer is deliberately strict - an unrecognized keyword fails the build naming its JSON pointer, because a page that silently omits a constraint is worse than a missing page. It resolves local $defs pointers to on-page anchors and walks the top-level allOf alongside the base schema by instance location, so the rich-mode conditionals land on the fields they constrain (forwards at the top level, wantedBy on the item definition) instead of being dropped. schemas/ is absent until the release that introduces it, so a missing folder only warns and the rest of the site still builds; once it exists, anything wrong with its contents is fatal. Co-Authored-By: Claude Opus 4.8 --- build.mjs | 92 +++++++++++++-- src/schema-doc.mjs | 285 +++++++++++++++++++++++++++++++++++++++++++++ src/schemas.mjs | 63 ++++++++++ 3 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 src/schema-doc.mjs create mode 100644 src/schemas.mjs diff --git a/build.mjs b/build.mjs index ed897f0..e34e57f 100644 --- a/build.mjs +++ b/build.mjs @@ -11,6 +11,8 @@ 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 { renderSchemaDoc } from './src/schema-doc.mjs'; +import { collectSchemas, locateSchemas } from './src/schemas.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); @@ -42,6 +44,28 @@ if (!existsSync(licensePath)) { } const licenseText = readFileSync(licensePath, 'utf8'); +// --- schemas: published JSON Schemas, sitting next to docs/ in the tool repo - + +// Absent until the release that introduces schemas/, so a missing folder only +// warns - the rest of the site must keep deploying against older releases. +// Once the folder exists, anything wrong with its contents is fatal. +const schemasDir = locateSchemas(docsDir); +let schemas = []; +if (schemasDir) { + try { + schemas = collectSchemas(schemasDir); + } catch (err) { + console.error(`error: ${err.message}`); + process.exit(1); + } + if (schemas.length === 0) { + console.error(`error: ${schemasDir} holds no v.json files - did the layout change?`); + process.exit(1); + } +} else { + console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/ and /docs/schemas/.'); +} + // --- version: release tag from env, else the tool repo's package.json ------ function readVersion() { @@ -141,34 +165,51 @@ const marked = new Marked({ }, }); -function renderDoc(page) { - state.toc = []; - state.slugCounts = new Map(); - const md = readFileSync(path.join(docsDir, page.file), 'utf8'); - const content = marked.parse(md); - const navGroups = [ +// Shared by the markdown docs and the generated schema pages, so both render +// the same sidebar. `current` is { slug } for a doc page, { schema } for a +// schema page - a schema page has no slug, so no doc entry matches. +function navGroups(current) { + const groups = [ { label: 'Getting started', - items: [{ title: 'Usage Guide', href: '/docs/usage/', current: page.slug === 'usage' }], + items: [{ title: 'Usage Guide', href: '/docs/usage/', current: current.slug === 'usage' }], }, { label: 'Specification', items: [ - { title: 'Overview', href: '/docs/', current: page.slug === '' }, + { title: 'Overview', href: '/docs/', current: current.slug === '' }, ...specPages.map((p) => ({ title: p.title, href: `/docs/${p.slug}/`, - current: p.slug === page.slug, + current: p.slug === current.slug, })), ], }, ]; + if (schemas.length > 0) { + groups.push({ + label: 'Schemas', + items: schemas.map((s) => ({ + title: s.name, + href: `/docs/schemas/${s.name}/`, + current: s.name === current.schema, + })), + }); + } + return groups; +} + +function renderDoc(page) { + state.toc = []; + state.slugCounts = new Map(); + const md = readFileSync(path.join(docsDir, page.file), 'utf8'); + const content = marked.parse(md); return docShell({ title: `${page.title} · flashtrace`, description: `flashtrace documentation - ${page.title}.`, path: page.slug ? `/docs/${page.slug}/` : '/docs/', version, - navGroups, + navGroups: navGroups({ slug: page.slug }), toc: state.toc, content, }); @@ -193,9 +234,36 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +// Schemas are machine-consumed artifacts, not pages: copied byte-for-byte and +// never through the markdown pipeline, whose link rewriting would corrupt the +// identifiers ($id, $ref, $schema) inside them. +if (schemasDir) { + cpSync(schemasDir, path.join(dist, 'schemas'), { recursive: true }); + for (const schema of schemas) { + // A real file, not a redirect - GitHub Pages has no server-side redirects + // and a meta-refresh means nothing to a JSON fetch. The bytes are the + // highest version's verbatim, $id included: a copy fetched from + // latest.json must still say which version it actually is. + writeFileSync(path.join(dist, 'schemas', schema.name, 'latest.json'), schema.latest.bytes); + + const dir = path.join(dist, 'docs', 'schemas', schema.name); + mkdirSync(dir, { recursive: true }); + try { + writeFileSync( + path.join(dir, 'index.html'), + renderSchemaDoc({ schema, version, navGroups: navGroups({ schema: schema.name }) }), + ); + } catch (err) { + console.error(`error: rendering /docs/schemas/${schema.name}/ failed.\n${err.message}`); + process.exit(1); + } + } +} + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), + ...schemas.map((s) => `/docs/schemas/${s.name}/`), '/license/', '/impressum/', ]; @@ -212,4 +280,6 @@ 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')); -console.log(`built ${pages.length + 3} pages into dist/ (flashtrace ${version || 'unknown version'})`); +console.log( + `built ${pages.length + schemas.length + 3} pages into dist/ (flashtrace ${version || 'unknown version'})`, +); diff --git a/src/schema-doc.mjs b/src/schema-doc.mjs new file mode 100644 index 0000000..1042280 --- /dev/null +++ b/src/schema-doc.mjs @@ -0,0 +1,285 @@ +// Renders a JSON Schema into a human-readable docs page, one section per +// version. Everything on the page comes from the schema file, so it cannot +// drift from what is actually served at /schemas/. +// +// Deliberately strict: a keyword the renderer does not understand fails the +// build, naming its JSON pointer. A page that silently omits a constraint is +// worse than a missing page. +import { docShell, esc, SITE_URL } from './layout.mjs'; + +// Every keyword this renderer knows how to display. Extending the set means +// deciding how the new constraint appears on the page - never widen it just to +// get a build green. +const KNOWN_KEYWORDS = new Set([ + '$schema', + '$id', + '$defs', + '$ref', + 'title', + 'description', + 'type', + 'required', + 'properties', + 'items', + 'enum', + 'const', + 'minimum', + 'oneOf', + 'allOf', + 'if', + 'then', +]); + +// Keywords whose values are subschemas, by shape. +const SUBSCHEMA_MAPS = ['properties', '$defs']; // name -> subschema +const SUBSCHEMA_SINGLES = ['items', 'if', 'then']; // subschema +const SUBSCHEMA_LISTS = ['oneOf', 'allOf']; // [subschema] + +const ptr = (base, token) => `${base}/${String(token).replaceAll('~', '~0').replaceAll('/', '~1')}`; + +// --- validation -------------------------------------------------------------- + +function findUnknown(node, pointer, bad) { + if (node === null || typeof node !== 'object' || Array.isArray(node)) return; + for (const key of Object.keys(node)) { + if (!KNOWN_KEYWORDS.has(key)) bad.push(ptr(pointer, key)); + } + for (const key of SUBSCHEMA_MAPS) { + for (const [name, sub] of Object.entries(node[key] ?? {})) { + findUnknown(sub, ptr(ptr(pointer, key), name), bad); + } + } + for (const key of SUBSCHEMA_SINGLES) { + if (node[key] !== undefined) findUnknown(node[key], ptr(pointer, key), bad); + } + for (const key of SUBSCHEMA_LISTS) { + (node[key] ?? []).forEach((sub, i) => findUnknown(sub, ptr(ptr(pointer, key), i), bad)); + } +} + +function assertRenderable(schema, label) { + const bad = []; + findUnknown(schema, '#', bad); + if (bad.length > 0) { + throw new Error( + `${label}: unsupported JSON Schema keyword(s) at:\n` + + bad.map((p) => ` ${p}`).join('\n') + + '\nTeach src/schema-doc.mjs how to render them, then rebuild.', + ); + } +} + +// --- $ref resolution --------------------------------------------------------- + +// Only local pointers into $defs; the schemas in this family have no others, +// and a remote ref would need fetching at build time. +function refName(ref, pointer) { + const m = /^#\/\$defs\/([^/]+)$/.exec(ref); + if (!m) throw new Error(`unsupported $ref "${ref}" at ${pointer} - only #/$defs/ is resolvable`); + return m[1]; +} + +function resolveRef(schema, ref, pointer) { + const name = refName(ref, pointer); + const target = schema.$defs?.[name]; + if (!target) throw new Error(`$ref "${ref}" at ${pointer} does not resolve - no such entry in $defs`); + return { name, node: target }; +} + +// --- type + description rendering ------------------------------------------- + +const lit = (v) => `${esc(JSON.stringify(v))}`; + +function typeHtml(schema, node, pointer, anchorPrefix) { + if (node.$ref) { + const name = refName(node.$ref, pointer); + return `${esc(name)}`; + } + if (node.oneOf) { + return node.oneOf + .map((sub, i) => typeHtml(schema, sub, ptr(ptr(pointer, 'oneOf'), i), anchorPrefix)) + .join(' | '); + } + if (node.const !== undefined) return `${lit(node.const)} (constant)`; + if (node.enum) return node.enum.map(lit).join(' | '); + + const { type } = node; + let base; + if (Array.isArray(type)) base = type.map((t) => `${esc(t)}`).join(' | '); + else if (type === 'array' && node.items) { + base = `array of ${typeHtml(schema, node.items, ptr(pointer, 'items'), anchorPrefix)}`; + } else if (type) base = `${esc(type)}`; + else base = 'any'; + + if (node.minimum !== undefined) base += ` (min ${esc(node.minimum)})`; + return base; +} + +// A property that is just a $ref carries no description of its own; the prose +// lives on the $defs entry it points at. +function descriptionFor(schema, node, pointer) { + if (node.description) return node.description; + if (node.$ref) return resolveRef(schema, node.$ref, pointer).node.description ?? ''; + return ''; +} + +// --- conditional requirements (allOf / if-then) ------------------------------ + +// `mode` is `"rich"` - the human form of an if-branch. +function conditionLabel(ifNode, pointer) { + const parts = Object.entries(ifNode.properties ?? {}).map(([name, sub]) => { + if (sub.const !== undefined) return `${esc(name)} is ${lit(sub.const)}`; + if (sub.enum) return `${esc(name)} is one of ${sub.enum.map(lit).join(' | ')}`; + throw new Error(`unsupported if-condition on "${name}" at ${pointer} - expected const or enum`); + }); + if (parts.length === 0) throw new Error(`empty if-condition at ${pointer}`); + return parts.join(' and '); +} + +// Walks the `then` branch alongside the base schema by instance location, so a +// nested requirement lands on the table that actually renders that instance. +// `properties.items.items.required: ["wantedBy"]` resolves through the $ref on +// the array's element schema and annotates the `item` definition. +function walkThen(schema, baseNode, thenNode, target, condition, out, pointer) { + let base = baseNode; + let scope = target; + if (base?.$ref) { + const resolved = resolveRef(schema, base.$ref, pointer); + base = resolved.node; + scope = resolved.name; + } + for (const key of Object.keys(thenNode)) { + if (!['required', 'properties', 'items'].includes(key)) { + throw new Error(`unsupported keyword "${key}" in a then-branch at ${ptr(pointer, key)}`); + } + } + for (const field of thenNode.required ?? []) out.push({ target: scope, field, condition }); + for (const [name, sub] of Object.entries(thenNode.properties ?? {})) { + const p = ptr(ptr(pointer, 'properties'), name); + walkThen(schema, base?.properties?.[name] ?? {}, sub, scope, condition, out, p); + } + if (thenNode.items) { + walkThen(schema, base?.items ?? {}, thenNode.items, scope, condition, out, ptr(pointer, 'items')); + } +} + +// Map>; target is a $defs name or 'root'. +function collectConditionals(schema) { + const flat = []; + (schema.allOf ?? []).forEach((entry, i) => { + const p = ptr(ptr('#', 'allOf'), i); + if (!entry.if || !entry.then) { + throw new Error(`unsupported allOf entry at ${p} - only if/then conditionals are rendered`); + } + walkThen(schema, schema, entry.then, 'root', conditionLabel(entry.if, ptr(p, 'if')), flat, ptr(p, 'then')); + }); + const byTarget = new Map(); + for (const { target, field, condition } of flat) { + if (!byTarget.has(target)) byTarget.set(target, new Map()); + byTarget.get(target).set(field, condition); + } + return byTarget; +} + +// --- tables ------------------------------------------------------------------ + +function propertyTable(schema, node, pointer, anchorPrefix, conditionals) { + const required = new Set(node.required ?? []); + const rows = Object.entries(node.properties ?? {}).map(([name, sub]) => { + const p = ptr(ptr(pointer, 'properties'), name); + const condition = conditionals?.get(name); + let req; + if (required.has(name)) req = 'Required'; + else if (condition) req = `Required when ${condition}`; + else req = 'Optional'; + return ` +${esc(name)} +${typeHtml(schema, sub, p, anchorPrefix)} +${req} +${esc(descriptionFor(schema, sub, p))} +`; + }); + if (rows.length === 0) return ''; + return ` + + +${rows.join('\n')} + +
PropertyTypeRequiredDescription
`; +} + +// --- page -------------------------------------------------------------------- + +function versionSection(schemaEntry, entry, toc) { + const { json: schema, version, file } = entry; + const label = `v${version}`; + assertRenderable(schema, `${schemaEntry.name}/${file}`); + const conditionals = collectConditionals(schema); + const canonical = `${SITE_URL}/schemas/${schemaEntry.name}/${file}`; + const isLatest = entry === schemaEntry.latest; + + toc.push({ id: label, text: `Version ${version}`, level: 2 }); + const parts = []; + parts.push( + `

Version ${version}#

`, + ); + + const urls = [`
  • ${esc(canonical)}
  • `]; + if (isLatest) { + urls.push( + `
  • ${esc(`${SITE_URL}/schemas/${schemaEntry.name}/latest.json`)} - byte-identical alias, moves with the highest version
  • `, + ); + } + parts.push(`
      ${urls.join('')}
    `); + + if (schema.title) parts.push(`

    ${esc(schema.title)}

    `); + if (schema.description) parts.push(`

    ${esc(schema.description)}

    `); + + parts.push( + `

    additionalProperties is deliberately left unconstrained. Additive changes do not bump the version, so validating a newer document against this schema must not fail on fields it does not list.

    `, + ); + + toc.push({ id: `${label}-properties`, text: 'Top-level properties', level: 3 }); + parts.push( + `

    Top-level properties#

    `, + ); + parts.push(propertyTable(schema, schema, '#', label, conditionals.get('root'))); + + for (const [name, def] of Object.entries(schema.$defs ?? {})) { + const id = `${label}-${name}`; + const p = ptr(ptr('#', '$defs'), name); + toc.push({ id, text: name, level: 3 }); + parts.push( + `

    ${esc(name)}#

    `, + ); + if (def.description) parts.push(`

    ${esc(def.description)}

    `); + const table = propertyTable(schema, def, p, label, conditionals.get(name)); + // Scalar definitions (a constrained string, say) have no properties table; + // their type is the whole statement. + parts.push(table || `

    Type: ${typeHtml(schema, def, p, label)}

    `); + } + + return parts.join('\n'); +} + +export function renderSchemaDoc({ schema, version, navGroups }) { + const toc = []; + // Newest version first: the current one is what a reader almost always wants. + const sections = [...schema.versions].reverse().map((entry) => versionSection(schema, entry, toc)); + const content = `

    ${esc(schema.name)} schema

    +

    Machine-readable JSON Schema for the flashtrace ${esc(schema.name)} document, served from +${esc(SITE_URL)}/schemas/${esc(schema.name)}/. This page is generated from those files.

    +

    The version below is bumped only by breaking changes - a field removed, renamed, re-typed, or a documented +meaning changed. A new optional field does not bump it.

    +${sections.join('\n')}`; + + return docShell({ + title: `${schema.name} schema · flashtrace`, + description: `JSON Schema reference for the flashtrace ${schema.name} document.`, + path: `/docs/schemas/${schema.name}/`, + version, + navGroups, + toc, + content, + }); +} diff --git a/src/schemas.mjs b/src/schemas.mjs new file mode 100644 index 0000000..b90b5f7 --- /dev/null +++ b/src/schemas.mjs @@ -0,0 +1,63 @@ +// 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. Nothing here parses +// for rewriting; the JSON is read only to render the docs page from it. +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +// schemas//v.json - the tool repo mirrors the URL layout it is served +// at, so the on-disk path is also the site path. +const VERSION_FILE = /^v(\d+)\.json$/; + +// 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; +} + +// Every directory holding v.json files becomes one schema with one or more +// versions. Returns [{ name, versions: [{ version, file, bytes, json }], latest }]. +export function collectSchemas(schemasDir) { + const out = []; + walk(schemasDir, schemasDir, out); + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; +} + +function walk(dir, rootDir, out) { + const versions = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, rootDir, out); + continue; + } + // latest.json is ours to generate; upstream shipping one would mean two + // sources disagree about which version is current. + if (entry.name === 'latest.json') { + throw new Error( + `${path.relative(rootDir, full)} exists upstream, but the site generates latest.json itself. ` + + 'Remove it upstream or drop the generation here - do not ship both.', + ); + } + const m = VERSION_FILE.exec(entry.name); + if (!m) continue; // non-version files are still copied verbatim, just not rendered + const bytes = readFileSync(full); + let json; + try { + json = JSON.parse(bytes.toString('utf8')); + } catch (err) { + throw new Error(`${path.relative(rootDir, full)} is not valid JSON: ${err.message}`); + } + versions.push({ version: Number(m[1]), file: entry.name, bytes, json }); + } + if (versions.length === 0) return; + // Numeric, not lexical: v10 must sort above v9. + versions.sort((a, b) => a.version - b.version); + out.push({ + name: path.relative(rootDir, dir).split(path.sep).join('/'), + versions, + latest: versions[versions.length - 1], + }); +} From 3c94051339f8863b78032308e0ae7c002f9c0533 Mon Sep 17 00:00:00 2001 From: LennarX Date: Mon, 20 Jul 2026 14:48:48 +0200 Subject: [PATCH 2/5] feat: pick the schema version instead of stacking every version Stacking one section per version does not scale - by v3 the page is mostly history and the TOC lists three copies of every definition. A picker at the top defaults to the latest and switches on demand. Every version still ships in the page and only its display is toggled, so #v1 and #v1-item keep resolving, in-page $ref links keep working, and the browser's own find still hits what is on screen. Degrades cleanly. The .js class now lands on in the pre-paint theme script, and the latest version is marked current in the markup - so the page paints on the right version rather than flashing all of them and collapsing to one. With scripting off the picker is styled away entirely and every version renders stacked, newest first, with all anchors reachable. A select rather than tabs: version counts only grow, and a picker stays one line at ten versions. It is rendered only when there is more than one version to choose between. The TOC rail follows the same data-version filter, and the flow marker now skips sections with no box - a display:none section measures as a zero rect at the top of the document and would drag the marker up there. Co-Authored-By: Claude Opus 4.8 --- src/layout.mjs | 15 ++++++++-- src/schema-doc.mjs | 51 +++++++++++++++++++++++---------- src/scripts/site.js | 69 ++++++++++++++++++++++++++++++++++++++++++--- src/styles/site.css | 48 +++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 22 deletions(-) diff --git a/src/layout.mjs b/src/layout.mjs index 72a790f..433e8a7 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -36,7 +36,10 @@ export function highlightTokens(escaped) { ); } -const themeInit = `(function(){try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`; +// Also marks the document as scripted before first paint, so a JS-only control +// (the schema version picker) can be styled away when scripting is off, and +// the version filtering it drives never flashes in. +const themeInit = `(function(){document.documentElement.classList.add('js');try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`; const sunIcon = ``; const moonIcon = ``; @@ -135,7 +138,9 @@ ${groupHtml} `; } -// Right-rail "On this page" TOC from [{ id, text, level }] (h2/h3). +// Right-rail "On this page" TOC from [{ id, text, level }] (h2/h3). An entry +// may carry { version, current } to tie it to one section of a versioned page, +// so the rail lists only what is on screen. function tocRail(toc) { if (!toc.length) return ''; return `