#!/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, '
') .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]) => [ `\`\` (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); }