- Copy admin-dashboard-web → dashboards/admin-web - Copy tracker-dashboard-web → dashboards/tracker-web - Update pnpm-workspace.yaml to include dashboards/* - Replace file: refs with workspace:* for @bytelyst/* packages - Replace all hardcoded LysnrAI/lysnn.com branding with generic platform refs - Make telemetry use NEXT_PUBLIC_PRODUCT_ID / PRODUCT_ID env vars - Update mock credentials, seed data, invitation codes, placeholders - Update READMEs, e2e tests, unit tests for product-agnostic content - Both dashboards pass tsc --noEmit clean
182 lines
5.5 KiB
TypeScript
182 lines
5.5 KiB
TypeScript
/**
|
|
* 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<DocFile & { snippet: string }> {
|
|
const docs = listDocs();
|
|
const root = getRepoRoot();
|
|
const results: Array<DocFile & { snippet: string }> = [];
|
|
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');
|
|
}
|