diff --git a/build.mjs b/build.mjs
index e550f0f..22a34a3 100644
--- a/build.mjs
+++ b/build.mjs
@@ -11,6 +11,7 @@ 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));
@@ -62,7 +63,21 @@ if (schemasDir) {
process.exit(1);
}
} else {
- console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/.');
+ console.warn('warn: no schemas/ in the flashtrace checkout - skipping /schemas/ and /docs/schemas/.');
+}
+
+// Serving is format-agnostic - every schema file is copied verbatim whatever
+// its extension. Rendering is not: src/schema-doc.mjs reads JSON Schema. A
+// format we cannot render is still published, just without a docs page, and
+// says so loudly rather than vanishing.
+const documented = schemas.filter((s) => s.format === 'json');
+for (const s of schemas) {
+ if (s.format !== 'json') {
+ console.warn(
+ `warn: ${s.title} (schemas/${s.name}/v*.${s.format}) is served but not documented - ` +
+ 'src/schema-doc.mjs renders JSON Schema only.',
+ );
+ }
}
// --- version: release tag from env, else the tool repo's package.json ------
@@ -164,34 +179,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 (documented.length > 0) {
+ groups.push({
+ label: 'Schemas',
+ items: documented.map((s) => ({
+ title: s.title,
+ href: `/docs/schemas/${s.slug}/`,
+ current: s.slug === 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,
});
@@ -232,11 +264,25 @@ if (schemasDir) {
schema.latest.bytes,
);
}
+ for (const schema of documented) {
+ const dir = path.join(dist, 'docs', 'schemas', schema.slug);
+ mkdirSync(dir, { recursive: true });
+ try {
+ writeFileSync(
+ path.join(dir, 'index.html'),
+ renderSchemaDoc({ schema, version, navGroups: navGroups({ schema: schema.slug }) }),
+ );
+ } catch (err) {
+ console.error(`error: rendering /docs/schemas/${schema.slug}/ failed.\n${err.message}`);
+ process.exit(1);
+ }
+ }
}
const sitePaths = [
'/',
...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')),
+ ...documented.map((s) => `/docs/schemas/${s.slug}/`),
'/license/',
'/impressum/',
];
@@ -253,4 +299,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 + documented.length + 3} pages into dist/ (flashtrace ${version || 'unknown version'})`,
+);
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 `
@@ -143,7 +148,11 @@ function tocRail(toc) {
On this page
${toc
- .map((h) => `${h.text} `)
+ .map((h) => {
+ const v = h.version ? ` data-version="${esc(h.version)}"` : '';
+ const cur = h.current ? ' is-current' : '';
+ return `${h.text} `;
+ })
.join('\n ')}
diff --git a/src/schema-doc.mjs b/src/schema-doc.mjs
new file mode 100644
index 0000000..54ff64d
--- /dev/null
+++ b/src/schema-doc.mjs
@@ -0,0 +1,314 @@
+// 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 `
+Property Type Required Description
+
+${rows.join('\n')}
+
+
`;
+}
+
+// --- 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;
+ // Every version ships in the page; the picker only chooses which one shows.
+ // The latest is marked current in the markup, so it is what paints first and
+ // what a reader without JS lands on above the older ones.
+ const heading = (id, text, level) => {
+ toc.push({ id, text, level, version: label, current: isLatest });
+ return `${text}# `;
+ };
+
+ const parts = [];
+ parts.push(heading(label, `Version ${version}`, 2));
+
+ 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(``);
+
+ 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.
`,
+ );
+
+ parts.push(heading(`${label}-properties`, 'Top-level properties', 3));
+ parts.push(propertyTable(schema, schema, '#', label, conditionals.get('root')));
+
+ for (const [name, def] of Object.entries(schema.$defs ?? {})) {
+ const p = ptr(ptr('#', '$defs'), name);
+ parts.push(heading(`${label}-${name}`, `${esc(name)}`, 3));
+ 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 ``;
+}
+
+// A , not tabs: version counts grow without bound and a picker stays
+// one line at ten versions. Rendered only when there is something to pick.
+//
+// It sits on the title line rather than in the right rail: the rail is
+// display:none under 1100px, which would strand the only way to change version
+// on every tablet and phone. Option labels are short (v10) so the control fits
+// beside the heading, and they match the anchors (#v10) while the section
+// heading below still spells out "Version 10".
+function versionPicker(schema) {
+ if (schema.versions.length < 2) return '';
+ const options = [...schema.versions]
+ .reverse()
+ .map((entry) => {
+ const label = `v${entry.version}`;
+ const suffix = entry === schema.latest ? ' (latest)' : '';
+ const selected = entry === schema.latest ? ' selected' : '';
+ return `${label}${suffix} `;
+ })
+ .join('\n ');
+ return `
+ Schema version
+
+ ${options}
+
+
`;
+}
+
+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 = `
+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 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.title} schema · flashtrace`,
+ description: `JSON Schema reference for the flashtrace ${schema.name} document.`,
+ path: `/docs/schemas/${schema.slug}/`,
+ version,
+ navGroups,
+ toc,
+ content,
+ });
+}
diff --git a/src/schemas.mjs b/src/schemas.mjs
index 5b5f9f9..7639f0a 100644
--- a/src/schemas.mjs
+++ b/src/schemas.mjs
@@ -1,8 +1,8 @@
// 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 rewrites
-// a schema; the JSON is parsed only to reject a release that ships a broken
-// one, since the URL is a published contract.
+// the bytes on the site must match the release exactly. Nothing here parses
+// for rewriting; the JSON is read only to reject a broken release and to
+// render the docs page from it.
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
@@ -12,6 +12,17 @@ import path from 'node:path';
// change here.
const VERSION_FILE = /^v(\d+)\.([A-Za-z0-9]+)$/;
+// "report" + json -> "Report JSON". The directory names the schema, the
+// extension names the format, and the page is titled from both.
+function displayTitle(name, format) {
+ const words = name
+ .split(/[/_-]+/)
+ .filter(Boolean)
+ .map((w) => w[0].toUpperCase() + w.slice(1))
+ .join(' ');
+ return `${words} ${format.toUpperCase()}`;
+}
+
// 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) {
@@ -21,12 +32,13 @@ export function locateSchemas(docsDir) {
// One entry per (directory, format) pair, so a directory that ever holds both
// v1.json and v1.xml keeps two independent version lines - and two latest.*
-// aliases - rather than one muddled one. Returns
-// [{ name, format, versions: [{ version, file, bytes, json }], latest }].
+// aliases - rather than one muddled one, and "Report JSON" and "Report XML"
+// rather than one muddled page. Returns
+// [{ name, format, slug, title, versions: [{ version, file, bytes, json }], latest }].
export function collectSchemas(schemasDir) {
const out = [];
walk(schemasDir, schemasDir, out);
- out.sort((a, b) => (a.name === b.name ? a.format.localeCompare(b.format) : a.name.localeCompare(b.name)));
+ out.sort((a, b) => a.title.localeCompare(b.title));
return out;
}
@@ -53,7 +65,7 @@ function walk(dir, rootDir, out) {
const format = ext.toLowerCase();
const bytes = readFileSync(full);
// Only the JSON documents are parsed - another format is served verbatim
- // and this build has no opinion on its contents.
+ // and the build decides separately whether it can render a page for it.
let json;
if (format === 'json') {
try {
@@ -70,6 +82,17 @@ function walk(dir, rootDir, out) {
for (const [format, versions] of byFormat) {
// Numeric, not lexical: v10 must sort above v9.
versions.sort((a, b) => a.version - b.version);
- out.push({ name, format, versions, latest: versions.at(-1) });
+ out.push({
+ name,
+ format,
+ // Page slug: /docs/schemas/report-json/. Two formats of one schema get
+ // their own page instead of fighting over /docs/schemas/report/. The
+ // pairing is unambiguous because a format is [A-Za-z0-9]+ and so cannot
+ // contain the separating dash.
+ slug: `${name}-${format}`,
+ title: displayTitle(name, format),
+ versions,
+ latest: versions.at(-1),
+ });
}
}
diff --git a/src/scripts/site.js b/src/scripts/site.js
index 1f8d31e..21e907e 100644
--- a/src/scripts/site.js
+++ b/src/scripts/site.js
@@ -107,6 +107,58 @@
pre.appendChild(btn);
});
+ // --- schema version picker ---
+ // Every version is in the page; this only chooses which one is displayed,
+ // so deep links like #v1 and #v1-item keep working and Ctrl-F still finds
+ // the version you are looking at. Without JS the picker is styled away and
+ // all versions render stacked, newest first.
+ var picker = document.querySelector('[data-version-picker]');
+ if (picker) {
+ // sections and their TOC entries both carry data-version
+ var versioned = document.querySelectorAll('[data-version]');
+
+ function showVersion(v) {
+ var found = false;
+ versioned.forEach(function (el) {
+ var on = el.getAttribute('data-version') === v;
+ el.classList.toggle('is-current', on);
+ if (on) found = true;
+ });
+ if (found) picker.value = v;
+ return found;
+ }
+
+ // #v2 names a version outright; #v2-item names a heading inside one.
+ function versionFromHash() {
+ var id = location.hash.slice(1);
+ if (!id) return '';
+ var target = document.getElementById(id);
+ var section = target && target.closest('[data-version]');
+ return section ? section.getAttribute('data-version') : '';
+ }
+
+ var initial = versionFromHash();
+ if (initial && showVersion(initial)) {
+ // The browser already tried to scroll here while the section was still
+ // hidden, which did nothing - so scroll again now that it is visible.
+ var target = document.getElementById(location.hash.slice(1));
+ if (target) target.scrollIntoView();
+ }
+
+ picker.addEventListener('change', function () {
+ if (!showVersion(picker.value)) return;
+ // replaceState, not location.hash: no history entry, no scroll jump
+ history.replaceState(null, '', '#' + picker.value);
+ // the TOC flow marker measures rects, which just changed underneath it
+ window.dispatchEvent(new Event('resize'));
+ });
+
+ window.addEventListener('hashchange', function () {
+ var v = versionFromHash();
+ if (v) showVersion(v);
+ });
+ }
+
// --- right-rail TOC flow indicator ---
// A single rail marker whose top/height track the projection of the visible
// document window onto the TOC entries, so the highlight "flows" across the
@@ -140,12 +192,21 @@
var barTop = Infinity;
var barBottom = -Infinity;
- for (var i = 0; i < sections.length; i++) {
- var s = sections[i];
+ // On a versioned page most sections are display:none and have no box
+ // at all. Drop them first: a zero rect would otherwise read as a
+ // section sitting at the top of the document and drag the marker there.
+ var shown = [];
+ for (var j = 0; j < sections.length; j++) {
+ if (sections[j].heading.getClientRects().length) shown.push(sections[j]);
+ else sections[j].link.classList.remove('is-active');
+ }
+
+ for (var i = 0; i < shown.length; i++) {
+ var s = shown[i];
var secTop = s.heading.getBoundingClientRect().top + scrollY;
var secBottom =
- i + 1 < sections.length
- ? sections[i + 1].heading.getBoundingClientRect().top + scrollY
+ i + 1 < shown.length
+ ? shown[i + 1].heading.getBoundingClientRect().top + scrollY
: contentBottom;
var secH = Math.max(secBottom - secTop, 1);
diff --git a/src/styles/site.css b/src/styles/site.css
index f4444b4..b0534c9 100644
--- a/src/styles/site.css
+++ b/src/styles/site.css
@@ -941,6 +941,72 @@ h3:hover .heading-anchor,
color: var(--muted);
}
+/* schema version picker: a JS-only control, so it does not exist without JS.
+ The .js class lands on before first paint and the latest version is
+ already marked .is-current in the markup, so the page paints on the right
+ version instead of flashing every version and collapsing to one.
+ Scripting off: no picker, every version stacked newest first, all anchors
+ still reachable. */
+/* Title line, not the right rail: .toc is display:none under 1100px, which
+ would stand the picker on every tablet and phone. Wraps under the heading
+ when the two no longer fit side by side. */
+.schema-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem 1.5rem;
+ margin: 0 0 1rem;
+}
+
+.schema-header h1 {
+ margin: 0;
+}
+
+.version-picker {
+ display: none;
+}
+
+.js .version-picker {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+/* Canonical schema URLs are long and unbreakable, and would push the page
+ into a horizontal scroll on a phone. Scoped to these spans so ordinary
+ inline code still breaks only at sensible points. */
+.doc-content code.schema-url {
+ overflow-wrap: anywhere;
+}
+
+.version-picker label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--muted);
+}
+
+.version-picker select {
+ font: inherit;
+ font-size: 0.9rem;
+ color: var(--text);
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.3rem 0.5rem;
+ cursor: pointer;
+}
+
+.version-picker select:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.js .schema-version:not(.is-current),
+.js .toc li[data-version]:not(.is-current) {
+ display: none;
+}
+
/* reveal-on-hover only where hover exists; touch devices get a
permanently visible button, as there is no way to discover it otherwise */
@media (hover: hover) {