@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
1,293 lines (1,131 loc) โข 98.1 kB
text/typescript
const polka = require('polka');
const sirv = require('sirv');
const { json, urlencoded } = require('body-parser');
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 { ZipArchive } = require('archiver');
import { execSync } from 'child_process';
import { logger } from '../utils/logger';
import { registerTemplatePlaygroundHandlebarsHelpers } from './handlebars-helpers';
import { createSessionTemplateMockData } from './session-template-mock-data';
function send(res: ServerResponse, statusCode: number, body: unknown): void {
res.statusCode = statusCode;
if (body === undefined) {
res.end();
return;
}
if (typeof body === 'string' || Buffer.isBuffer(body)) {
res.end(body);
return;
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
}
res.end(JSON.stringify(body));
}
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 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 = 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
}
};
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> {
// Skip actual CLI execution in test mode to avoid slow/destructive side effects
if (process.env.NODE_ENV === 'test') {
logger.info(`[test mode] Skipping compodoc generation for session ${sessionId}`);
return;
}
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');
// Resolve CLI path by walking up from __dirname until bin/index-cli.js is found.
// This works in production (__dirname = dist/), in tests (__dirname = test/dist/โฆ),
// and survives process.chdir() calls made by test suites.
const findCli = (startDir: string): string | undefined => {
let dir = startDir;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, 'bin', 'index-cli.js');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return undefined;
};
const cliPath =
process.env.COMPODOC_CLI_PATH ??
findCli(__dirname) ??
path.resolve(__dirname, '../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 {
registerTemplatePlaygroundHandlebarsHelpers(Handlebars, context);
}
private generateCompodocHtml(data: any): string {
const component = data.component || {};
const navTabs = data.navTabs || [];
// Generate navigation tabs
const tabsHtml = navTabs
.map((tab, index) => {
const isActive = index === 0;
const activeClass = isActive ? 'nav-link active' : 'nav-link';
const labelMap = {
info: 'Info',
api: 'API',
readme: 'Readme',
source: 'Source',
template: 'Template',
styles: 'Styles',
'dom-tree': 'DOM Tree'
};
const label = labelMap[tab.label] || tab.label;
return ` <li class="nav-item">
<a href="${tab.href}" class="${activeClass}" role="tab" id="${tab.id}-tab" data-bs-toggle="tab" data-link="${tab['data-link']}">${label}</a>
</li>`;
})
.join('\n');
// Generate tab content
let tabContentHtml = '';
// Info tab
if (navTabs.some(tab => tab.id === 'info')) {
const isActive = navTabs[0].id === 'info';
const activeClass = isActive ? 'active in' : '';
tabContentHtml += ` <div class="tab-pane fade ${activeClass}" id="info">
<p class="comment">
<h3>File</h3>
</p>
<p class="comment">
<code>${component.file || ''}</code>
</p>
${
component.description
? `
<p class="comment">
<h3>Description</h3>
</p>
<p class="comment">
<p>${component.description.replace(/\n/g, '</p>\n<p>')}</p>
</p>
`
: ''
}
${
component.implements && component.implements.length > 0
? `
<p class="comment">
<h3>Implements</h3>
</p>
<p class="comment">
${component.implements.map(impl => `<code>${impl}</code>`).join(', ')}
</p>
`
: ''
}
<section data-compodoc="block-metadata">
<h3>Metadata</h3>
<table class="table table-sm table-hover metadata">
<tbody>
${
component.selector
? `
<tr>
<td class="col-md-3">selector</td>
<td class="col-md-9"><code>${component.selector}</code></td>
</tr>`
: ''
}
${
component.templateUrl
? `
<tr>
<td class="col-md-3">templateUrl</td>
<td class="col-md-9"><code>${component.templateUrl}</code></td>
</tr>`
: ''
}
${
component.styleUrls && component.styleUrls.length > 0
? `
<tr>
<td class="col-md-3">styleUrls</td>
<td class="col-md-9"><code>${component.styleUrls.join(', ')}</code></td>
</tr>`
: ''
}
</tbody>
</table>
</section>
${
component.methods && component.methods.length > 0
? `
<section data-compodoc="block-index">
<h3 id="index">Index</h3>
<table class="table table-sm table-bordered index-table">
<tbody>
<tr>
<td class="col-md-4">
<h6><b>Methods</b></h6>
</td>
</tr>
<tr>
<td class="col-md-4">
<ul class="index-list">
${component.methods.map(method => `<li><a href="#${method.name}">${method.name}</a></li>`).join('\n ')}
</ul>
</td>
</tr>
</tbody>
</table>
</section>
<section data-compodoc="block-methods">
<h3 id="methods">Methods</h3>
${component.methods
.map(
method => `