/** * Server-side utilities for reading project documentation files. * * Scans the repo root for .md files in docs/, plus root-level READMEs etc. * New docs added to the repo auto-appear — no code changes needed. */ import fs from 'fs'; import path from 'path'; /** Resolve the repo root (two levels up from admin-dashboard-web/src/lib/) */ function getRepoRoot(): string { // In dev: process.cwd() is admin-dashboard-web/ // DOCS_DIR env override for Docker/custom setups if (process.env.DOCS_DIR) return path.resolve(process.env.DOCS_DIR, '..'); return path.resolve(process.cwd(), '..'); } export interface DocFile { slug: string; // e.g. "docs/STRIPE_SETUP_GUIDE" or "README" title: string; // Human-readable title derived from filename path: string; // Relative path from repo root, e.g. "docs/STRIPE_SETUP_GUIDE.md" category: string; // e.g. "docs", "docs/research", "root" sizeBytes: number; modifiedAt: string; // ISO date } /** Derive a human-readable title from a filename */ function titleFromFilename(filename: string): string { return filename .replace(/\.md$/i, '') .replace(/[_-]/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()); } /** List all .md files in the repo that should be shown in the admin portal */ export function listDocs(): DocFile[] { const root = getRepoRoot(); const results: DocFile[] = []; // 1. Root-level .md files const rootFiles = fs.readdirSync(root).filter(f => f.endsWith('.md')); for (const f of rootFiles) { const full = path.join(root, f); const stat = fs.statSync(full); results.push({ slug: f.replace(/\.md$/i, ''), title: titleFromFilename(f), path: f, category: 'root', sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), }); } // 2. docs/ directory (recursive) const docsDir = path.join(root, 'docs'); if (fs.existsSync(docsDir)) { walkDir(docsDir, root, results); } // 3. Service READMEs const serviceDirs = [ 'backend', 'admin-dashboard-web', 'user-dashboard-web', 'mobile_app', 'tracker-dashboard-web', ]; for (const dir of serviceDirs) { const readme = path.join(root, dir, 'README.md'); if (fs.existsSync(readme)) { const stat = fs.statSync(readme); results.push({ slug: `${dir}/README`, title: `${dir} — README`, path: `${dir}/README.md`, category: 'services', sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), }); } } return results.sort((a, b) => a.path.localeCompare(b.path)); } function walkDir(dir: string, repoRoot: string, results: DocFile[]): void { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walkDir(full, repoRoot, results); } else if (entry.name.endsWith('.md')) { const rel = path.relative(repoRoot, full); const stat = fs.statSync(full); const category = path.dirname(rel); results.push({ slug: rel.replace(/\.md$/i, ''), title: titleFromFilename(entry.name), path: rel, category, sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), }); } } } /** Read a single doc by its slug (e.g. "docs/STRIPE_SETUP_GUIDE") */ export function readDoc(slug: string): { content: string; meta: DocFile } | null { const root = getRepoRoot(); const filePath = path.join(root, slug + '.md'); // Security: prevent path traversal const resolved = path.resolve(filePath); if (!resolved.startsWith(path.resolve(root))) return null; if (!fs.existsSync(resolved)) return null; const stat = fs.statSync(resolved); const content = fs.readFileSync(resolved, 'utf-8'); const rel = path.relative(root, resolved); return { content, meta: { slug, title: titleFromFilename(path.basename(rel)), path: rel, category: path.dirname(rel) === '.' ? 'root' : path.dirname(rel), sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), }, }; } /** Full-text search across all docs (case-insensitive) */ export function searchDocs(query: string): Array { const docs = listDocs(); const root = getRepoRoot(); const results: Array = []; const lowerQuery = query.toLowerCase(); for (const doc of docs) { const filePath = path.join(root, doc.path); if (!fs.existsSync(filePath)) continue; const content = fs.readFileSync(filePath, 'utf-8'); const lowerContent = content.toLowerCase(); const idx = lowerContent.indexOf(lowerQuery); if (idx !== -1) { const start = Math.max(0, idx - 80); const end = Math.min(content.length, idx + query.length + 80); const snippet = (start > 0 ? '...' : '') + content.slice(start, end).replace(/\n/g, ' ') + (end < content.length ? '...' : ''); results.push({ ...doc, snippet }); } } return results; } /** Gather all docs content for RAG context (truncated to stay within token limits) */ export function getAllDocsContent(maxCharsPerDoc = 4000): string { const docs = listDocs(); const root = getRepoRoot(); const parts: string[] = []; for (const doc of docs) { const filePath = path.join(root, doc.path); if (!fs.existsSync(filePath)) continue; let content = fs.readFileSync(filePath, 'utf-8'); if (content.length > maxCharsPerDoc) { content = content.slice(0, maxCharsPerDoc) + '\n...(truncated)'; } parts.push(`=== ${doc.path} ===\n${content}`); } return parts.join('\n\n'); }