419 lines
16 KiB
JavaScript
419 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* gen-schema-matrix.mjs — erzeugt die Referenz-Seite
|
|
* src/content/docs/reference/schema-properties.md aus den JSON-Schemas
|
|
* in ../tibi-types/schemas/config/*.json.
|
|
*
|
|
* Aufruf: npm run gen:schemas (node scripts/gen-schema-matrix.mjs)
|
|
*
|
|
* Regeln:
|
|
* - ESM, Node-Bordmittel, keine neuen Dependencies.
|
|
* - Idempotent: zweiter Lauf = byte-identisches Ergebnis (sortierte,
|
|
* deterministische Ausgabe, kein Zeitstempel im Inhalt).
|
|
* - Bricht mit klarer Fehlermeldung ab, wenn ein Schema fehlt.
|
|
* - Keine erfundenen Properties: alles kommt aus den JSON-Schemas.
|
|
*/
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const SCHEMA_DIR = join(ROOT, '..', 'tibi-types', 'schemas', 'config');
|
|
const OUT = join(ROOT, 'src', 'content', 'docs', 'reference', 'schema-properties.md');
|
|
|
|
// Herkunft der Quelle: Commit + Commit-Datum aus dem tibi-types-Repo.
|
|
// Bewusst deterministisch (kein Aufrufzeitpunkt), damit das Skript idempotent bleibt.
|
|
function sourceStamp() {
|
|
const repo = join(ROOT, '..', 'tibi-types');
|
|
if (!existsSync(join(repo, '.git'))) return '';
|
|
try {
|
|
const hash = execFileSync('git', ['-C', repo, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim().slice(0, 7);
|
|
const date = execFileSync('git', ['-C', repo, 'log', '-1', '--format=%cs'], { encoding: 'utf8' }).trim();
|
|
return `Generiert aus \`tibi-types ${hash}\` (Stand ${date}) — Änderungen an den Schemas dort erfordern \`npm run gen:schemas\`.`;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// Feste Liste: genau diese 17 Schemas erwartet der Generator.
|
|
const SCHEMAS = [
|
|
'action.schema.json',
|
|
'asset.schema.json',
|
|
'collection-meta.schema.json',
|
|
'collection.schema.json',
|
|
'defs.schema.json',
|
|
'embedding.schema.json',
|
|
'field-list.schema.json',
|
|
'field-meta.schema.json',
|
|
'field.schema.json',
|
|
'hooks.schema.json',
|
|
'image-filter.schema.json',
|
|
'job.schema.json',
|
|
'openapi.schema.json',
|
|
'permissions.schema.json',
|
|
'project.schema.json',
|
|
'projections.schema.json',
|
|
'snapshot.schema.json',
|
|
];
|
|
|
|
// --- Laden ---------------------------------------------------------------
|
|
|
|
const missing = SCHEMAS.filter((f) => !existsSync(join(SCHEMA_DIR, f)));
|
|
if (missing.length > 0) {
|
|
console.error(`[gen:schemas] FEHLER: ${missing.length} Schema(s) fehlen in ${SCHEMA_DIR}:`);
|
|
for (const f of missing) console.error(` - ${f}`);
|
|
console.error('[gen:schemas] Abgebrochen — keine Datei geschrieben.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const store = new Map();
|
|
for (const f of SCHEMAS) {
|
|
store.set(f, JSON.parse(readFileSync(join(SCHEMA_DIR, f), 'utf8')));
|
|
}
|
|
|
|
// --- Helfer ----------------------------------------------------------------
|
|
|
|
/** Zelle für Markdown-Tabellen säubern: Pipes escapen, Umbrüche glätten. */
|
|
function cell(text) {
|
|
return String(text ?? '—')
|
|
.replace(/\r?\n/g, '<br>')
|
|
.replace(/\|/g, '\\|')
|
|
.trim() || '—';
|
|
}
|
|
|
|
function fmtValue(v) {
|
|
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
return s.length > 60 ? `${s.slice(0, 57)}…` : s;
|
|
}
|
|
|
|
/** Kurzname eines $ref-Ziels, z. B. "defs.schema.json#/definitions/boolOrEval" -> "boolOrEval (defs)". */
|
|
function refShort(ref) {
|
|
const [file, pointer] = ref.split('#');
|
|
const base = (file || '').replace('.schema.json', '') || 'dieses Schema';
|
|
const name = (pointer || '').split('/').pop() || file || ref;
|
|
if (!file) return `\`${name}\``;
|
|
if (!pointer || pointer === '') return `\`${file}\``;
|
|
return `\`${name}\` (${base})`;
|
|
}
|
|
|
|
/**
|
|
* $ref auflösen (max. 3 Ebenen, Zykelschutz). Gibt { file, node } oder null.
|
|
* Versteht "datei.json#/definitions/x", "datei.json" (ganzes Schema)
|
|
* und "#/definitions/x" (lokal).
|
|
*/
|
|
function resolveRef(ref, fromFile, seen = new Set()) {
|
|
if (seen.has(`${fromFile}::${ref}`)) return null;
|
|
seen.add(`${fromFile}::${ref}`);
|
|
const [file, pointer] = ref.split('#');
|
|
const targetFile = file || fromFile;
|
|
const target = store.get(targetFile);
|
|
if (!target) return null;
|
|
if (!pointer || pointer === '' || pointer === '#') return { file: targetFile, node: target };
|
|
const parts = pointer.replace(/^#/, '').split('/').filter(Boolean);
|
|
let node = target;
|
|
for (const p of parts) {
|
|
node = node?.[p];
|
|
if (node === undefined) return null;
|
|
}
|
|
if (node && typeof node === 'object' && node.$ref && seen.size < 4) {
|
|
return resolveRef(node.$ref, targetFile, seen) ?? { file: targetFile, node };
|
|
}
|
|
return { file: targetFile, node };
|
|
}
|
|
|
|
/** Trägt der Zweig eigene Typ-Info (vs. reine Pflicht-Constraints wie anyOf:[{required}])? */
|
|
function hasTypeInfo(node) {
|
|
if (!node || typeof node !== 'object') return false;
|
|
return ['type', 'enum', 'const', '$ref', 'oneOf', 'anyOf', 'properties', 'items', 'patternProperties']
|
|
.some((k) => node[k] !== undefined);
|
|
}
|
|
|
|
/** Typ-Kurzform eines Schema-Knotens ohne $ref/oneOf-Auflösung (Rekursionsschutz). */
|
|
function baseType(node) {
|
|
if (!node || typeof node !== 'object') return 'beliebig';
|
|
if (node.const !== undefined) return `fest: \`${fmtValue(node.const)}\``;
|
|
const opts = node.oneOf || node.anyOf;
|
|
if (Array.isArray(opts)) {
|
|
const typed = opts.filter(hasTypeInfo);
|
|
if (typed.length > 0) return typed.map((o) => baseType(o)).join(' oder ');
|
|
}
|
|
if (node.$ref) return refShort(node.$ref);
|
|
const t = node.type;
|
|
const tStr = Array.isArray(t) ? t.join(' oder ') : t;
|
|
if (node.enum) {
|
|
const vals = node.enum.map((v) => `\`${fmtValue(v)}\``).join(', ');
|
|
return `${tStr ? `${tStr}, ` : ''}eine von: ${vals}`;
|
|
}
|
|
if (tStr === 'array' && node.items) return `Liste von ${baseType(node.items)}`;
|
|
if (tStr === 'object' && node.properties) return 'object';
|
|
if (tStr) return tStr;
|
|
if (node.properties || node.patternProperties) return 'object';
|
|
if (node.items) return `Liste von ${baseType(node.items)}`;
|
|
if (node.additionalProperties) return 'object mit freien Werten';
|
|
return 'beliebig';
|
|
}
|
|
|
|
/**
|
|
* Menschenlesbarer Typ eines Schema-Knotens.
|
|
* Löst $ref (Zielname + dessen Typ) und oneOf/anyOf (Optionen) auf.
|
|
*/
|
|
function typeLabel(node, fromFile, depth = 0) {
|
|
if (!node || typeof node !== 'object') return 'beliebig';
|
|
if (node.$ref && depth < 8) {
|
|
const short = refShort(node.$ref);
|
|
const hit = resolveRef(node.$ref, fromFile);
|
|
if (hit && hit.node && typeof hit.node === 'object') {
|
|
const inner = baseType(hit.node);
|
|
const rest = { ...node };
|
|
delete rest.$ref;
|
|
delete rest.description;
|
|
delete rest.title;
|
|
const extra = Object.keys(rest).some((k) =>
|
|
['type', 'enum', 'const', 'oneOf', 'anyOf', 'items', 'properties'].includes(k))
|
|
? `, ${baseType(rest)}`
|
|
: '';
|
|
return `${short} — ${inner}${extra}`;
|
|
}
|
|
return short;
|
|
}
|
|
const opts = node.oneOf || node.anyOf;
|
|
if (Array.isArray(opts)) {
|
|
const typed = opts.filter(hasTypeInfo);
|
|
if (typed.length > 0) {
|
|
const joiner = node.oneOf ? ' oder ' : ' bzw. ';
|
|
const inner = typed.map((o) => typeLabel(o, fromFile, depth + 1)).join(joiner);
|
|
return depth > 0 ? `(${inner})` : inner;
|
|
}
|
|
}
|
|
if (node.const !== undefined) return `fest: \`${fmtValue(node.const)}\``;
|
|
const t = node.type;
|
|
const tStr = Array.isArray(t) ? t.join(' oder ') : t;
|
|
if (node.enum) {
|
|
const vals = node.enum.map((v) => `\`${fmtValue(v)}\``).join(', ');
|
|
return `${tStr ? `${tStr}, ` : ''}eine von: ${vals}`;
|
|
}
|
|
if (tStr === 'array' && node.items) return `Liste von ${typeLabel(node.items, fromFile, depth + 1)}`;
|
|
if (tStr) return tStr;
|
|
if (node.properties || node.patternProperties) return 'object';
|
|
if (node.items) return `Liste von ${typeLabel(node.items, fromFile, depth + 1)}`;
|
|
if (node.additionalProperties) return 'object mit freien Werten';
|
|
return 'beliebig';
|
|
}
|
|
|
|
function defaultLabel(node) {
|
|
if (!node || typeof node !== 'object') return '—';
|
|
if (node.default !== undefined) return `\`${fmtValue(node.default)}\``;
|
|
return '—';
|
|
}
|
|
|
|
function descLabel(node) {
|
|
if (!node || typeof node !== 'object') return '—';
|
|
return node.description || node.title || '—';
|
|
}
|
|
|
|
/** Markdown-Tabelle mit fester Spaltenzahl bauen. */
|
|
function table(header, rows) {
|
|
const lines = [`| ${header.join(' | ')} |`, `| ${header.map(() => '---').join(' | ')} |`];
|
|
for (const r of rows) lines.push(`| ${r.map(cell).join(' | ')} |`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const PROP_HEADER = ['Property', 'Typ', 'Pflicht', 'Vorgabe/Default', 'Beschreibung'];
|
|
|
|
/**
|
|
* Effektives Objekt-Schema bestimmen: Top-Level-$ref (field.schema.json)
|
|
* wird zum Ziel in definitions/ aufgelöst.
|
|
*/
|
|
function effectiveSchema(file, schema) {
|
|
if (schema.$ref && !schema.properties) {
|
|
const hit = resolveRef(schema.$ref, file);
|
|
if (hit && hit.node && hit.node.properties) {
|
|
return { node: hit.node, via: schema.$ref };
|
|
}
|
|
}
|
|
return { node: schema, via: null };
|
|
}
|
|
|
|
// --- Seitenaufbau ------------------------------------------------------------
|
|
|
|
const out = [];
|
|
out.push('---');
|
|
out.push("title: 'Schema-Properties (generiert)'");
|
|
out.push("description: 'Alle Properties aller 17 Config-Schemas als Tabelle — generiert, nicht von Hand editieren.'");
|
|
out.push('---');
|
|
out.push('');
|
|
out.push('Diese Seite listet die Properties aller 17 Config-Schemas aus `tibi-types/schemas/config/`.');
|
|
out.push('');
|
|
out.push('> Generiert, nicht von Hand editieren — neu erzeugen mit `npm run gen:schemas`.');
|
|
out.push('');
|
|
out.push('Typnamen folgen JSON-Schema (`string`, `integer`, `number`, `boolean`, `object`, `Liste von …`);');
|
|
out.push('`$ref`-Verweise nennen den Baustein und dessen Typ, `oneOf`-Alternativen sind mit „oder“ verbunden.');
|
|
out.push('');
|
|
|
|
let tableCount = 0;
|
|
const perSchema = [];
|
|
|
|
for (const file of SCHEMAS) {
|
|
const schema = store.get(file);
|
|
const short = file.replace('.schema.json', '');
|
|
const required = schema.required || [];
|
|
out.push(`## ${short}`);
|
|
out.push('');
|
|
if (schema.title) out.push(`*${cell(schema.title)}* — \`${file}\``);
|
|
else out.push(`\`${file}\``);
|
|
out.push('');
|
|
|
|
// Sonderfall 1: definitions ohne Properties (defs.schema.json).
|
|
const { node: eff, via } = effectiveSchema(file, schema);
|
|
const props = eff.properties || {};
|
|
const propNames = Object.keys(props);
|
|
|
|
if (via) out.push(`Bildet den Baustein \`${via.split('/').pop()}\` dieses Schemas ab.`, '');
|
|
|
|
if (propNames.length > 0) {
|
|
if (required.length > 0) {
|
|
out.push(`Pflicht: ${required.map((r) => `\`${r}\``).join(', ')}.`);
|
|
out.push('');
|
|
}
|
|
// typeLabel braucht den Dateikontext für $ref-Auflösung: Zeilen mit file neu bauen.
|
|
const req = new Set(eff.required || required);
|
|
const rows = Object.entries(props).map(([name, sub]) => [
|
|
`\`${name}\``,
|
|
typeLabel(sub, file),
|
|
req.has(name) ? 'ja' : 'nein',
|
|
defaultLabel(sub),
|
|
descLabel(sub),
|
|
]);
|
|
out.push(table(PROP_HEADER, rows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
perSchema.push({ schema: short, properties: rows.length, note: '' });
|
|
} else if (schema.type === 'array' && schema.items) {
|
|
// Sonderfall 2: Listen-Schema (field-list.schema.json).
|
|
out.push('Dieses Schema beschreibt eine **Liste** — keine Objekt-Properties, sondern Elemente:');
|
|
out.push('');
|
|
out.push(table(PROP_HEADER, [[
|
|
'`[]` (Elemente)',
|
|
typeLabel(schema.items, file),
|
|
'—',
|
|
'—',
|
|
'Jedes Element ist entweder ein Feldname (`string`) oder ein Feld-Objekt nach `field.schema.json`.',
|
|
]]));
|
|
out.push('');
|
|
tableCount += 1;
|
|
perSchema.push({ schema: short, properties: 1, note: 'Listen-Elemente' });
|
|
} else if (schema.patternProperties) {
|
|
// Sonderfall 3: freie Schlüssel (image-filter, projections).
|
|
const patterns = Object.entries(schema.patternProperties).filter(([k]) => k !== '^x-');
|
|
const rows = patterns.map(([pat, sub]) => [
|
|
`\`<name>\` (Muster \`${pat}\`)`,
|
|
typeLabel(sub, file),
|
|
'—',
|
|
'—',
|
|
descLabel(sub) !== '—' ? descLabel(sub) : 'Freier Schlüssel, Wert siehe Untertabelle.',
|
|
]);
|
|
out.push('Dieses Schema nutzt **freie Schlüssel** (Pattern):');
|
|
out.push('');
|
|
out.push(table(PROP_HEADER, rows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
let innerProps = 0;
|
|
for (const [pat, sub] of patterns) {
|
|
let inner = sub;
|
|
if (sub.type === 'array' && sub.items && sub.items.properties) inner = sub.items;
|
|
if (inner && inner.properties) {
|
|
const req = new Set(inner.required || []);
|
|
const irows = Object.entries(inner.properties).map(([name, s]) => [
|
|
`\`${name}\``,
|
|
typeLabel(s, file),
|
|
req.has(name) ? 'ja' : 'nein',
|
|
defaultLabel(s),
|
|
descLabel(s),
|
|
]);
|
|
out.push(`### Wertefelder zu \`${pat}\``);
|
|
out.push('');
|
|
out.push(table(PROP_HEADER, irows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
innerProps += irows.length;
|
|
} else if (inner && inner.properties === undefined && sub.properties) {
|
|
const req = new Set(sub.required || []);
|
|
const irows = Object.entries(sub.properties).map(([name, s]) => [
|
|
`\`${name}\``,
|
|
typeLabel(s, file),
|
|
req.has(name) ? 'ja' : 'nein',
|
|
defaultLabel(s),
|
|
descLabel(s),
|
|
]);
|
|
out.push(`### Wertefelder zu \`${pat}\``);
|
|
out.push('');
|
|
out.push(table(PROP_HEADER, irows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
innerProps += irows.length;
|
|
}
|
|
}
|
|
perSchema.push({ schema: short, properties: rows.length + innerProps, note: 'patternProperties + Wertefelder' });
|
|
} else if (Object.keys(schema.definitions || {}).length > 0 || Object.keys(schema.$defs || {}).length > 0) {
|
|
// Sonderfall 4: nur Bausteine (defs.schema.json).
|
|
const defs = schema.definitions || schema.$defs;
|
|
const rows = Object.entries(defs).map(([name, sub]) => [
|
|
`\`${name}\``,
|
|
baseType(sub),
|
|
sub.required ? `Pflicht: ${sub.required.map((r) => `\`${r}\``).join(', ')}` : '—',
|
|
sub.description || '—',
|
|
]);
|
|
out.push('Dieses Schema definiert nur **`$ref`-Bausteine** (kein eigenes Config-Ziel):');
|
|
out.push('');
|
|
out.push(table(['Baustein', 'Typ', 'Pflicht', 'Beschreibung'], rows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
perSchema.push({ schema: short, properties: rows.length, note: '$ref-Bausteine' });
|
|
} else {
|
|
throw new Error(`[gen:schemas] Schema ohne Properties/Items/patternProperties/definitions: ${file}`);
|
|
}
|
|
|
|
// Baustein-Übersicht, wenn das Schema eigene definitions mit Properties hat.
|
|
const defs = schema.definitions || schema.$defs;
|
|
if (defs && propNames.length > 0) {
|
|
const withProps = Object.entries(defs).filter(([, d]) => d && d.properties);
|
|
if (withProps.length > 0) {
|
|
out.push(`### Bausteine in ${short}`);
|
|
out.push('');
|
|
const drows = withProps.map(([name, d]) => [
|
|
`\`${name}\``,
|
|
Object.keys(d.properties).map((p) => `\`${p}\``).join(', '),
|
|
d.required ? d.required.map((r) => `\`${r}\``).join(', ') : '—',
|
|
]);
|
|
out.push(table(['Baustein', 'Felder', 'Pflicht'], drows));
|
|
out.push('');
|
|
tableCount += 1;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
mkdirSync(dirname(OUT), { recursive: true });
|
|
const content = `${out.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
const herkunft = sourceStamp();
|
|
const final = herkunft ? content.replace(/\n$/, `\n\n${herkunft}\n`) : content;
|
|
writeFileSync(OUT, final);
|
|
|
|
// --- Bericht ------------------------------------------------------------------
|
|
|
|
const total = perSchema.reduce((a, p) => a + p.properties, 0);
|
|
console.log(`[gen:schemas] geschrieben: ${OUT}`);
|
|
console.log(`[gen:schemas] Schemas: ${perSchema.length}, Tabellen: ${tableCount}, Properties gesamt: ${total}`);
|
|
for (const p of perSchema) console.log(` - ${p.schema}: ${p.properties} Zeilen${p.note ? ` (${p.note})` : ''}`);
|
|
|
|
// Selbstprüfung: keine leeren Tabellen, keine [object Object]-Reste.
|
|
if (/\| *\| *\|/.test(content)) {
|
|
console.error('[gen:schemas] FEHLER: leere Tabellenzellen gefunden.');
|
|
process.exit(1);
|
|
}
|
|
if (content.includes('[object Object]')) {
|
|
console.error('[gen:schemas] FEHLER: [object Object]-Reste gefunden.');
|
|
process.exit(1);
|
|
}
|