Files
webawesome/scripts/build.js
Lindsay M fba0b11343 Add Matter theme (#547)
* Add Matter theme

* Add Matter to alpha build

* Add changelog

* Fix incorrect palette name

* Make loud fills darker in light mode
2025-01-16 12:08:51 -05:00

431 lines
11 KiB
JavaScript

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 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() {
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/',
},
},
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);