Files
tibi-docs/scripts/audit-docs.mjs
T
apairon 1928922e07 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
2026-10-17 01:49:35 +00:00

278 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}`);