UNPKG

rynex

Version:

A minimalist TypeScript framework for building reactive web applications with no virtual DOM

433 lines (431 loc) 16.8 kB
/** * Rynex Builder - Extended * Handles compilation and bundling with TypeScript support using Rolldown * Supports both simple and advanced project structures */ import { rolldown } from 'rolldown'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { parseRynexFile, transformImports } from './parser.js'; import { logger } from './logger.js'; import { scanRoutes, generateRouteManifest, generateRouterConfig } from './route-scanner.js'; /** * Check if Tailwind CSS is configured * Note: Tailwind CSS support with Rolldown will need a custom plugin or PostCSS integration */ function hasTailwindConfig(projectRoot) { const configFiles = ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts']; return configFiles.some(file => fs.existsSync(path.join(projectRoot, file))); } /** * Copy files from public directory to dist */ function copyPublicFiles(sourceDir, destDir) { const files = fs.readdirSync(sourceDir); for (const file of files) { const sourcePath = path.join(sourceDir, file); const destPath = path.join(destDir, file); const stat = fs.statSync(sourcePath); if (stat.isDirectory()) { // Recursively copy directories if (!fs.existsSync(destPath)) { fs.mkdirSync(destPath, { recursive: true }); } copyPublicFiles(sourcePath, destPath); } else { // Copy files (skip styles.css as it's handled separately) if (file !== 'styles.css') { fs.copyFileSync(sourcePath, destPath); logger.debug(`Copied: ${file}`); } } } } /** * Build components in src/components directory */ async function buildComponents(projectRoot, distDir, minify, sourceMaps) { const componentsDir = path.join(projectRoot, 'src', 'components'); if (!fs.existsSync(componentsDir)) { logger.debug('No components directory found'); return; } const componentFiles = fs.readdirSync(componentsDir).filter(f => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.endsWith('.d.ts')); if (componentFiles.length === 0) { logger.debug('No component files to build'); return; } logger.info(`Building ${componentFiles.length} components`); const distComponentsDir = path.join(distDir, 'components'); if (!fs.existsSync(distComponentsDir)) { fs.mkdirSync(distComponentsDir, { recursive: true }); } for (const file of componentFiles) { const componentPath = path.join(componentsDir, file); const componentName = path.basename(file, path.extname(file)); const outputPath = path.join(distComponentsDir, `${componentName}.bundel.js`); logger.debug(`Building component: ${componentName}`); try { const build = await rolldown({ input: componentPath, cwd: projectRoot, external: [] }); await build.write({ file: outputPath, format: 'es', sourcemap: sourceMaps, minify }); await build.close(); logger.success(`Built component: ${componentName}.bundel.js`); } catch (error) { logger.error(`Failed to build component ${componentName}`, error); } } } /** * Generate a cache-busting hash based on timestamp */ function generateBuildHash() { const timestamp = Date.now().toString(); return crypto.createHash('md5').update(timestamp).digest('hex').substring(0, 8); } /** * Generate HTML file for a page with cache-busting */ function generatePageHTML(pageName, distPageDir, buildHash) { const html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Expires" content="0"> <meta name="build-version" content="${buildHash}"> <title>${pageName.charAt(0).toUpperCase() + pageName.slice(1)} - Rynex</title> <link rel="stylesheet" href="styles.css?v=${buildHash}"> </head> <body> <div id="root"></div> <script type="module" src="bundel.js?v=${buildHash}"></script> </body> </html> `; const htmlPath = path.join(distPageDir, 'page.html'); fs.writeFileSync(htmlPath, html, 'utf8'); logger.debug(`Generated HTML for page: ${pageName} with build hash: ${buildHash}`); } /** * Generate CSS file for a page */ function generatePageCSS(distPageDir) { const css = `/* Page Styles */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } `; const cssPath = path.join(distPageDir, 'styles.css'); fs.writeFileSync(cssPath, css, 'utf8'); logger.debug(`Generated CSS for page`); } /** * Build pages in src/pages directory */ async function buildPages(projectRoot, distDir, minify, sourceMaps, buildHash) { const pagesDir = path.join(projectRoot, 'src', 'pages'); if (!fs.existsSync(pagesDir)) { logger.debug('No pages directory found'); return; } const pageDirectories = fs.readdirSync(pagesDir).filter(f => { const fullPath = path.join(pagesDir, f); return fs.statSync(fullPath).isDirectory(); }); if (pageDirectories.length === 0) { logger.debug('No page directories to build'); return; } logger.info(`Building ${pageDirectories.length} pages`); for (const pageDir of pageDirectories) { const pagePath = path.join(pagesDir, pageDir); const pageFile = path.join(pagePath, 'page.ts'); if (!fs.existsSync(pageFile)) { logger.warning(`No page.ts found in ${pageDir}`); continue; } const distPageDir = path.join(distDir, 'pages', pageDir); if (!fs.existsSync(distPageDir)) { fs.mkdirSync(distPageDir, { recursive: true }); } // Build page TypeScript to bundel.js const outputPath = path.join(distPageDir, 'bundel.js'); logger.debug(`Building page: ${pageDir}`); try { const build = await rolldown({ input: pageFile, cwd: projectRoot, external: [] }); await build.write({ file: outputPath, format: 'es', sourcemap: sourceMaps, minify }); await build.close(); // Generate page.html with cache-busting generatePageHTML(pageDir, distPageDir, buildHash); // Generate styles.css generatePageCSS(distPageDir); logger.success(`Built page: ${pageDir}`); } catch (error) { logger.error(`Failed to build page ${pageDir}`, error); } } } /** * Build main entry point (for simple projects) */ async function buildMainEntry(projectRoot, options, allStyles, buildHash) { const isDebug = process.argv.includes('--debug'); const distDir = path.join(projectRoot, path.dirname(options.output)); // Create Rolldown plugin for Rynex transformation const rynexPlugin = { name: 'rynex-transform', async transform(code, id) { // Only process TypeScript and JavaScript files if (!/\.(ts|js|tsx|jsx)$/.test(id)) { return null; } logger.debug(`Processing file: ${id}`); // Check if file contains view or style keywords if (code.includes('view {') || code.includes('style {')) { logger.debug(`Found view/style keywords in: ${id}`); const parsed = parseRynexFile(code); if (parsed.styles) { logger.debug(`Extracted ${parsed.styles.length} chars of styles from: ${id}`); allStyles += parsed.styles; } let transformedCode = parsed.code; transformedCode = transformImports(transformedCode); logger.debug(`Transformed file: ${id}`); return { code: transformedCode, map: null }; } // Transform imports even if no view/style logger.debug(`No view/style keywords in: ${id}`); const transformedCode = transformImports(code); return { code: transformedCode, map: null }; } }; logger.debug(`Building main entry: ${options.entry}`); // Setup plugins array const plugins = []; // Check for Tailwind CSS if (hasTailwindConfig(projectRoot)) { logger.info('Tailwind CSS config detected (manual PostCSS integration recommended)'); } // Add Rynex plugin plugins.push(rynexPlugin); // Build with Rolldown const build = await rolldown({ input: path.join(projectRoot, options.entry), cwd: projectRoot, plugins: plugins, external: [] }); await build.write({ file: path.join(projectRoot, options.output), format: 'es', sourcemap: options.sourceMaps, minify: options.minify }); await build.close(); logger.debug(`Rolldown build completed successfully`); // Handle styles.css const publicStylesPath = path.join(projectRoot, 'public', 'styles.css'); const distStylesPath = path.join(distDir, 'styles.css'); if (allStyles) { // If we extracted styles from components, write them logger.debug(`Writing ${allStyles.length} chars of extracted CSS to ${distStylesPath}`); await fs.promises.writeFile(distStylesPath, allStyles, 'utf8'); logger.success('Component styles written to dist/styles.css'); } else if (!fs.existsSync(distStylesPath)) { // If no extracted styles and no styles.css exists, create empty one logger.debug('No styles found, creating default styles.css'); const defaultStyles = `/* Rynex Styles */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } `; await fs.promises.writeFile(distStylesPath, defaultStyles, 'utf8'); logger.success('Created default styles.css'); } logger.success(`Build complete: ${options.output}`); } /** * Build a Rynex project * Supports both simple and advanced project structures */ export async function build(options) { const isDebug = process.argv.includes('--debug'); if (isDebug) { logger.setDebug(true); } logger.info('Building Rynex project'); logger.debug(`Build options: ${JSON.stringify(options)}`); const projectRoot = process.cwd(); const srcDir = path.join(projectRoot, 'src'); const distDir = path.join(projectRoot, path.dirname(options.output)); logger.debug(`Project root: ${projectRoot}`); logger.debug(`Source directory: ${srcDir}`); logger.debug(`Output directory: ${distDir}`); // Ensure dist directory exists if (!fs.existsSync(distDir)) { fs.mkdirSync(distDir, { recursive: true }); } // Generate build hash for cache-busting const buildHash = generateBuildHash(); logger.info(`Build hash: ${buildHash}`); // Collect all styles let allStyles = ''; try { // Scan and generate routes if file-based routing is enabled if (options.config?.routing?.fileBasedRouting) { const pagesDir = path.join(projectRoot, options.config.routing.pagesDir || 'src/pages'); if (fs.existsSync(pagesDir)) { logger.info('Scanning file-based routes...'); const routeManifest = scanRoutes(pagesDir); // Generate route manifest const manifestPath = path.join(distDir, 'route-manifest.js'); generateRouteManifest(routeManifest, manifestPath); // Generate router config const routerConfigPath = path.join(distDir, 'router-config.js'); generateRouterConfig(routeManifest, routerConfigPath); logger.success(`Generated routes: ${routeManifest.routes.length} routes found`); } } // Check if this is an advanced project structure (has components or pages) const hasComponents = fs.existsSync(path.join(srcDir, 'components')); const hasPages = fs.existsSync(path.join(srcDir, 'pages')); if (hasComponents || hasPages) { logger.info('Detected advanced project structure'); // Build components if (hasComponents) { await buildComponents(projectRoot, distDir, options.minify, options.sourceMaps); } // Build pages if (hasPages) { await buildPages(projectRoot, distDir, options.minify, options.sourceMaps, buildHash); } } // Always build main entry point await buildMainEntry(projectRoot, options, allStyles, buildHash); // Update index.html with cache-busting if it exists const indexHtmlPath = path.join(distDir, 'index.html'); if (fs.existsSync(indexHtmlPath)) { let indexHtml = fs.readFileSync(indexHtmlPath, 'utf8'); // Add cache-busting meta tags if not present if (!indexHtml.includes('Cache-Control')) { indexHtml = indexHtml.replace('<head>', `<head>\n <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">\n <meta http-equiv="Pragma" content="no-cache">\n <meta http-equiv="Expires" content="0">\n <meta name="build-version" content="${buildHash}">`); } // Add version query params to JS and CSS files indexHtml = indexHtml.replace(/(src|href)="([^"]+\.(js|css|mjs))"/g, (match, attr, file) => { if (file.includes('?v=')) return match; return `${attr}="${file}?v=${buildHash}"`; }); fs.writeFileSync(indexHtmlPath, indexHtml, 'utf8'); logger.success(`Updated index.html with cache-busting (v=${buildHash})`); } // Copy public files to dist const publicDir = path.join(projectRoot, 'public'); if (fs.existsSync(publicDir)) { logger.debug(`Copying public files from ${publicDir} to ${distDir}`); copyPublicFiles(publicDir, distDir); logger.success('Public files copied to dist'); } } catch (error) { logger.error('Build failed', error); logger.debug(`Error details: ${JSON.stringify(error, null, 2)}`); throw error; } } /** * Watch mode for development */ export async function watch(options) { logger.info('Watching for changes'); // Use esbuild's watch mode const projectRoot = process.cwd(); const rynexPlugin = { name: 'rynex-transform', async transform(code, id) { if (!/\.(ts|js|tsx|jsx)$/.test(id)) { return null; } if (code.includes('view {') || code.includes('style {')) { const parsed = parseRynexFile(code); let transformedCode = parsed.code; transformedCode = transformImports(transformedCode); return { code: transformedCode, map: null }; } const transformedCode = transformImports(code); return { code: transformedCode, map: null }; } }; // Setup plugins for watch mode const watchPlugins = []; // Check for Tailwind CSS if (hasTailwindConfig(projectRoot)) { logger.info('Tailwind CSS config detected (manual PostCSS integration recommended)'); } // Add Rynex plugin watchPlugins.push(rynexPlugin); const build = await rolldown({ input: path.join(projectRoot, options.entry), cwd: projectRoot, plugins: watchPlugins, external: [], watch: { skipWrite: false } }); await build.write({ file: path.join(projectRoot, options.output), format: 'es', sourcemap: true, minify: false }); logger.success('Watch mode enabled (Note: Rolldown watch support may require manual rebuild)'); } //# sourceMappingURL=builder.js.map