Files
tibi-docs/scripts/audit-docs.mjs
T
apairon dcdb6ee637 Darstellung: Inline-Code-Chips fuer Dark Mode, 58 Seiten auf .mdx (Komponenten rendern jetzt)
Befund 1 (Nutzer): Inline-Code war im Dark Mode kaum lesbar.
- src/styles/custom.css: eigene Chip-Farben (--tibi-inline-*), Rahmen, nowrap in der ersten Tabellenspalte
- gemessen: Text/Chip 10,2:1 dunkel, 12,4:1 hell

Befund 2 (beim Nachmessen gefunden): 58 Seiten waren .md - Starlight-Komponenten wurden NICHT gerendert
(rohe <Aside>/<Steps>-Tags, import-Zeile als sichtbarer Text auf 57 Seiten).
- alle 58 Seiten auf .mdx umgestellt und MDX-konform gemacht (Imports, Block-Tags in eigenen Zeilen,
  HTML-Kommentare -> {/* */}, geschweifte Klammern escaped)
- nachher: 428 Asides, 87 Steps, 158 Tab-Gruppen gerendert, 0 geleakte Imports
- fehlender Import in reference/hooks-daten ergaenzt

Regressionsschutz: audit-docs.mjs prueft jetzt Komponente-ohne-Import, .md-mit-Komponente,
geleakte Imports und rohe Tags im gebauten dist/. AGENTS.md 3 dokumentiert die .mdx-Regel.

Nebenfund: de.json mit ASCII-Umschreibungen -> Issue tibi-admin-nova #6
Build: 71 Seiten gruen, Audit 7 legitime Platzhalter-Marker
2026-10-17 13:54:52 +00:00

310 lines
13 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);
}
// Komponente genutzt, aber nicht importiert (rendert dann als rohes HTML)
const usedComponents = new Set(
[...text.matchAll(/<(Aside|Steps|Tabs|TabItem|Card|CardGrid|LinkCard|Badge|FileTree)\b/g)].map((m) => m[1]),
);
const imported = new Set(
([...text.matchAll(/^import\s*\{([^}]*)\}/gm)].flatMap((m) => m[1].split(',').map((s) => s.trim()))),
);
for (const c of usedComponents) {
if (!imported.has(c) && f.endsWith('.mdx')) add(f, 'import', `Komponente <${c}> genutzt, aber nicht importiert`);
if (!f.endsWith('.mdx')) add(f, 'import', `Komponente <${c}> in einer .md-Datei — Datei muss .mdx sein`);
}
// 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}×)`);
}
// --- Render-Prüfung gegen dist/ (Komponenten müssen echt gerendert sein) ----
const DIST = path.join(ROOT, 'dist');
const renderFindings = [];
if (fs.existsSync(DIST)) {
const htmlFiles = walk(DIST).filter((f) => f.endsWith('.html'));
const RAW_TAG = /<(Aside|Steps|Tabs|TabItem|Card|CardGrid|LinkCard|Badge|FileTree)[\s/>]/;
for (const f of htmlFiles) {
const html = fs.readFileSync(f, 'utf8');
if (html.includes('astrojs/starlight/components')) {
renderFindings.push({ file: rel(f), kind: 'render', msg: 'import-Zeile steht sichtbar im HTML (Seite ist .md statt .mdx)', line: null });
}
const m = RAW_TAG.exec(html);
if (m) {
renderFindings.push({ file: rel(f), kind: 'render', msg: `Komponenten-Tag <${m[1]}> ungerendert im HTML (Seite ist .md statt .mdx)`, line: null });
}
}
} else {
renderFindings.push({ file: 'dist/', kind: 'render', msg: 'dist/ fehlt — zuerst `npm run build` laufen lassen', line: null });
}
// --- 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: [...findings, ...renderFindings],
summary: {
total: findings.length + renderFindings.length,
byKind: [...findings, ...renderFindings].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}`);