#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; const ROOT = process.cwd(); const DOCS_DIR = path.join(ROOT, "docs"); const BACKEND_ROUTES_DIR = path.join(ROOT, "backend", "src", "routes"); const FRONTEND_PAGES_DIR = path.join(ROOT, "frontend", "pages"); const MOBILE_APP_DIR = path.join(ROOT, "mobile", "app"); const OUT_BACKEND = path.join(DOCS_DIR, "funktionen", "backend-api.md"); const OUT_FRONTEND = path.join(DOCS_DIR, "funktionen", "frontend-web.md"); const OUT_MOBILE = path.join(DOCS_DIR, "funktionen", "mobile-app.md"); const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"]; async function walkFiles(dir, extension) { const result = []; async function walk(current) { const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { const full = path.join(current, entry.name); if (entry.isDirectory()) { await walk(full); } else if (entry.isFile() && full.endsWith(extension)) { result.push(full); } } } await walk(dir); return result.sort(); } function normalizePosix(p) { return p.split(path.sep).join("/"); } function toRelative(filePath) { return normalizePosix(path.relative(ROOT, filePath)); } function extractBackendEndpoints(sourceText) { const endpoints = []; const regex = /\b(?:server|subApp|m2mApp|devicesApp)\.(get|post|put|patch|delete|options|head)\(\s*["'`](.+?)["'`]/g; let match; while ((match = regex.exec(sourceText)) !== null) { const method = String(match[1] || "").toUpperCase(); const routePath = String(match[2] || ""); if (!HTTP_METHODS.includes(method.toLowerCase())) { continue; } endpoints.push({ method, path: routePath }); } return endpoints.sort((a, b) => { if (a.path === b.path) { return a.method.localeCompare(b.method); } return a.path.localeCompare(b.path); }); } function sortAndUnique(items) { return [...new Set(items)].sort((a, b) => a.localeCompare(b)); } function filePathToNuxtRoute(filePath, baseDir) { const relative = normalizePosix(path.relative(baseDir, filePath)); let route = relative.replace(/\.vue$/, ""); route = route.replace(/\.client$/, "").replace(/\.server$/, ""); route = route .replace(/\[\[\.\.\.(.+?)\]\]/g, ":$1*?") .replace(/\[\.\.\.(.+?)\]/g, ":$1*") .replace(/\[\[(.+?)\]\]/g, ":$1?") .replace(/\[(.+?)\]/g, ":$1"); route = route.replace(/\/index$/g, ""); if (route === "index") { route = ""; } if (!route.startsWith("/")) { route = `/${route}`; } if (route === "") { return "/"; } return route || "/"; } function filePathToExpoRoute(filePath, baseDir) { const relative = normalizePosix(path.relative(baseDir, filePath)); const baseName = path.basename(relative); if (baseName.startsWith("_")) { return null; } let route = relative.replace(/\.tsx$/, ""); route = route .replace(/\[\[(.+?)\]\]/g, ":$1?") .replace(/\[(.+?)\]/g, ":$1"); route = route.replace(/\/index$/g, ""); if (route === "index") { route = ""; } if (!route.startsWith("/")) { route = `/${route}`; } if (route === "") { return "/"; } return route; } async function generateBackendDoc() { const files = await walkFiles(BACKEND_ROUTES_DIR, ".ts"); const generatedAt = new Date().toISOString(); let output = "# Backend API Funktionskatalog\n\n"; output += `Automatisch generiert am: ${generatedAt}\n\n`; output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n"; const allEndpoints = []; for (const file of files) { const source = await fs.readFile(file, "utf-8"); const endpoints = extractBackendEndpoints(source); const relativeFile = toRelative(file); if (endpoints.length === 0) { continue; } output += `## ${relativeFile}\n\n`; output += "| Methode | Pfad |\n"; output += "|---|---|\n"; for (const endpoint of endpoints) { output += `| ${endpoint.method} | \`${endpoint.path}\` |\n`; allEndpoints.push(`${endpoint.method} ${endpoint.path}`); } output += "\n"; } const uniqueCount = sortAndUnique(allEndpoints).length; output += `Gesamtzahl erkannter Endpunkte: **${uniqueCount}**\n`; await fs.writeFile(OUT_BACKEND, output, "utf-8"); } async function generateFrontendDoc() { const files = await walkFiles(FRONTEND_PAGES_DIR, ".vue"); const generatedAt = new Date().toISOString(); const rows = files.map((file) => { const route = filePathToNuxtRoute(file, FRONTEND_PAGES_DIR); return { route, file: toRelative(file) }; }); rows.sort((a, b) => a.route.localeCompare(b.route)); let output = "# Frontend Web Funktionskatalog\n\n"; output += `Automatisch generiert am: ${generatedAt}\n\n`; output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n"; output += "| Route (Nuxt) | Datei |\n"; output += "|---|---|\n"; for (const row of rows) { output += `| \`${row.route}\` | \`${row.file}\` |\n`; } output += `\nGesamtzahl erkannter Web-Routen: **${rows.length}**\n`; await fs.writeFile(OUT_FRONTEND, output, "utf-8"); } async function generateMobileDoc() { const files = await walkFiles(MOBILE_APP_DIR, ".tsx"); const generatedAt = new Date().toISOString(); const rows = files .map((file) => { const route = filePathToExpoRoute(file, MOBILE_APP_DIR); if (!route) { return null; } return { route, file: toRelative(file) }; }) .filter(Boolean); rows.sort((a, b) => a.route.localeCompare(b.route)); let output = "# Mobile App Funktionskatalog\n\n"; output += `Automatisch generiert am: ${generatedAt}\n\n`; output += "Hinweis: Diese Datei wird durch `docs/scripts/sync-funktionsdoku.mjs` erzeugt.\n\n"; output += "| Route (Expo Router) | Datei |\n"; output += "|---|---|\n"; for (const row of rows) { output += `| \`${row.route}\` | \`${row.file}\` |\n`; } output += `\nGesamtzahl erkannter Mobile-Screens: **${rows.length}**\n`; await fs.writeFile(OUT_MOBILE, output, "utf-8"); } async function main() { await generateBackendDoc(); await generateFrontendDoc(); await generateMobileDoc(); console.log("Funktionsdokumentation erfolgreich synchronisiert."); } main().catch((error) => { console.error("Fehler bei der Doku-Synchronisierung:", error); process.exit(1); });