317 lines
13 KiB
JavaScript
317 lines
13 KiB
JavaScript
#!/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}`);
|