import browserSync from 'browser-sync'; import chalk from 'chalk'; import { execSync } from 'child_process'; import { deleteAsync } from 'del'; import esbuild from 'esbuild'; import { replace } from 'esbuild-plugin-replace'; import { mkdir, readFile } from 'fs/promises'; import getPort, { portNumbers } from 'get-port'; import { globby } from 'globby'; import nunjucks from 'nunjucks'; import ora from 'ora'; import { dirname, join, relative } from 'path'; import process from 'process'; import copy from 'recursive-copy'; import { fileURLToPath } from 'url'; import { cdnDir, distDir, docsDir, rootDir, runScript, siteDir } from './utils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const isDeveloping = process.argv.includes('--develop'); const isAlpha = process.argv.includes('--alpha'); const spinner = ora({ text: 'Web Awesome', color: 'cyan' }).start(); const packageData = JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf-8')); const version = JSON.stringify(packageData.version.toString()); let buildContexts = { bundledContext: {}, unbundledContext: {}, }; /** * Runs the full build. */ async function buildAll() { const start = Date.now(); try { await cleanup(); await generateManifest(); await generateReactWrappers(); await generateTypes(); await generateStyles(); // copy everything to unbundled before we generate bundles. await copy(cdnDir, distDir, { overwrite: true }); await generateBundle(); await generateDocs(); const time = (Date.now() - start) / 1000 + 's'; spinner.succeed(`The build is complete ${chalk.gray(`(finished in ${time})`)}`); } catch (err) { spinner.fail(); console.log(chalk.red(`\n${err}`)); } } /** Empties the dist directory. */ async function cleanup() { spinner.start('Cleaning up dist'); await deleteAsync(distDir); await deleteAsync(cdnDir); await mkdir(distDir, { recursive: true }); await mkdir(cdnDir, { recursive: true }); spinner.succeed(); } /** * Analyzes components and generates the custom elements manifest file. */ function generateManifest() { spinner.start('Generating CEM'); try { execSync('cem analyze --config "custom-elements-manifest.js"'); } catch (error) { console.error(`\n\n${error.message}`); if (!isDeveloping) { process.exit(1); } } spinner.succeed(); return Promise.resolve(); } /** * Generates React wrappers for all components. */ function generateReactWrappers() { spinner.start('Generating React wrappers'); try { execSync(`node scripts/make-react.js --outdir "${cdnDir}"`, { stdio: 'inherit' }); } catch (error) { console.error(`\n\n${error.message}`); if (!isDeveloping) { process.exit(1); } } spinner.succeed(); return Promise.resolve(); } /** * Copies theme stylesheets to the dist. */ async function generateStyles() { spinner.start('Copying stylesheets'); // // NOTE - alpha setting omits certain stylesheets that are pro-only // if (isAlpha) { // Copy all styles await copy(join(rootDir, 'src/styles'), join(cdnDir, 'styles'), { overwrite: true }); // Remove pro themes const allThemes = await globby(join(cdnDir, 'styles/themes/**/*.css')); const proThemes = allThemes.filter(file => { if ( file.includes('themes/classic') || file.includes('themes/default') || file.includes('themes/awesome') || file.includes('themes/active') || file.includes('themes/glossy') || file.includes('themes/matter') || file.includes('themes/mellow') || file.includes('themes/playful') || file.includes('themes/premium') || file.includes('themes/tailspin') || file.includes('themes/brutalist') ) { return false; } return true; }); // Delete pro themes that shouldn't be in alpha await Promise.all(proThemes.map(file => deleteAsync(file))); } else { await copy(join(rootDir, 'src/styles'), join(cdnDir, 'styles'), { overwrite: true }); } spinner.succeed(); return Promise.resolve(); } /** * Runs TypeScript to generate types. */ async function generateTypes() { spinner.start('Running the TypeScript compiler'); try { execSync(`tsc --project ./tsconfig.prod.json --outdir "${cdnDir}"`); } catch (error) { if (!isDeveloping) { process.exit(1); } return Promise.reject(error.stdout); } spinner.succeed(); return Promise.resolve(); } /** * Runs esbuild to generate the final dist. */ async function generateBundle() { spinner.start('Bundling with esbuild'); // Bundled config const config = { format: 'esm', target: 'es2020', entryPoints: [ // // IMPORTANT: Entry points MUST be mapped in package.json => exports // // Utilities './src/webawesome.ts', // Autoloader + utilities './src/webawesome.loader.ts', './src/webawesome.ssr-loader.ts', // Individual components ...(await globby('./src/components/**/!(*.(style|test)).ts')), // Translations ...(await globby('./src/translations/**/*.ts')), // React wrappers ...(await globby('./src/react/**/*.ts')), ], outdir: cdnDir, chunkNames: 'chunks/[name].[hash]', define: { 'process.env.NODE_ENV': '"production"', // required by Floating UI }, bundle: true, splitting: true, minify: false, plugins: [replace({ __WEBAWESOME_VERSION__: version })], loader: { '.css': 'text', }, }; const unbundledConfig = { ...config, splitting: true, treeShaking: true, // Don't inline libraries like Lit etc. packages: 'external', outdir: distDir, }; try { if (isDeveloping) { buildContexts.bundledContext = await esbuild.context(config); buildContexts.unbundledContext = await esbuild.context(unbundledConfig); await buildContexts.bundledContext.rebuild(); await buildContexts.unbundledContext.rebuild(); } else { // One-time build for production await esbuild.build(config); await esbuild.build(unbundledConfig); } } catch (error) { spinner.fail(); console.log(chalk.red(`\n${error}`)); if (!isDeveloping) { process.exit(1); } } spinner.succeed(); } /** * Incrementally rebuilds the source files. Must be called only after `generateBundle()` has been called. */ async function regenerateBundle() { try { spinner.start('Re-bundling with esbuild'); await buildContexts.bundledContext.rebuild(); await buildContexts.unbundledContext.rebuild(); } catch (error) { spinner.fail(); console.log(chalk.red(`\n${error}`)); if (!isDeveloping) { process.exit(1); } } spinner.succeed(); } /** * Generates the documentation site. */ async function generateDocs() { /** * Used by the webawesome-app to skip doc generation since it will do its own. */ if (process.env.SKIP_ELEVENTY === 'true') { return; } spinner.start('Writing the docs'); const args = []; if (isAlpha) args.push('--alpha'); if (isDeveloping) args.push('--develop'); let output; try { // 11ty output = (await runScript(join(__dirname, 'docs.js'), args)) // Cleanup the output .replace('[11ty]', '') .replace(' seconds', 's') .replace(/\(.*?\)/, '') .toLowerCase() .trim(); // Copy dist (production only) if (!isDeveloping) { await copy(cdnDir, join(siteDir, 'dist')); } spinner.succeed(`Writing the docs ${chalk.gray(`(${output}`)})`); } catch (error) { console.error('\n\n' + chalk.red(error) + '\n'); spinner.fail(chalk.red(`Error while writing the docs.`)); if (!isDeveloping) { process.exit(1); } } } // Initial build await buildAll(); if (!isDeveloping) { console.log(); // just a newline for readability } // Launch the dev server if (isDeveloping) { spinner.start('Launching the dev server'); const bs = browserSync.create(); const port = await getPort({ port: portNumbers(4000, 4999) }); const url = `http://localhost:${port}/`; const reload = () => { spinner.start('Reloading browser'); bs.reload(); spinner.succeed(); }; // Launch browser sync bs.init( { startPath: '/', port, logLevel: 'silent', logPrefix: '[webawesome]', logFileChanges: true, notify: false, single: false, ghostMode: false, server: { baseDir: siteDir, routes: { '/dist/': './dist-cdn/', }, }, middleware: [ function simulateWebawesomeApp(req, res, next) { // Accumulator for strings so we can pass them through nunjucks a second time similar to how the webawesome-app // will be running nunjucks twice. const finalString = []; const encoding = 'utf-8'; const _write = res.write; res.write = function (chunk, encoding) { finalString.push(chunk.toString()); }; const _end = res.end; res.end = function (...args) { const transformedStr = nunjucks.renderString(finalString.join(''), { // Stub the server EJS shortcodes. server: { head: '', loginOrAvatar: '', flashes: '', }, }); _write.call(res, transformedStr, encoding); _end.call(res, ...args); }; next(); }, ], callbacks: { ready: (_err, instance) => { // 404 errors instance.addMiddleware('*', async (req, res) => { if (req.url.toLowerCase().endsWith('.svg')) { // Make sure SVGs error out in dev instead of serve the 404 page res.writeHead(404); } else { try { const notFoundTemplate = await readFile(join(siteDir, '404.html'), 'utf-8'); res.writeHead(404); res.write(notFoundTemplate || 'Page Not Found'); } catch { // We're probably disconnected for some reason, so fail gracefully } } res.end(); }); }, }, }, () => { spinner.succeed(); console.log(`\nThe dev server is running at ${chalk.cyan(url)}\n`); }, ); // Rebuild and reload when source files change bs.watch('src/**/!(*.test).*').on('change', async filename => { spinner.info(`File modified ${chalk.gray(`(${relative(rootDir, filename)})`)}`); try { const isTestFile = filename.includes('.test.ts'); const isCssStylesheet = filename.includes('.css'); const isComponent = filename.includes('components/') && filename.includes('.ts') && !isCssStylesheet && !isTestFile; // Re-bundle when relevant files change if (isTestFile) { return; } await regenerateBundle(); // Copy stylesheets when CSS files change if (isCssStylesheet) { await generateStyles(); } // Regenerate metadata when components change if (isComponent) { await generateManifest(); } // This needs to be outside of "isComponent" check because SSR needs to run on CSS files too. await generateDocs(); reload(); } catch (err) { console.error(chalk.red(err)); if (!isDeveloping) { process.exit(1); } } }); // Rebuild the docs and reload when the docs change bs.watch(`${docsDir}/**/*.*`).on('change', async filename => { spinner.info(`File modified ${chalk.gray(`(${relative(rootDir, filename)})`)}`); await generateDocs(); reload(); }); } // // Cleanup everything when the process terminates // function terminate() { // dispose of contexts. Object.values(buildContexts).forEach(context => context?.dispose?.()); if (spinner) { spinner.stop(); } process.exit(); } process.on('SIGINT', terminate); process.on('SIGTERM', terminate);