/* eslint-disable no-console */ const http = require('http'); const fs = require('fs'); const path = require('path'); const url = require('url'); const minimist = require('minimist'); const chalk = require('chalk'); const ora = require('ora'); const { encodeThemesToQueryParam } = require('./queryParamEncoding'); // Constants const DEFAULT_THEME_NAME = 'Local Theme'; const DEFAULT_DOCS_URL = 'https://paragon-openedx.netlify.app/'; /** * Generates a docs URL with encoded themes parameter */ function generateDocsUrl(themeUrls, host, port, themeName, docsBaseUrl) { if (!themeUrls) { return null; } const themes = []; const activeIndex = 1; // Set active index to 1 since we'll add default theme first // Always include the default Open edX theme first (index 0) themes.push({ name: 'Open edX (Default)', urls: [], }); // Create a single theme variant that includes all available CSS files const themeUrlList = []; // Add core.css if it exists if (themeUrls.core) { const corePath = themeUrls.core.paths.default.replace(/^\.\//, ''); themeUrlList.push(`http://${host}:${port}/${corePath}`); } // Add all theme variants if they exist if (themeUrls.variants) { Object.entries(themeUrls.variants).forEach(([, themeData]) => { const themePath = themeData.paths.default.replace(/^\.\//, ''); themeUrlList.push(`http://${host}:${port}/${themePath}`); }); } if (themeUrlList.length === 0) { // If no local themes, just return URL with default theme const encodedThemes = encodeThemesToQueryParam(themes, 0); return `${docsBaseUrl}?themes=${encodedThemes}`; } // Create a single theme with all the combined URLs (index 1) themes.push({ name: themeName, urls: themeUrlList, }); const encodedThemes = encodeThemesToQueryParam(themes, activeIndex); return `${docsBaseUrl}?themes=${encodedThemes}`; } /** * Serves theme CSS files on a local server as if they were on a CDN. * * @param {string[]} commandArgs - Command line arguments for serving theme CSS files. * @param {string} [commandArgs.build-dir='./dist'] - The directory containing built CSS files to serve. * @param {number} [commandArgs.port=3000] - The port to serve files on. * @param {string} [commandArgs.host='localhost'] - The host to serve files on. * @param {boolean} [commandArgs.cors=true] - Whether to enable CORS headers. * @param {string} [commandArgs.theme-name='Local Theme'] - The name for the theme in the docs URL. * @param {string} [commandArgs.docs-url] - The base URL for the Paragon docs site. */ async function serveThemeCssCommand(commandArgs) { const defaultArgs = { 'build-dir': './dist', port: 3000, host: 'localhost', cors: true, 'theme-name': DEFAULT_THEME_NAME, 'docs-url': DEFAULT_DOCS_URL, }; const alias = { 'build-dir': 'b', port: 'p', host: 'h', 'theme-name': 't', 'docs-url': 'd', }; const { 'build-dir': buildDir, port, host, cors, 'theme-name': themeName, 'docs-url': docsUrl, } = minimist(commandArgs, { alias, default: defaultArgs, boolean: ['cors'], }); const resolvedBuildDir = path.resolve(process.cwd(), buildDir); // Check if build directory exists if (!fs.existsSync(resolvedBuildDir)) { console.error(chalk.red.bold(`Error: Build directory '${resolvedBuildDir}' does not exist.`)); console.error(chalk.yellow('Please run `paragon build-scss` first to generate CSS files.')); process.exit(1); } // Check for theme-urls.json to validate this is a proper dist directory const themeUrlsPath = path.join(resolvedBuildDir, 'theme-urls.json'); if (!fs.existsSync(themeUrlsPath)) { console.error(chalk.red.bold(`Error: '${resolvedBuildDir}' does not appear to be a valid Paragon dist directory.`)); console.error(chalk.yellow('Missing theme-urls.json file. Please run `paragon build-scss` first.')); process.exit(1); } // Read theme-urls.json to understand the available files let themeUrls; try { const themeUrlsContent = fs.readFileSync(themeUrlsPath, 'utf8'); const themeUrlsJson = JSON.parse(themeUrlsContent); themeUrls = themeUrlsJson.themeUrls; } catch (error) { console.error(chalk.red.bold('Error: Could not read theme-urls.json file.')); process.exit(1); } // Create server const server = http.createServer((req, res) => { const parsedUrl = url.parse(req.url, true); let filePath = parsedUrl.pathname; // Remove leading slash if (filePath.startsWith('/')) { filePath = filePath.substring(1); } // Set content type and no-cache headers for development const setContentTypeAndNoCache = (contentType) => { res.setHeader('Content-Type', contentType); // Add no-cache headers for development res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); }; // Set CORS headers if enabled const setCorsHeaders = () => { if (cors) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); } }; // Handle theme-urls.json or default to theme-urls.json if no file specified if (filePath === 'theme-urls.json' || filePath === '') { // Set CORS headers if enabled setCorsHeaders(); // Set content type and no-cache headers using helper function setContentTypeAndNoCache('application/json'); res.end(JSON.stringify(themeUrls, null, 2)); return; } // Resolve file path relative to build directory const fullPath = path.join(resolvedBuildDir, filePath); // Security check: ensure file is within build directory if (!fullPath.startsWith(resolvedBuildDir)) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return; } // Set CORS headers if enabled setCorsHeaders(); // Handle OPTIONS requests for CORS preflight if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // Check if file exists if (!fs.existsSync(fullPath)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); return; } // Get file stats const stats = fs.statSync(fullPath); // Handle directories if (stats.isDirectory()) { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Directory access not allowed'); return; } // Set appropriate content type based on file extension const ext = path.extname(fullPath).toLowerCase(); // Only serve CSS files and theme configuration JSON if (ext === '.css') { setContentTypeAndNoCache('text/css'); } else if (ext === '.json' && path.basename(fullPath) === 'theme-urls.json') { setContentTypeAndNoCache('application/json'); } else { // Reject all other file types res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Only CSS files and theme-urls.json are allowed'); return; } res.setHeader('Content-Length', stats.size); // Stream the file const fileStream = fs.createReadStream(fullPath); fileStream.pipe(res); fileStream.on('error', (error) => { console.error(chalk.red(`Error serving file ${filePath}:`, error.message)); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal server error'); } }); }); // Start server server.listen(port, host, () => { const spinner = ora('Starting theme CSS server...').start(); setTimeout(() => { spinner.succeed(chalk.green.bold(`Theme CSS server running at http://${host}:${port}`)); console.log(chalk.cyan('\nAvailable theme files:')); // List available files based on theme-urls.json if (themeUrls) { // Core files if (themeUrls.core) { console.log(chalk.yellow('\nCore CSS:')); const coreDefault = themeUrls.core.paths.default.replace(/^\.\//, ''); const coreMinified = themeUrls.core.paths.minified.replace(/^\.\//, ''); console.log(chalk.gray(` http://${host}:${port}/${coreDefault}`)); console.log(chalk.gray(` http://${host}:${port}/${coreMinified}`)); } // Theme variants if (themeUrls.variants) { console.log(chalk.yellow('\nTheme Variants:')); Object.entries(themeUrls.variants).forEach(([variantName, themeData]) => { const isDefault = themeUrls.defaults && themeUrls.defaults[variantName]; const prefix = isDefault ? chalk.green('★ ') : ' '; const themeDefault = themeData.paths.default.replace(/^\.\//, ''); const themeMinified = themeData.paths.minified.replace(/^\.\//, ''); console.log(chalk.gray(`${prefix}${variantName}:`)); console.log(chalk.gray(` http://${host}:${port}/${themeDefault}`)); console.log(chalk.gray(` http://${host}:${port}/${themeMinified}`)); }); } // Theme URLs JSON console.log(chalk.yellow('\nTheme Configuration:')); console.log(chalk.gray(` http://${host}:${port}/theme-urls.json`)); } // Show the Paragon docs URL with encoded themes const docsUrlWithThemes = generateDocsUrl(themeUrls, host, port, themeName, docsUrl); if (docsUrlWithThemes) { console.log(chalk.green.bold('\n📖 Paragon Docs URL with encoded themes:')); console.log(chalk.cyan(docsUrlWithThemes)); console.log(chalk.gray('\nThis URL will load the Paragon docs site with the default Open edX theme and your local theme CSS files pre-configured.')); console.log(chalk.gray('The local theme will be active by default, but you can switch to the default Open edX theme using the theme selector.')); } else { console.log(chalk.yellow('\n⚠️ No themes found to encode for docs URL.')); } console.log(chalk.gray('\nPress Ctrl+C to stop the server')); }, 1000); }); // Handle server errors server.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(chalk.red.bold(`Error: Port ${port} is already in use.`)); console.error(chalk.yellow('Try using a different port with --port option.')); } else { console.error(chalk.red.bold('Server error:', error.message)); } process.exit(1); }); // Handle graceful shutdown process.on('SIGINT', () => { console.log(chalk.gray('\nShutting down server...')); server.close(() => { console.log(chalk.green('Server stopped')); process.exit(0); }); }); } module.exports = serveThemeCssCommand;