@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
1,210 lines (1,041 loc) โข 121 kB
text/typescript
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>