UNPKG

@compodoc/compodoc

Version:

The missing documentation tool for your Angular application

1,210 lines (1,041 loc) โ€ข 121 kB
const polka = require('polka'); const sirv = require('sirv'); const { json, urlencoded } = require('body-parser'); const send = require('@polka/send-type'); import { IncomingMessage, ServerResponse } from 'http'; import { Polka } from 'polka'; import * as path from 'path'; import * as fs from 'fs-extra'; import * as http from 'http'; import * as crypto from 'crypto'; import * as os from 'os'; const archiver = require('archiver'); import { execSync } from 'child_process'; import { logger } from '../utils/logger'; interface PlaygroundSession { id: string; templateDir: string; documentationDir: string; lastActivity: number; config: CompoDocConfig; documentationGenerated?: boolean; } interface CompoDocConfig { // Documentation Metadata name?: string; // Paths and Output output?: string; theme?: string; language?: string; base?: string; // Assets and Custom UI customFavicon?: string; customLogo?: string; assetsFolder?: string; extTheme?: string; // Feature Toggles - Disable Options disableSourceCode?: boolean; disableGraph?: boolean; disableCoverage?: boolean; disablePrivate?: boolean; disableProtected?: boolean; disableInternal?: boolean; disableLifeCycleHooks?: boolean; disableConstructors?: boolean; disableRoutesGraph?: boolean; disableSearch?: boolean; disableDependencies?: boolean; disableProperties?: boolean; disableDomTree?: boolean; disableTemplateTab?: boolean; disableStyleTab?: boolean; disableMainGraph?: boolean; disableFilePath?: boolean; disableOverview?: boolean; // UI Options hideGenerator?: boolean; hideDarkModeToggle?: boolean; minimal?: boolean; // Additional Content includes?: string; includesName?: string; // Serving Options port?: number; hostname?: string; serve?: boolean; open?: boolean; watch?: boolean; // Export Options exportFormat?: string; // Coverage Options coverageTest?: boolean; coverageTestThreshold?: number; coverageMinimumPerFile?: number; coverageTestThresholdFail?: boolean; coverageTestShowOnlyFailed?: boolean; unitTestCoverage?: string; // Google Analytics gaID?: string; gaSite?: string; // Advanced Options silent?: boolean; maxSearchResults?: number; // Menu Configuration toggleMenuItems?: string[] | string; navTabConfig?: any[] | string; } export class TemplatePlaygroundServer { private app: Polka; private server: any; private port: number; private handlebars: any; private sessions: Map<string, PlaygroundSession> = new Map(); private ipToSessionId: Map<string, string> = new Map(); private debounceTimers: Map<string, NodeJS.Timeout> = new Map(); private fakeProjectPath: string; private originalTemplatesPath: string; private cleanupInterval: NodeJS.Timeout; private signalHandlers: Map<string, (...args: any[]) => void> = new Map(); constructor(port?: number) { this.port = port || parseInt(process.env.PLAYGROUND_PORT || process.env.PORT || '3001', 10); this.app = polka(); this.setupPaths(); this.initializeHandlebars(); this.setupMiddleware(); this.setupRoutes(); this.startSessionCleanup(); this.setupSignalHandlers(); } /** * Get the underlying HTTP server instance for testing purposes * @returns HTTP server instance or null if not started */ public getHttpServer(): any { // Polka stores the actual HTTP server in the .server property // This is needed for Supertest compatibility which expects a Node.js HTTP server return this.server?.server || null; } private setupSignalHandlers(): void { // Skip signal handlers entirely in test environment to prevent memory leaks if (process.env.NODE_ENV === 'test') { return; } // Handle CTRL+C (SIGINT) and other termination signals const signals = ['SIGINT', 'SIGTERM', 'SIGUSR2']; signals.forEach(signal => { const handler = async () => { logger.info(`Received ${signal}, shutting down Template Playground server gracefully...`); try { await this.stop(); logger.info('Server shutdown complete'); process.exit(0); } catch (error) { logger.error('Error during server shutdown:', error); process.exit(1); } }; this.signalHandlers.set(signal, handler); process.on(signal, handler); }); // Handle uncaught exceptions (only if not already handled) if (process.listenerCount('uncaughtException') === 0) { const uncaughtHandler = async (error) => { logger.error('Uncaught exception:', error); try { await this.stop(); } catch (stopError) { logger.error('Error during emergency shutdown:', stopError); } process.exit(1); }; this.signalHandlers.set('uncaughtException', uncaughtHandler); process.on('uncaughtException', uncaughtHandler); } // Handle unhandled promise rejections (only if not already handled) if (process.listenerCount('unhandledRejection') === 0) { const rejectionHandler = async (reason, promise) => { logger.error('Unhandled rejection at:', promise, 'reason:', reason); try { await this.stop(); } catch (stopError) { logger.error('Error during emergency shutdown:', stopError); } process.exit(1); }; this.signalHandlers.set('unhandledRejection', rejectionHandler); process.on('unhandledRejection', rejectionHandler); } } private setupPaths(): void { // Try to find paths for distributed package first, then fall back to development paths // For playground-demo: check resources/playground-demo first, then src directory const distributedFakeProjectPath = path.join(__dirname, 'resources', 'playground-demo'); const devFakeProjectPath = path.join(process.cwd(), 'src', 'playground-demo'); if (fs.existsSync(distributedFakeProjectPath)) { this.fakeProjectPath = distributedFakeProjectPath; } else if (fs.existsSync(devFakeProjectPath)) { this.fakeProjectPath = devFakeProjectPath; } else { throw new Error('playground-demo directory not found. Please ensure it exists.'); } // For templates: check if we're running from dist (distributed) or development const distributedTemplatesPath = path.join(__dirname, 'templates'); // When running from dist/, this is dist/templates const devTemplatesPath = path.join(process.cwd(), 'src', 'templates'); const legacyTemplatesPath = path.join(process.cwd(), 'hbs-templates-copy'); if (fs.existsSync(distributedTemplatesPath)) { this.originalTemplatesPath = distributedTemplatesPath; } else if (fs.existsSync(devTemplatesPath)) { this.originalTemplatesPath = devTemplatesPath; } else if (fs.existsSync(legacyTemplatesPath)) { // Keep legacy support for existing hbs-templates-copy this.originalTemplatesPath = legacyTemplatesPath; } else { throw new Error('Templates directory not found. Please ensure src/templates or dist/templates exists.'); } } private getClientIP(req: IncomingMessage): string { // Get IP address from various headers (handles proxies, load balancers, etc.) const forwarded = req.headers['x-forwarded-for'] as string; const realIP = req.headers['x-real-ip'] as string; const remoteAddr = (req as IncomingMessage & { socket?: { remoteAddress?: string } }).socket?.remoteAddress || 'unknown'; let ip = forwarded?.split(',')[0] || realIP || remoteAddr || 'unknown'; // Clean up IPv6 localhost if (ip === '::1' || ip === '::ffff:127.0.0.1') { ip = '127.0.0.1'; } return ip; } private generateSessionIdFromIP(ip: string): string { // Create a consistent hash from IP address return crypto.createHash('md5').update(ip + 'template-playground-salt').digest('hex'); } private createOrGetSessionByIP(ip: string): PlaygroundSession { // Check if session already exists for this IP const existingSessionId = this.ipToSessionId.get(ip); if (existingSessionId && this.sessions.has(existingSessionId)) { const session = this.sessions.get(existingSessionId)!; // Update last activity session.lastActivity = Date.now(); logger.info(`โ™ป๏ธ Reusing existing session for IP ${ip}: ${existingSessionId}`); return session; } // Create new session const sessionId = this.generateSessionIdFromIP(ip); const templateDir = path.join(os.tmpdir(), `hbs-templates-copy-${sessionId}`); const documentationDir = path.join(os.tmpdir(), `generated-documentation-${sessionId}`); // Clean up any existing directories from previous sessions if (fs.existsSync(templateDir)) { fs.removeSync(templateDir); } if (fs.existsSync(documentationDir)) { fs.removeSync(documentationDir); } // Copy original templates to session directory fs.copySync(this.originalTemplatesPath, templateDir); fs.ensureDirSync(documentationDir); const session: PlaygroundSession = { id: sessionId, templateDir, documentationDir, lastActivity: Date.now(), config: { hideGenerator: false, disableSourceCode: false, disableGraph: false, disableCoverage: false, disablePrivate: false, disableProtected: false, disableInternal: false } }; this.sessions.set(sessionId, session); this.ipToSessionId.set(ip, sessionId); logger.info(`๐Ÿ†• Created new session for IP ${ip}: ${sessionId}`); // Generate initial documentation (skip in test mode to avoid template issues) if (process.env.NODE_ENV !== 'test') { this.generateDocumentation(sessionId); } return session; } private createNewSession(ip: string): PlaygroundSession { // Generate a unique session ID (not based on IP) const sessionId = crypto.randomBytes(16).toString('hex'); const templateDir = path.join(os.tmpdir(), `hbs-templates-copy-${sessionId}`); const documentationDir = path.join(os.tmpdir(), `generated-documentation-${sessionId}`); // Clean up any existing directories from previous sessions if (fs.existsSync(templateDir)) { fs.removeSync(templateDir); } if (fs.existsSync(documentationDir)) { fs.removeSync(documentationDir); } // Copy original templates to session directory fs.copySync(this.originalTemplatesPath, templateDir); fs.ensureDirSync(documentationDir); const session: PlaygroundSession = { id: sessionId, templateDir, documentationDir, lastActivity: Date.now(), config: { hideGenerator: false, disableSourceCode: false, disableGraph: false, disableCoverage: false, disablePrivate: false, disableProtected: false, disableInternal: false, disableFilePath: false, disableOverview: false } }; this.sessions.set(sessionId, session); // Don't update ipToSessionId mapping for new sessions to allow multiple sessions per IP logger.info(`๐Ÿ†• Created new session for IP ${ip}: ${sessionId}`); // Generate initial documentation (skip in test mode to avoid template issues) if (process.env.NODE_ENV !== 'test') { this.generateDocumentation(sessionId); } return session; } private updateSessionActivity(sessionId: string): void { const session = this.sessions.get(sessionId); if (session) { session.lastActivity = Date.now(); } } private generateDocumentation(sessionId: string, debounce: boolean = false): void { if (debounce) { // Clear existing timer const existingTimer = this.debounceTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); } // Set new timer for 300ms const timer = setTimeout(() => { this.runCompoDocForSession(sessionId); this.debounceTimers.delete(sessionId); }, 300); this.debounceTimers.set(sessionId, timer); } else { // Generate immediately this.runCompoDocForSession(sessionId); } } private async runCompoDocForSession(sessionId: string): Promise<void> { const session = this.sessions.get(sessionId); if (!session) { logger.error(`Session ${sessionId} not found`); return; } try { logger.info(`๐Ÿš€ Generating documentation for session ${sessionId}`); // Build CompoDoc CLI command using absolute paths for temp directories // Use the configured fake project path with tsconfig.json const fakeProjectTsConfigPath = path.join(this.fakeProjectPath, 'tsconfig.json'); // Use absolute path to the CLI script const cliPath = path.resolve(process.cwd(), 'bin', 'index-cli.js'); // In test mode, check if CLI exists before proceeding if (process.env.NODE_ENV === 'test' && !fs.existsSync(cliPath)) { logger.warn(`CLI not found in test environment: ${cliPath}. Skipping documentation generation.`); session.documentationGenerated = true; // Mark as generated to avoid retries return; } const cmd = [ `node "${cliPath}"`, `-p "${fakeProjectTsConfigPath}"`, `-d "${session.documentationDir}"`, `--templates "${session.templateDir}"` ]; // Dynamically add all config options as CLI flags const config = session.config || {}; const booleanFlags = [ 'hideGenerator', 'disableSourceCode', 'disableGraph', 'disableCoverage', 'disablePrivate', 'disableProtected', 'disableInternal', 'disableLifeCycleHooks', 'disableConstructors', 'disableRoutesGraph', 'disableSearch', 'disableDependencies', 'disableProperties', 'disableDomTree', 'disableTemplateTab', 'disableStyleTab', 'disableMainGraph', 'disableFilePath', 'disableOverview', 'hideDarkModeToggle', 'minimal', 'serve', 'open', 'watch', 'silent', 'coverageTest', 'coverageTestThresholdFail', 'coverageTestShowOnlyFailed' ]; const valueFlags = [ 'theme', 'language', 'base', 'customFavicon', 'customLogo', 'assetsFolder', 'extTheme', 'includes', 'includesName', 'output', 'port', 'hostname', 'exportFormat', 'coverageTestThreshold', 'coverageMinimumPerFile', 'unitTestCoverage', 'gaID', 'gaSite', 'maxSearchResults', 'toggleMenuItems', 'navTabConfig' ]; for (const flag of booleanFlags) { if (config[flag] === true) { cmd.push(`--${flag}`); } } for (const flag of valueFlags) { if (config[flag] !== undefined && config[flag] !== "") { let value = config[flag]; // For arrays/objects, stringify if (Array.isArray(value) || typeof value === 'object') { value = JSON.stringify(value); } cmd.push(`--${flag} \"${value}\"`); } } const fullCmd = cmd.join(' '); logger.info(`๐Ÿš€ Executing CompoDoc command: ${fullCmd}`); // Log the command to a file for debugging require('fs').appendFileSync('server-commands.log', `${new Date().toISOString()} - ${fullCmd}\n`); // Execute with proper error handling (inherit stdio to see errors) execSync(fullCmd, { cwd: process.cwd(), stdio: 'inherit' // Show output/errors instead of hiding them }); this.updateSessionActivity(sessionId); logger.info(`โœ… Documentation generated successfully for session ${sessionId}`); } catch (error) { logger.error(`โŒ Error generating documentation for session ${sessionId}:`, error); } } private startSessionCleanup(): void { // Clean up sessions older than 1 hour every 10 minutes this.cleanupInterval = setInterval(() => { const cutoffTime = Date.now() - (60 * 60 * 1000); // 1 hour ago for (const [sessionId, session] of this.sessions.entries()) { if (session.lastActivity < cutoffTime) { this.cleanupSession(sessionId); } } }, 10 * 60 * 1000); // Every 10 minutes } private cleanupSession(sessionId: string): void { const session = this.sessions.get(sessionId); if (session) { try { // Remove directories if (fs.existsSync(session.templateDir)) { fs.removeSync(session.templateDir); } if (fs.existsSync(session.documentationDir)) { fs.removeSync(session.documentationDir); } // Clear timer if exists const timer = this.debounceTimers.get(sessionId); if (timer) { clearTimeout(timer); this.debounceTimers.delete(sessionId); } // Remove IP mapping for (const [ip, id] of this.ipToSessionId.entries()) { if (id === sessionId) { this.ipToSessionId.delete(ip); break; } } this.sessions.delete(sessionId); logger.info(`๐Ÿงน Cleaned up session: ${sessionId}`); } catch (error) { logger.error(`Error cleaning up session ${sessionId}:`, error); } } } private initializeHandlebars(): void { this.handlebars = require('handlebars'); this.registerHandlebarsHelpers(this.handlebars, {}); } private async registerAvailablePartials(): Promise<void> { try { const partialsDir = path.join(process.cwd(), 'dist/templates/partials'); logger.info(`๐Ÿ” Looking for partials in: ${partialsDir}`); logger.info(`๐Ÿ” Partials directory exists: ${fs.existsSync(partialsDir)}`); if (fs.existsSync(partialsDir)) { const partialFiles = fs.readdirSync(partialsDir).filter(file => file.endsWith('.hbs')); logger.info(`๐Ÿ“ Found ${partialFiles.length} partial files: ${JSON.stringify(partialFiles)}`); for (const file of partialFiles) { const partialName = file.replace('.hbs', ''); const partialPath = path.join(partialsDir, file); const partialContent = fs.readFileSync(partialPath, 'utf8'); // Register the partial this.handlebars.registerPartial(partialName, partialContent); logger.info(`โœ… Registered partial: ${partialName}`); } } else { logger.warn(`โš ๏ธ Partials directory not found at: ${partialsDir}`); } } catch (error) { logger.error(`โŒ Error registering partials:`, error); } } private setupMiddleware(): void { // Add request logging for debugging this.app.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { const headers = req.headers; logger.info(`๐Ÿ” REQUEST: ${req.method} ${req.url} - User-Agent: ${headers['user-agent'] || 'unknown'}`); next(); }); // Enable CORS for development this.app.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); if (req.method === 'OPTIONS') { res.statusCode = 200; res.end(); } else { next(); } }); // Serve Compodoc resources at root level for relative path compatibility // Try dist/resources first (production), then src/resources (development/testing) const compodocResourcesPathDist = path.join(process.cwd(), 'dist/resources'); const compodocResourcesPathSrc = path.join(process.cwd(), 'src/resources'); const compodocResourcesPath = fs.existsSync(compodocResourcesPathDist) ? compodocResourcesPathDist : compodocResourcesPathSrc; logger.info(`๐Ÿ“ Setting up root-level static files from: ${compodocResourcesPath}`); logger.info(`๐Ÿ“ Compodoc resources path exists: ${fs.existsSync(compodocResourcesPath)}`); // Serve styles, js, images, and other resources at root level using sirv this.app.use('/styles', sirv(path.join(compodocResourcesPath, 'styles'), { dev: true })); this.app.use('/js', sirv(path.join(compodocResourcesPath, 'js'), { dev: true })); this.app.use('/images', sirv(path.join(compodocResourcesPath, 'images'), { dev: true })); this.app.use('/fonts', sirv(path.join(compodocResourcesPath, 'fonts'), { dev: true })); // Serve Compodoc resources under /resources path as well (for backward compatibility) this.app.use('/resources', sirv(compodocResourcesPath, { dev: true })); // Serve static files from template playground directory (index.html, app.js) // Try dist/resources first (production), then src/resources (development/testing) const playgroundStaticPathDist = path.join(process.cwd(), 'dist/resources/template-playground-app'); const playgroundStaticPathSrc = path.join(process.cwd(), 'src/resources/template-playground-app'); const playgroundStaticPath = fs.existsSync(playgroundStaticPathDist) ? playgroundStaticPathDist : playgroundStaticPathSrc; logger.info(`๐Ÿ“ Setting up playground static files from: ${playgroundStaticPath}`); logger.info(`๐Ÿ“ Playground static path exists: ${fs.existsSync(playgroundStaticPath)}`); this.app.use(sirv(playgroundStaticPath, { dev: true })); // Parse JSON bodies and form data using body-parser this.app.use(json({ limit: '10mb' })); this.app.use(urlencoded({ extended: true, limit: '10mb' })); } private setupRoutes(): void { // API route to get available templates this.app.get('/api/templates', this.getTemplates.bind(this)); // API route to get template content this.app.get('/api/templates/:templateName', this.getTemplate.bind(this)); // API route to get example data this.app.get('/api/example-data/:dataType', this.getExampleData.bind(this)); // API route to render template with custom data this.app.post('/api/render', this.renderTemplate.bind(this)); // API route to render complete page with template this.app.post('/api/render-page', this.renderCompletePage.bind(this)); // API route to generate documentation with CompoDoc CLI this.app.post('/api/generate-docs', this.generateDocs.bind(this)); // API route to download template package this.app.post('/api/download-template', this.downloadTemplatePackage.bind(this)); // API route to download template ZIP (server-side creation) this.app.post('/api/session/:sessionId/download-zip', this.downloadSessionTemplateZip.bind(this)); this.app.post('/api/session/:sessionId/download-all-templates', this.downloadAllSessionTemplates.bind(this)); this.app.get('/api/session/:sessionId/download/all', this.downloadAllSessionTemplates.bind(this)); // Alias for compatibility // Session management API routes this.app.post('/api/session', this.createSessionAPI.bind(this)); this.app.post('/api/session/create', this.createSessionAPI.bind(this)); this.app.get('/api/session/:sessionId/templates', this.getSessionTemplates.bind(this)); this.app.get('/api/session/:sessionId/template/*', this.getSessionTemplate.bind(this)); this.app.post('/api/session/:sessionId/template/*', this.saveSessionTemplate.bind(this)); this.app.get('/api/session/:sessionId/template-data/*', this.getSessionTemplateData.bind(this)); this.app.post('/api/session/:sessionId/generate-docs', this.generateSessionDocs.bind(this)); this.app.post('/api/session/:sessionId/generate', this.generateSessionDocs.bind(this)); // Alias for compatibility this.app.get('/api/session/:sessionId/config', this.getSessionConfig.bind(this)); this.app.post('/api/session/:sessionId/config', this.updateSessionConfig.bind(this)); // Serve session-specific generated documentation this.app.get('/api/session/:sessionId/docs/*', this.serveSessionDocs.bind(this)); // Serve session-specific generated documentation at the expected URL pattern // These routes MUST come before the catch-all route this.app.get('/docs/:sessionId/index.html', (req: any, res: ServerResponse) => { logger.info(`๐Ÿ” Docs index route hit: /docs/${req.params.sessionId}/index.html`); const sessionId = req.params.sessionId; const session = this.sessions.get(sessionId); if (!session) { logger.error(`โŒ Session not found: ${sessionId}`); send(res, 404, { success: false, message: 'Session not found' }); return; } this.updateSessionActivity(sessionId); const fullPath = path.join(session.documentationDir, 'index.html'); logger.info(`๐Ÿ“‚ Looking for file: ${fullPath}`); if (fs.existsSync(fullPath)) { logger.info(`โœ… Serving file: ${fullPath}`); const content = fs.readFileSync(fullPath); res.setHeader('Content-Type', 'text/html'); res.end(content); } else { logger.error(`โŒ File not found: ${fullPath}`); res.statusCode = 404; res.end('Documentation file not found'); } }); // Serve any file within session documentation using dynamic sirv middleware this.app.get('/docs/:sessionId/*', (req: any, res: ServerResponse) => { const sessionId = req.params.sessionId; const session = this.sessions.get(sessionId); if (!session) { logger.error(`โŒ Session not found: ${sessionId}`); send(res, 404, { success: false, message: 'Session not found' }); return; } this.updateSessionActivity(sessionId); // Use sirv to serve files from the session documentation directory const sessionSirv = sirv(session.documentationDir, { dev: true, single: false, setHeaders: (res, pathname) => { logger.info(`โœ… Serving file via sirv: ${pathname}`); } }); // Remove the session prefix from the URL for sirv const originalUrl = req.url; const sessionPrefix = `/docs/${sessionId}`; if (originalUrl && originalUrl.startsWith(sessionPrefix)) { req.url = originalUrl.substring(sessionPrefix.length) || '/'; logger.info(`๐Ÿ” Sirv serving: ${req.url} from ${session.documentationDir}`); } sessionSirv(req, res, () => { // If sirv doesn't handle it, restore original URL and return 404 req.url = originalUrl; logger.error(`โŒ File not found in session docs: ${req.url}`); res.statusCode = 404; res.end('Documentation file not found'); }); }); // Handle direct access to session documentation root (index.html) this.app.get('/docs/:sessionId', (req: any, res: ServerResponse) => { logger.info(`๐Ÿ” Docs root route hit: /docs/${req.params.sessionId}`); const sessionId = req.params.sessionId; const session = this.sessions.get(sessionId); if (!session) { logger.error(`โŒ Session not found: ${sessionId}`); send(res, 404, { success: false, message: 'Session not found' }); return; } this.updateSessionActivity(sessionId); const fullPath = path.join(session.documentationDir, 'index.html'); logger.info(`๐Ÿ“‚ Looking for file: ${fullPath}`); if (fs.existsSync(fullPath)) { logger.info(`โœ… Serving file: ${fullPath}`); const content = fs.readFileSync(fullPath); res.setHeader('Content-Type', 'text/html'); res.end(content); } else { logger.error(`โŒ File not found: ${fullPath}`); res.statusCode = 404; res.end('Documentation file not found'); } }); // Serve generated documentation files (legacy) - MUST come after session-specific routes // TEMPORARILY COMMENTED OUT TO TEST SESSION ROUTES // this.app.use('/docs', express.static(this.fakeProjectPath)); // Serve generated docs from playground-demo // Serve the main playground app for root path only this.app.get('/', (req, res) => { // Try dist/resources first (production), then src/resources (development/testing) const indexPathDist = path.join(process.cwd(), 'dist/resources/template-playground-app/index.html'); const indexPathSrc = path.join(process.cwd(), 'src/resources/template-playground-app/index.html'); const indexPath = fs.existsSync(indexPathDist) ? indexPathDist : indexPathSrc; if (fs.existsSync(indexPath)) { const content = fs.readFileSync(indexPath); res.setHeader('Content-Type', 'text/html'); res.end(content); } else { res.statusCode = 404; res.end('Template Playground not built. Please run the build process.'); } }); // Handle any remaining non-API routes by serving the main app (for SPA routing) // Note: This catch-all route should be last and will handle all unmatched routes this.app.get('*', (req, res) => { // Skip API, resources, and docs routes as they are handled above if (req.url.startsWith('/api') || req.url.startsWith('/resources') || req.url.startsWith('/docs')) { res.statusCode = 404; res.end('Not Found'); return; } logger.warn(`โš ๏ธ CATCH-ALL ROUTE HIT: ${req.method} ${req.url}`); // Try dist/resources first (production), then src/resources (development/testing) const indexPathDist = path.join(process.cwd(), 'dist/resources/template-playground-app/index.html'); const indexPathSrc = path.join(process.cwd(), 'src/resources/template-playground-app/index.html'); const indexPath = fs.existsSync(indexPathDist) ? indexPathDist : indexPathSrc; if (fs.existsSync(indexPath)) { const content = fs.readFileSync(indexPath); res.setHeader('Content-Type', 'text/html'); res.end(content); } else { res.statusCode = 404; res.end('Template Playground not built. Please run the build process.'); } }); } private async getTemplates(req: any, res: ServerResponse): Promise<void> { try { const templatesDir = path.join(process.cwd(), 'dist/templates/partials'); const files = await fs.readdir(templatesDir); const templates = files .filter(file => file.endsWith('.hbs')) .map(file => ({ name: file.replace('.hbs', ''), filename: file, path: path.join(templatesDir, file) })); send(res, 200, templates); } catch (error) { logger.error('Error reading templates:', error); send(res, 500, { error: 'Failed to read templates' }); } } private async getTemplate(req: any, res: ServerResponse): Promise<void> { try { const templateName = req.params.templateName; const templatePath = path.join(process.cwd(), 'dist/templates/partials', `${templateName}.hbs`); if (!await fs.pathExists(templatePath)) { send(res, 404, { error: 'Template not found' }); return; } const content = await fs.readFile(templatePath, 'utf-8'); send(res, 200, { name: templateName, content: content, path: templatePath }); } catch (error) { logger.error('Error reading template:', error); send(res, 500, { error: 'Failed to read template' }); } } private async getExampleData(req: any, res: ServerResponse): Promise<void> { try { const dataType = req.params.dataType; // Import example data dynamically const { EXAMPLE_DATA, TEMPLATE_CONTEXT } = await import('./example-data'); if (!EXAMPLE_DATA[dataType]) { send(res, 404, { error: 'Example data type not found' }); return; } // Wrap data for template compatibility const wrappedData = dataType === 'component' || dataType === 'directive' || dataType === 'pipe' || dataType === 'guard' || dataType === 'interceptor' || dataType === 'injectable' || dataType === 'class' || dataType === 'interface' || dataType === 'entity' ? { [dataType]: EXAMPLE_DATA[dataType], ...EXAMPLE_DATA[dataType] } : EXAMPLE_DATA[dataType]; send(res, 200, { data: wrappedData, context: TEMPLATE_CONTEXT }); } catch (error) { logger.error('Error getting example data:', error); send(res, 500, { error: 'Failed to get example data' }); } } private async renderTemplate(req: any, res: ServerResponse): Promise<void> { try { const { templateContent, templateData, templateContext } = req.body; if (!templateContent) { send(res, 400, { error: 'Template content is required' }); return; } // Use the pre-initialized Handlebars instance const template = this.handlebars.compile(templateContent); const rendered = template(templateData || {}); send(res, 200, { rendered }); } catch (error) { logger.error('Error rendering template:', error); send(res, 500, { error: 'Failed to render template', details: error.message }); } } private async renderCompletePage(req: any, res: ServerResponse): Promise<void> { try { let { templateContent, templateData, templateContext } = req.body; // Handle form data by parsing JSON strings if (typeof templateData === 'string') { try { templateData = JSON.parse(templateData); } catch (e) { templateData = {}; } } if (typeof templateContext === 'string') { try { templateContext = JSON.parse(templateContext); } catch (e) { templateContext = {}; } } if (!templateContent) { send(res, 400, { error: 'Template content is required' }); return; } // Generate proper Compodoc-style HTML directly const renderedContent = this.generateCompodocHtml(templateData || {}); // Create complete HTML page with Compodoc styling const completePage = `<!doctype html> <html class="no-js" lang=""> <head> <meta charset="utf-8"> <meta http-equiv="x-ua-compatible" content="ie=edge"> <title>Template Preview - Compodoc</title> <meta name="description" content=""> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="/resources/images/favicon.ico"> <link rel="stylesheet" href="/resources/styles/bootstrap.min.css"> <link rel="stylesheet" href="/resources/styles/compodoc.css"> <link rel="stylesheet" href="/resources/styles/prism.css"> <link rel="stylesheet" href="/resources/styles/dark.css"> <link rel="stylesheet" href="/resources/styles/style.css"> </head> <body> <script> // Blocking script to avoid flickering dark mode var useDark = window.matchMedia('(prefers-color-scheme: dark)'); var darkModeState = useDark.matches; var darkModeStateLocal = localStorage.getItem('compodoc_darkmode-state'); if (darkModeStateLocal) { darkModeState = darkModeStateLocal === 'true'; } if (darkModeState) { document.body.classList.add('dark'); } </script> <div class="container-fluid main"> <!-- START CONTENT --> <div class="content component"> <div class="content-data"> ${renderedContent} </div> </div> <!-- END CONTENT --> </div> <script> var COMPODOC_CURRENT_PAGE_DEPTH = 0; var COMPODOC_CURRENT_PAGE_CONTEXT = 'component'; var COMPODOC_CURRENT_PAGE_URL = 'component.html'; </script> <script src="/resources/js/libs/bootstrap-native.js"></script> <script src="/resources/js/libs/prism.js"></script> <script src="/resources/js/compodoc.js"></script> <script src="/resources/js/tabs.js"></script> <script src="/resources/js/sourceCode.js"></script> </body> </html>`; res.setHeader('Content-Type', 'text/html'); res.end(completePage); } catch (error) { logger.error('Error rendering complete page:', error); send(res, 500, { error: 'Failed to render complete page', details: error.message }); } } private async generateDocs(req: any, res: ServerResponse): Promise<void> { try { const { customTemplateContent, mockData } = req.body; // Update mock data if provided if (mockData) { // This part of the logic needs to be adapted to work with the new session-based system // For now, we'll just log that it's not directly applicable here logger.warn('mockData parameter is not directly applicable in this session-based system. It will be ignored.'); } // Create or get session for the documentation generation based on client IP const clientIP = this.getClientIP(req); const session = this.createOrGetSessionByIP(clientIP); const sessionId = session.id; // Update session config if custom template content is provided if (customTemplateContent && req.body.templatePath) { const templatePath = path.join(session.templateDir, req.body.templatePath); await fs.writeFile(templatePath, customTemplateContent, 'utf8'); } // Generate documentation for the new session this.generateDocumentation(sessionId, true); // Use debounce send(res, 200, { success: true, message: 'Documentation generation initiated for a new session', sessionId: sessionId }); } catch (error) { logger.error('Error generating documentation:', error); send(res, 500, { error: 'Failed to generate documentation', details: error.message }); } } private registerHandlebarsHelpers(Handlebars: any, context: any): void { // Register translation helper (matches Compodoc's i18n helper pattern) Handlebars.registerHelper('t', function() { console.log('T HELPER CALLED'); const context = this; const key = arguments[0]; const translations: { [key: string]: string } = { 'components': 'Components', 'modules': 'Modules', 'interfaces': 'Interfaces', 'classes': 'Classes', 'injectables': 'Injectables', 'pipes': 'Pipes', 'directives': 'Directives', 'guards': 'Guards', 'interceptors': 'Interceptors', 'entities': 'Entities', 'controllers': 'Controllers', 'info': 'Info', 'readme': 'Readme', 'source': 'Source', 'template': 'Template', 'styles': 'Styles', 'dom-tree': 'DOM Tree', 'file': 'File', 'description': 'Description', 'implements': 'Implements', 'metadata': 'Metadata', 'index': 'Index', 'methods': 'Methods', 'properties': 'Properties' }; return translations[key] || key; }); // Register relative URL helper Handlebars.registerHelper('relativeURL', (depth: any, ...args: any[]) => { const depthValue = typeof depth === 'number' ? depth : (context.depth || 0); const baseUrl = '../'.repeat(depthValue); const pathArgs = args.slice(0, -1); // Remove Handlebars options object return baseUrl + pathArgs.join('/'); }); // Register comparison helper (matches Compodoc's CompareHelper implementation) Handlebars.registerHelper('compare', function() { const context = this; const a = arguments[0]; const operator = arguments[1]; const b = arguments[2]; const options = arguments[3]; if (arguments.length < 4) { throw new Error('handlebars Helper {{compare}} expects 4 arguments'); } let result = false; switch (operator) { case 'indexof': result = b.indexOf(a) !== -1; break; case '===': result = a === b; break; case '!==': result = a !== b; break; case '>': result = a > b; break; case '<': result = a < b; break; case '>=': result = a >= b; break; case '<=': result = a <= b; break; case '==': result = a == b; break; case '!=': result = a != b; break; default: throw new Error('helper {{compare}}: invalid operator: `' + operator + '`'); } if (result === false) { return options.inverse(context); } return options.fn(context); }); // Register tab helpers (matches Compodoc's IsTabEnabledHelper and IsInitialTabHelper) Handlebars.registerHelper('isTabEnabled', function() { const context = this; const navTabs = arguments[0]; const tabId = arguments[1]; const options = arguments[2]; const isEnabled = navTabs && navTabs.some((tab: any) => tab.id === tabId); if (isEnabled) { return options.fn(context); } else { return options.inverse(context); } }); Handlebars.registerHelper('isInitialTab', function() { const context = this; const navTabs = arguments[0]; const tabId = arguments[1]; const isInitial = navTabs && navTabs.length > 0 && navTabs[0].id === tabId; if (isInitial) { return 'active in'; } return ''; }); // Register utility helpers Handlebars.registerHelper('orLength', function(...args: any[]) { const options = args.pop(); const hasLength = args.some(arg => arg && (Array.isArray(arg) ? arg.length > 0 : arg)); if (hasLength) { return options.fn(this); } else { return options.inverse(this); } }); Handlebars.registerHelper('breakComma', function(array: any[]) { if (Array.isArray(array)) { return array.join(', '); } return array; }); Handlebars.registerHelper('parseDescription', function(description: string, depth: number) { // Simple markdown parsing - just return as HTML for now return new Handlebars.SafeString(description || ''); }); Handlebars.registerHelper('escapeSimpleQuote', function(text: string) { if (typeof text === 'string') { return text.replace(/'/g, "\\'"); } return text; }); // Register JSDoc helper Handlebars.registerHelper('jsdoc-code-example', function(jsdoctags: any[], options: any) { return options.fn({ tags: jsdoctags || [] }); }); // Register link-type helper as a simple partial Handlebars.registerHelper('link-type', function(type: any, options: any) { if (type && type.href) { return new Handlebars.SafeString(`<a href="${type.href}" target="${type.target || '_self'}">${type.raw || type}</a>`); } return type; }); // Register built-in block helpers Handlebars.registerHelper('each', Handlebars.helpers.each); Handlebars.registerHelper('if', Handlebars.helpers.if); Handlebars.registerHelper('unless', Handlebars.helpers.unless); Handlebars.registerHelper('with', Handlebars.helpers.with); // Register common partials used in templates Handlebars.registerPartial('component-detail', ` <p class="comment"> <h3>{{t "file"}}</h3> </p> <p class="comment"> <code>{{component.file}}</code> </p> {{#if component.description}} <p class="comment"> <h3>{{t "description"}}</h3> </p> <p class="comment"> {{{parseDescription component.description depth}}} </p> {{/if}} {{#if component.implements}} <p class="comment"> <h3>{{t "implements"}}</h3> </p> <p class="comment"> {{#each component.implements}} <code>{{this}}</code>{{#unless @last}}, {{/unless}} {{/each}} </p> {{/if}} <section data-compodoc="block-metadata"> <h3>{{t "metadata"}}</h3> <table class="table table-sm table-hover metadata"> <tbody> {{#if component.selector}} <tr> <td class="col-md-3">selector</td> <td class="col-md-9"><code>{{component.selector}}</code></td> </tr>