#!/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(/(? { 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* { 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}`);