import {deleteAsync} from 'del'; import {exec, spawn} from 'child_process'; import {globby} from 'globby'; import browserSync from 'browser-sync'; import chalk from 'chalk'; import commandLineArgs from 'command-line-args'; import copy from 'recursive-copy'; import esbuild from 'esbuild'; import fs from 'fs/promises'; import getPort, {portNumbers} from 'get-port'; import ora from 'ora'; import util from 'util'; import * as path from 'path'; import {readFileSync} from 'fs'; import {replace} from 'esbuild-plugin-replace'; const {serve, dev} = commandLineArgs([ {name: 'serve', type: Boolean}, {name: 'dev', type: Boolean} ]); const outdir = 'doc/dist'; const cdndir = 'cdn'; const sitedir = 'doc/dist/site'; const spinner = ora({hideCursor: false}).start(); const execPromise = util.promisify(exec); let childProcess; let buildResults = []; const bundleDirectories = [outdir, 'doc/etemplate2/_data']; let packageData = JSON.parse(readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8')); const egwVersion = JSON.stringify(packageData.version.toString()); // Cleanup on exit process.on('SIGINT', handleCleanup); process.on('SIGTERM', handleCleanup); // // Runs 11ty and builds the docs. The returned promise resolves after the initial publish has completed. The child // process and an array of strings containing any output are included in the resolved promise. // // To debug: // > DEBUG=Eleventy* npx @11ty/eleventy // async function buildTheDocs(watch = false) { return new Promise(async (resolve, reject) => { const afterSignal = '[eleventy.after]'; const args = ['@11ty/eleventy', '--quiet']; const output = []; if (watch) { args.push('--watch'); args.push('--incremental'); } // To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy const child = spawn('npx', args, { timeout: 60000, // 60s stdio: 'pipe', cwd: 'doc/etemplate2', shell: true // for Windows }); child.stdout.on('data', data => { if (data.includes(afterSignal)) { return; } // don't log the signal output.push(data.toString()); }); if (watch) { // The process doesn't terminate in watch mode so, before resolving, we listen for a known signal in stdout that // tells us when the first build completes. child.stdout.on('data', data => { if (data.includes(afterSignal)) { resolve({child, output}); } }); } else { child.on('close', () => { resolve({child, output}); }); } }); } // // Builds the source with esbuild. // async function buildTheSource() { const alwaysExternal = [/*'@lit',*/ 'jquery']; const cdnConfig = { format: 'esm', target: 'es2017', entryPoints: [ // // NOTE: Entry points must be mapped in package.json > exports, otherwise users won't be able to import them! // // The whole shebang './api/js/etemplate/etemplate2.ts', // The auto-loader //'./src/shoelace-autoloader.ts', // Components //...(await globby('./src/components/**/!(*.(style|test)).ts')), // Translations //...(await globby('./src/translations/**/*.ts')), // Public utilities //...(await globby('./src/utilities/**/!(*.(style|test)).ts')), // Theme stylesheets //...(await globby('./src/themes/**/!(*.test).ts')), // React wrappers //...(await globby('./src/react/**/*.ts')) ], outdir: sitedir + '/assets/scripts', chunkNames: 'chunks/[name].[hash]', define: { // Floating UI requires this to be set 'process.env.NODE_ENV': '"production"' }, bundle: true, // // We don't bundle certain dependencies in the unbundled build. This ensures we ship bare module specifiers, // allowing end users to better optimize when using a bundler. (Only packages that ship ESM can be external.) // // We never bundle React or @lit-labs/react though! // external: alwaysExternal, splitting: true, plugins: [ replace({ __EGROUPWARE_VERSION__: egwVersion }) ] }; const npmConfig = { ...cdnConfig, external: undefined, minify: false, packages: 'external', outdir }; if (serve) { // Use the context API to allow incremental dev builds const contexts = await Promise.all([esbuild.context(cdnConfig), esbuild.context(npmConfig)]); await Promise.all(contexts.map(context => context.rebuild())); return contexts; } else { // Use the standard API for production builds return await Promise.all([esbuild.build(cdnConfig), esbuild.build(npmConfig)]); } } async function rollup(watch = false) { return new Promise(async (resolve, reject) => { const afterSignal = '[rollup.after]'; const args = ['--silent']; const output = []; if (watch) { args.push('--watch'); args.push('--incremental'); } // To debug use this in terminal: DEBUG=Eleventy* npx @11ty/eleventy const child = spawn('rollup', args, { stdio: 'pipe', cwd: '.', shell: true // for Windows }); child.stdout.on('data', data => { if (data.includes(afterSignal)) { return; } // don't log the signal output.push(data.toString()); }); // Not even waiting resolve({child, output}); }); } // // Called on SIGINT or SIGTERM to cleanup the build and child processes. // function handleCleanup() { buildResults.forEach(result => result.dispose()); if (childProcess) { childProcess.kill('SIGINT'); } process.exit(); } // // Helper function to draw a spinner while tasks run. // async function nextTask(label, action) { spinner.text = label; spinner.start(); try { await action(); spinner.stop(); console.log(`${chalk.green('āœ”')} ${label}`); } catch (err) { spinner.stop(); console.error(`${chalk.red('āœ˜')} ${err}`); if (err.stdout) { console.error(chalk.red(err.stdout)); } if (err.stderr) { console.error(chalk.red(err.stderr)); } process.exit(1); } } await nextTask('Cleaning up the previous build', async () => { await Promise.all([deleteAsync(sitedir), ...bundleDirectories.map(dir => deleteAsync(dir))]); await fs.mkdir(outdir, {recursive: true}); }); await nextTask('Generating component metadata', () => { return Promise.all( bundleDirectories.map(dir => { return execPromise(`node doc/scripts/metadata.mjs --outdir "${dir}"`, {stdio: 'inherit'}); }) ); }); /* await nextTask('Generating themes', () => { return execPromise(`node scripts/make-themes.js --outdir "${outdir}"`, {stdio: 'inherit'}); }); */ /* We don't do these await nextTask('Running the TypeScript compiler', () => { return execPromise(`tsc --project ./tsconfig.json --outdir "${outdir}"`, {stdio: 'inherit'}); }); await nextTask('Building source files', async () => { buildResults = await buildTheSource(); }); */ // EGroupware way of packaging // We can't watch await nextTask('Rolling up', async () => { await rollup(dev); }); // Launch the dev server if (serve) { let result; // Spin up Eleventy and Wait for the search index to appear before proceeding. The search index is generated during // eleventy.after, so it appears after the docs are fully published. This is kinda hacky, but here we are. // Kick off the Eleventy dev server with --watch and --incremental await nextTask('Building docs', async () => { result = await buildTheDocs(true); }); const bs = browserSync.create(); const port = await getPort({port: portNumbers(4000, 4999)}); const browserSyncConfig = { startPath: '/', port, logLevel: 'silent', logPrefix: '[egw]', logFileChanges: true, notify: false, single: false, ghostMode: false, server: { baseDir: sitedir, routes: { '/dist': './cdn' } } }; // Launch browser sync bs.init(browserSyncConfig, () => { const url = `http://localhost:${port}`; console.log(chalk.cyan(`\nšŸ„¾ The dev server is available at ${url}`)); // Log deferred output if (result.output.length > 0) { console.log('\n' + result.output.join('\n')); } // Log output that comes later on result.child.stdout.on('data', data => { console.log(data.toString()); }); }); // Rebuild and reload when source files change bs.watch('src/**/!(*.test).*').on('change', async filename => { console.log('[build] File changed: ', filename); try { const isTheme = /^src\/themes/.test(filename); const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename); // Rebuild the source const rebuildResults = buildResults.map(result => result.rebuild()); await Promise.all(rebuildResults); // Rebuild stylesheets when a theme file changes if (isTheme) { await Promise.all( bundleDirectories.map(dir => { execPromise(`node scripts/make-themes.js --outdir "${dir}"`, {stdio: 'inherit'}); }) ); } // Rebuild metadata (but not when styles are changed) if (!isStylesheet) { await Promise.all( bundleDirectories.map(dir => { return execPromise(`node scripts/make-metadata.js --outdir "${dir}"`, {stdio: 'inherit'}); }) ); } bs.reload(); } catch (err) { console.error(chalk.red(err)); } }); // Reload without rebuilding when the docs change bs.watch([`${sitedir}/**/*.*`]).on('change', filename => { bs.reload(); }); } // Build for production if (!serve) { let result; await nextTask('Building the docs', async () => { result = await buildTheDocs(); }); // Log deferred output if (result.output.length > 0) { console.log('\n' + result.output.join('\n')); } process.exit(0); }