Audit + Umbau: Faktenfehler behoben, Server-/Admin-Lücken geschlossen, 49 Screenshots, Schema-Matrix generiert
- Audit: plans/doku-audit.md (Befunde, Phasen P1-P9, Ausführungsprotokoll) + plans/audit/ (8 Detailreports) + plans/offene-issues.md - Werkzeuge: scripts/audit-docs.mjs (Links/Anker/Bilder/Tabellen), scripts/gen-schema-matrix.mjs (npm run gen:schemas) - Neue Seiten: projekt/feld-rechte, reference/rest-system, reference/schema-properties, projekt/tests, 5 Anwender-Seiten - Fehler behoben: EMBEDDING_BGE_M3_*-Env, allowSnapshots, MCP-Scopes (.read/.create/.update), meta.views, loadAll-Signatur, OpenAPI-Endpunkt, MongoDB 8, Befehlspalette - Screenshots: 9 Admin + 10 Anwender aus Mock-Projekt corporate (t1 entfernt), 49 Bilder gesamt - AGENTS.md: Sync-Behauptung richtiggestellt, Belegpflicht/UI-Namen/Screenshot-Regeln, Werkzeug-Abschnitt - Build: 71 Seiten grün, 0 tote Links, 0 kaputte Tabellen
This commit is contained in:
1 parent
1897bb50d9
commit
1928922e07
104 files changed
+4756
-1044
No files matched your search
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Struktur-Audit der Tibi-Doku.
|
||||
* Prüft Mechanik (Frontmatter, Codeblöcke, Links, Anker, Bilder, Tabellen,
|
||||
* Stil-Auffälligkeiten, Starlight-Komponenten) und schreibt plans/audit-report.json.
|
||||
*
|
||||
* Aufruf: node scripts/audit-docs.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..');
|
||||
const DOCS = path.join(ROOT, 'src/content/docs');
|
||||
|
||||
const walk = (dir) =>
|
||||
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
});
|
||||
|
||||
const files = walk(DOCS).filter((f) => /\.mdx?$/.test(f)).sort();
|
||||
const rel = (f) => path.relative(ROOT, f);
|
||||
|
||||
// --- Seitenregister für Link-/Ankerprüfung -------------------------------
|
||||
const pages = new Map(); // slug -> {file, anchors:Set, title}
|
||||
const slugOf = (file) => path.relative(DOCS, file).replace(/\.mdx?$/, '').replace(/\/index$/, '') || 'index';
|
||||
|
||||
const slugifyHeading = (h) =>
|
||||
h
|
||||
.toLowerCase()
|
||||
.replace(/[`*_]/g, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
const raw = new Map();
|
||||
for (const f of files) raw.set(f, fs.readFileSync(f, 'utf8'));
|
||||
|
||||
for (const f of files) {
|
||||
const text = raw.get(f);
|
||||
const anchors = new Set();
|
||||
const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
||||
let inFence = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
|
||||
if (inFence) continue;
|
||||
const m = /^(#{2,6})\s+(.*)$/.exec(line);
|
||||
if (m) {
|
||||
let s = slugifyHeading(m[2]);
|
||||
let n = s, i = 1;
|
||||
while (anchors.has(n)) n = `${s}-${i++}`;
|
||||
anchors.add(s);
|
||||
anchors.add(n);
|
||||
}
|
||||
}
|
||||
const fmTitle = /^---\r?\n[\s\S]*?\btitle:\s*(.+)$/m.exec(text)?.[1]?.trim();
|
||||
pages.set(slugOf(f), { file: rel(f), anchors, title: fmTitle });
|
||||
}
|
||||
|
||||
// --- Prüfungen ------------------------------------------------------------
|
||||
const findings = [];
|
||||
const add = (file, kind, msg, line) => findings.push({ file: rel(file), kind, msg, line: line ?? null });
|
||||
|
||||
const STARLIGHT_COMPONENTS = [
|
||||
'Aside', 'Badge', 'Card', 'CardGrid', 'Code', 'FileTree', 'Icon', 'LinkButton', 'LinkCard',
|
||||
'Steps', 'TabItem', 'Tabs', 'Icon', 'MarkdownContent', 'LinkCard',
|
||||
];
|
||||
|
||||
const stats = {
|
||||
files: files.length,
|
||||
byDir: {},
|
||||
components: {},
|
||||
lacksDescription: [],
|
||||
emptyFenceLang: 0,
|
||||
proseWalls: [],
|
||||
asciiUmlauts: [],
|
||||
hollowMarkers: [],
|
||||
t1Refs: [],
|
||||
tibiAdminRefs: [],
|
||||
};
|
||||
|
||||
for (const f of files) {
|
||||
const text = raw.get(f);
|
||||
const lines = text.split(/\r?\n/);
|
||||
const dir = path.relative(DOCS, f).split(path.sep)[0];
|
||||
stats.byDir[dir] = (stats.byDir[dir] ?? 0) + 1;
|
||||
|
||||
// Frontmatter
|
||||
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text);
|
||||
if (!fm) add(f, 'frontmatter', 'Kein Frontmatter-Block');
|
||||
else {
|
||||
if (!/\btitle:/.test(fm[1])) add(f, 'frontmatter', 'title fehlt');
|
||||
const desc = /\bdescription:\s*(.*)$/m.exec(fm[1])?.[1]?.trim();
|
||||
if (!desc) { add(f, 'frontmatter', 'description fehlt'); stats.lacksDescription.push(rel(f)); }
|
||||
else if (desc.replace(/["']/g, '').length > 160) add(f, 'frontmatter', `description zu lang (${desc.length} Zeichen)`);
|
||||
for (const key of ['sidebar', 'tableOfContents', 'hero', 'template', 'draft', 'lastUpdated', 'banner']) {
|
||||
if (new RegExp(`^${key}:`, 'm').test(fm[1])) stats[`fm_${key}`] = (stats[`fm_${key}`] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Codeblöcke
|
||||
let fenceOpen = null;
|
||||
let fenceCount = 0;
|
||||
const fences = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = /^\s*(```|~~~)(.*)$/.exec(lines[i]);
|
||||
if (!m) continue;
|
||||
if (fenceOpen === null) {
|
||||
fenceOpen = { line: i + 1, lang: m[2].trim() };
|
||||
fenceCount++;
|
||||
if (!m[2].trim()) { add(f, 'codefence', 'Codeblock ohne Sprach-Tag', i + 1); stats.emptyFenceLang++; }
|
||||
} else {
|
||||
fences.push({ start: fenceOpen.line, end: i + 1, lang: fenceOpen.lang });
|
||||
if (m[2].trim()) add(f, 'codefence', `Schließender Zaun mit Inhalt: "${m[2].trim()}"`, i + 1);
|
||||
fenceOpen = null;
|
||||
}
|
||||
}
|
||||
if (fenceOpen !== null) add(f, 'codefence', 'Nicht geschlossener Codeblock', fenceOpen.line);
|
||||
|
||||
// Inline-Code unbalanciert (grobe Heuristik)
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) return;
|
||||
const ticks = (line.match(/(?<!`)`(?!`)/g) ?? []).length;
|
||||
if (ticks % 2 === 1 && !line.includes('```')) add(f, 'inlinecode', 'Ungerade Zahl einfacher Backticks', i + 1);
|
||||
});
|
||||
|
||||
// Überschriften-Hierarchie + leer
|
||||
const fmOffset = fm ? fm[0].split(/\r?\n/).length - 1 : 0;
|
||||
const bodyLines = lines.slice(fmOffset);
|
||||
let prevLevel = 1;
|
||||
let headFence = false;
|
||||
bodyLines.forEach((line, i) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) { headFence = !headFence; return; }
|
||||
if (headFence) return;
|
||||
const m = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (!m) return;
|
||||
const lvl = m[1].length;
|
||||
const cline = (fm ? fm[0].split(/\r?\n/).length - 1 : 0) + i + 1;
|
||||
if (lvl === 1) add(f, 'heading', 'H1 im Body (Titel kommt aus Frontmatter)', cline);
|
||||
if (lvl > prevLevel + 1) add(f, 'heading', `Sprung von H${prevLevel} auf H${lvl}`, cline);
|
||||
if (!m[2].trim()) add(f, 'heading', 'Leere Überschrift', cline);
|
||||
if (m[2].trim().length > 80) add(f, 'heading', `Sehr lange Überschrift (${m[2].trim().length})`, cline);
|
||||
prevLevel = lvl;
|
||||
});
|
||||
|
||||
// Links + Anker
|
||||
const linkRe = /(\[)([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||
let lm;
|
||||
while ((lm = linkRe.exec(text))) {
|
||||
if (text[lm.index - 1] === '!') continue; // Bild, separat geprüft
|
||||
const target = lm[3];
|
||||
if (/^(https?:|mailto:|#)/.test(target)) {
|
||||
if (target.startsWith('#') && !pages.get(slugOf(f))?.anchors.has(target.slice(1)))
|
||||
add(f, 'anchor', `Lokaler Anker ohne Ziel: ${target}`);
|
||||
continue;
|
||||
}
|
||||
const line = text.slice(0, lm.index).split(/\r?\n/).length;
|
||||
const [p, anchor] = target.split('#');
|
||||
let slug = p.replace(/^\//, '').replace(/\/$/, '');
|
||||
if (slug.endsWith('.md')) slug = slug.slice(0, -3);
|
||||
if (slug === '') slug = 'index';
|
||||
// relative Links
|
||||
const targetPage = pages.get(slug) ?? pages.get(slug + '/index');
|
||||
if (!targetPage) { add(f, 'link', `Interner Link ohne Ziel: ${target}`, line); continue; }
|
||||
if (anchor && !targetPage.anchors.has(anchor))
|
||||
add(f, 'anchor', `Anker nicht gefunden: ${target}`, line);
|
||||
}
|
||||
|
||||
// Bilder
|
||||
const imgRe = /!\[([^\]]*)\]\(([^)\s]+)/g;
|
||||
let im;
|
||||
while ((im = imgRe.exec(text))) {
|
||||
const line = text.slice(0, im.index).split(/\r?\n/).length;
|
||||
if (/^https?:/.test(im[2])) { add(f, 'image', `Externes Bild (unsicher): ${im[2]}`, line); continue; }
|
||||
const p = path.resolve(path.dirname(f), im[2].split('#')[0].split('?')[0]);
|
||||
if (!fs.existsSync(p)) add(f, 'image', `Bilddatei fehlt: ${im[2]}`, line);
|
||||
if (!im[1].trim()) add(f, 'image', 'Bild ohne Alt-Text', line);
|
||||
}
|
||||
|
||||
// Starlight-Komponenten
|
||||
for (const c of new Set(STARLIGHT_COMPONENTS)) {
|
||||
const n = (text.match(new RegExp(`<${c}[\\s/>]`, 'g')) ?? []).length;
|
||||
if (n) stats.components[c] = (stats.components[c] ?? 0) + n;
|
||||
}
|
||||
const imports = text.match(/import\s*\{([^}]*)\}\s*from\s*'@astrojs\/starlight\/components'/);
|
||||
if (imports) for (const c of imports[1].split(',').map((s) => s.trim())) stats.components[c] = (stats.components[c] ?? 0) + 0;
|
||||
|
||||
// Rohes HTML in .md (funktioniert, aber inkonsistent)
|
||||
const htmlHits = text.match(/^\s*<(div|span|p|table|ul|br|img|details|summary|h[1-6])[\s>]/gm);
|
||||
if (htmlHits) stats.rawHtml = (stats.rawHtml ?? 0) + htmlHits.length;
|
||||
|
||||
// Tabellen-Konsistenz
|
||||
const tlines = lines.map((l, i) => ({ l, i: i + 1 })).filter(({ l }) => /^\s*\|.*\|\s*$/.test(l));
|
||||
let tbl = [];
|
||||
const flush = () => {
|
||||
if (tbl.length >= 2) {
|
||||
const cols = tbl.map((t) => t.l.trim().replace(/^\||\|$/g, '').split('|').length);
|
||||
const uniq = new Set(cols);
|
||||
if (uniq.size > 1) add(f, 'table', `Tabellenzeilen mit unterschiedlicher Spaltenzahl: ${cols.join(',')}`, tbl[0].i);
|
||||
if (tbl.length > 1 && !/^\s*\|[\s:|-]+\|\s*$/.test(tbl[1].l)) add(f, 'table', 'Trenner-Zeile der Tabelle fehlt/ungültig', tbl[1].i);
|
||||
}
|
||||
tbl = [];
|
||||
};
|
||||
for (const t of tlines) {
|
||||
if (tbl.length && t.i !== tbl[tbl.length - 1].i + 1) flush();
|
||||
tbl.push(t);
|
||||
}
|
||||
flush();
|
||||
|
||||
// Absatz-Wände (Stil)
|
||||
const stripped = text.split('\n\n').filter((b) => !/^\s*[|#>]/.test(b) && !/^---/.test(b.trim()) && !/^\s*</.test(b));
|
||||
stripped.forEach((b) => {
|
||||
const plain = b.replace(/`[^`]*`/g, '').trim();
|
||||
if (plain.length > 900 && !plain.includes('\n- ') && (plain.match(/\.\s/g) ?? []).length > 8)
|
||||
stats.proseWalls.push(`${rel(f)} (${plain.length} Zeichen)`);
|
||||
});
|
||||
|
||||
// ASCII-Umlaute (nur häufige Wörter, wenig False Positives)
|
||||
if (/\b(fuer|ueber|moeglich|waehrend|groesse|schluessel|zurueck|koennen|muessen|moechte|aendern|ausser|gemaeß|Grundeinstellung)\b/i.test(text))
|
||||
stats.asciiUmlauts.push(rel(f));
|
||||
if (/(?:waehl|einstell(?:ung)?en?|benutz(?:er)?|sc(?:h)luessel)/i.test('') ) {} // noop
|
||||
|
||||
// Hüllen-Reste (nur Fließtext — in Codeblöcken sind „Platzhalter"/„TODO" legitime Beispieltexte)
|
||||
const proseText = (() => {
|
||||
const out = [];
|
||||
let f = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*(```|~~~)/.test(line)) { f = !f; continue; }
|
||||
if (!f) out.push(line);
|
||||
}
|
||||
return out.join('\n');
|
||||
})();
|
||||
['Für spätere Agenten', 'TODO', 'FIXME', 'noch nicht', 'Hülle', 'Platzhalter', 'TBD', 'WIP']
|
||||
.forEach((k) => {
|
||||
const idx = proseText.indexOf(k);
|
||||
if (idx < 0) return;
|
||||
const line = proseText.slice(0, idx).split(/\r?\n/).length;
|
||||
const ctx = proseText.split(/\r?\n/)[line - 1]?.trim().slice(0, 110) ?? '';
|
||||
add(f, 'hollow', `Marker "${k}": ${ctx}`, line);
|
||||
stats.hollowMarkers.push(`${rel(f)}:${line} ${k}`);
|
||||
});
|
||||
|
||||
// Verbotene Namen
|
||||
if (/\bt1\b/.test(text)) stats.t1Refs.push(rel(f));
|
||||
const badAdmin = text.match(/tibi-admin(?!-nova)/g);
|
||||
if (badAdmin) stats.tibiAdminRefs.push(`${rel(f)} (${badAdmin.length}×)`);
|
||||
}
|
||||
|
||||
// --- Sidebar-Abgleich -----------------------------------------------------
|
||||
const cfg = fs.readFileSync(path.join(ROOT, 'astro.config.mjs'), 'utf8');
|
||||
const sidebarSlugs = [...cfg.matchAll(/slug:\s*'([^']+)'/g)].map((m) => m[1]);
|
||||
const pageSlugs = [...pages.keys()].filter((s) => s !== 'index');
|
||||
const notInSidebar = pageSlugs.filter((s) => !sidebarSlugs.includes(s));
|
||||
const sidebarMissing = sidebarSlugs.filter((s) => !pages.has(s));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
stats,
|
||||
sidebar: { count: sidebarSlugs.length, notInSidebar, sidebarMissing },
|
||||
findings,
|
||||
summary: {
|
||||
total: findings.length,
|
||||
byKind: findings.reduce((a, f) => ({ ...a, [f.kind]: (a[f.kind] ?? 0) + 1 }), {}),
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.join(ROOT, 'plans'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ROOT, 'plans/audit-report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report.summary, null, 2));
|
||||
console.log('Seiten:', stats.files, JSON.stringify(stats.byDir));
|
||||
console.log('Nicht in Sidebar:', notInSidebar);
|
||||
console.log('Sidebar ohne Datei:', sidebarMissing);
|
||||
console.log('Komponenten:', JSON.stringify(stats.components));
|
||||
console.log('\nFindings:');
|
||||
for (const f of findings) console.log(` ${f.file}:${f.line ?? '-'} [${f.kind}] ${f.msg}`);
|
||||
@@ -0,0 +1,417 @@
|
||||
#!/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 16 Schemas erwartet der Generator.
|
||||
const SCHEMAS = [
|
||||
'action.schema.json',
|
||||
'asset.schema.json',
|
||||
'collection-meta.schema.json',
|
||||
'collection.schema.json',
|
||||
'defs.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 16 Config-Schemas als Tabelle — generiert, nicht von Hand editieren.'");
|
||||
out.push('---');
|
||||
out.push('');
|
||||
out.push('Diese Seite listet die Properties aller 16 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);
|
||||
}
|
||||
@@ -3,26 +3,40 @@
|
||||
* sync-openapi.mjs — holt die OpenAPI-Spec vom laufenden Tibi-Server
|
||||
* und legt sie als Referenz-Seite ab. Ohne laufenden Server: Platzhalter.
|
||||
* Aufruf: npm run sync:openapi
|
||||
* Env: TIBI_API_URL (default http://localhost:8080/api), TIBI_NAMESPACE
|
||||
* Env: TIBI_API_URL (default http://localhost:8080/api/v1), TIBI_NAMESPACE
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const base = process.env.TIBI_API_URL ?? 'http://localhost:8080/api';
|
||||
const base = process.env.TIBI_API_URL ?? 'http://localhost:8080/api/v1';
|
||||
const namespace = process.env.TIBI_NAMESPACE;
|
||||
const out = join(root, 'src', 'content', 'docs', 'reference', 'openapi.md');
|
||||
|
||||
const frontmatter = `---\ntitle: 'OpenAPI (generiert)'\ndescription: 'Projektspezifische OpenAPI-Spec vom laufenden tibi-server: Endpunkt, Abruf per curl und Sync.'\n---\n`;
|
||||
const erklaerung = `Die Spec erzeugt der Server frisch aus Config und Collections deines Projekts: \`GET /api/v1/_openapi/:project\`. Es gibt keine statische \`/openapi.json\` — jede Spec gehört zu genau einem Projekt (Namespace).\n\n\`\`\`bash title="Spec eines Projekts abrufen"\ncurl http://localhost:8080/api/v1/_openapi/meinprojekt\n\`\`\`\n\n\`npm run sync:openapi\` ruft diesen Endpunkt für \`$TIBI_NAMESPACE\` auf (Basis \`TIBI_API_URL\`, Default \`http://localhost:8080/api/v1\`) und schreibt das Ergebnis in diese Seite.\n\nLäuft der Admin hinter seinem Proxy, erreichst du dieselbe Spec auch über \`$CODING_TIBIADMIN_URL/api/_openapi/<namespace>\` — der Proxy schreibt \`/api\` auf \`/api/v1\` um.\n`;
|
||||
|
||||
function schreiben(stand, specBlock) {
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, `${frontmatter}\n> Stand: ${stand} — nicht von Hand editieren.\n\n${erklaerung}${specBlock}`);
|
||||
}
|
||||
|
||||
if (!namespace) {
|
||||
console.warn('[sync:openapi] TIBI_NAMESPACE fehlt — Platzhalter bleibt.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/openapi.json`);
|
||||
const url = `${base}/_openapi/${namespace}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const spec = await res.text();
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(
|
||||
out,
|
||||
`---\ntitle: 'OpenAPI (generiert)'\n---\n\n> Stand: ${new Date().toISOString()} · Quelle: \`${base}/openapi.json\` — nicht von Hand editieren.\n\n\`\`\`json\n${spec.slice(0, 20000)}\n\`\`\`\n\n_Volle Spec: \`${base}/openapi.json\`_\n`,
|
||||
schreiben(
|
||||
`${new Date().toISOString()} · Quelle: \`${url}\` · Erzeugen mit \`npm run sync:openapi\``,
|
||||
`\n\`\`\`json\n${spec.slice(0, 20000)}\n\`\`\`\n\n_Volle Spec: \`${url}\`_\n`,
|
||||
);
|
||||
console.log(`[sync:openapi] geschrieben: ${out}`);
|
||||
} catch (err) {
|
||||
console.warn(`[sync:openapi] Server nicht erreichbar (${base}): ${err.message} — Platzhalter bleibt.`);
|
||||
console.warn(`[sync:openapi] Server nicht erreichbar (${base}/_openapi/${namespace}): ${err.message} — Platzhalter bleibt.`);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-server-docs.mjs — Single Source bleibt in den Quell-Repos,
|
||||
* sync-server-docs.mjs — STILLGELEGT (2026-10-17).
|
||||
*
|
||||
* Grund: Der Sync kopierte die englischen Server-Docs nach src/content/docs/server/ und die
|
||||
* Nova-Docs nach src/content/docs/admin/. Diese Ziele stehen nicht in der Sidebar, es entstanden
|
||||
* verwaiste Seiten, die nie verlinkt wurden. Die deutschen Handseiten sind seither kanonisch.
|
||||
*
|
||||
* Nicht mehr aufrufen (kein npm-Script mehr). Datei bleibt nur als Nachweis/Referenz liegen.
|
||||
*
|
||||
* ALT: sync-server-docs.mjs — Single Source bleibt in den Quell-Repos,
|
||||
* diese Doku rendert nur. Kopiert:
|
||||
* - ../tibi-server/docs/*.md -> src/content/docs/server/ (ohne uebersicht.md zu überschreiben)
|
||||
* - ../tibi-admin-nova/docs/*.md -> src/content/docs/admin/ (+ test-plans/)
|
||||
|
||||
Reference in new issue
Block a user