Files
tibi-docs/scripts/audit-docs.mjs
T

317 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 konkrete Redaktionsmarker; Fachbegriffe wie „Platzhalter"
// und gültige Zustandsbeschreibungen wie „noch nicht registriert" sind kein Befund.)
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');
})();
[
{ label: 'Für spätere Agenten', pattern: /Für spätere Agenten/i },
{ label: 'TODO/FIXME/TBD/WIP', pattern: /\b(?:TODO|FIXME|TBD|WIP)\b/i },
{ label: 'Hülle', pattern: /\bHülle\b/i },
{ label: 'unersetzter Platzhalter-Vermerk', pattern: /\bStand\s*:\s*Platzhalter\b/i },
].forEach(({ label, pattern }) => {
const match = pattern.exec(proseText);
if (!match) return;
const idx = match.index;
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 "${label}": ${ctx}`, line);
stats.hollowMarkers.push(`${rel(f)}:${line} ${label}`);
});
// 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}`);