summaryrefslogtreecommitdiff
path: root/scripts/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/cmd')
-rw-r--r--scripts/cmd/build.js167
-rw-r--r--scripts/cmd/prebuild.js115
-rw-r--r--scripts/cmd/test.js89
3 files changed, 371 insertions, 0 deletions
diff --git a/scripts/cmd/build.js b/scripts/cmd/build.js
new file mode 100644
index 000000000..2cc99b7d3
--- /dev/null
+++ b/scripts/cmd/build.js
@@ -0,0 +1,167 @@
+import fs from 'node:fs/promises';
+import esbuild from 'esbuild';
+import glob from 'fast-glob';
+import { dim, green, red, yellow } from 'kleur/colors';
+import prebuild from './prebuild.js';
+
+/** @type {import('esbuild').BuildOptions} */
+const defaultConfig = {
+ minify: false,
+ format: 'esm',
+ platform: 'node',
+ target: 'node18',
+ sourcemap: false,
+ sourcesContent: false,
+};
+
+const dt = new Intl.DateTimeFormat('en-us', {
+ hour: '2-digit',
+ minute: '2-digit',
+});
+
+function getPrebuilds(isDev, args) {
+ let prebuilds = [];
+ while (args.includes('--prebuild')) {
+ let idx = args.indexOf('--prebuild');
+ prebuilds.push(args[idx + 1]);
+ args.splice(idx, 2);
+ }
+ if (prebuilds.length && isDev) {
+ prebuilds.unshift('--no-minify');
+ }
+ return prebuilds;
+}
+
+export default async function build(...args) {
+ const config = Object.assign({}, defaultConfig);
+ const isDev = args.slice(-1)[0] === 'IS_DEV';
+ const prebuilds = getPrebuilds(isDev, args);
+ const patterns = args
+ .filter((f) => !!f) // remove empty args
+ .map((f) => f.replace(/^'/, '').replace(/'$/, '')); // Needed for Windows: glob strings contain surrounding string chars??? remove these
+ let entryPoints = [].concat(
+ ...(await Promise.all(
+ patterns.map((pattern) => glob(pattern, { filesOnly: true, absolute: true })),
+ )),
+ );
+
+ const noClean = args.includes('--no-clean-dist');
+ const bundle = args.includes('--bundle');
+ const forceCJS = args.includes('--force-cjs');
+
+ const { type = 'module', dependencies = {} } = await readPackageJSON('./package.json');
+
+ config.define = {};
+ for (const [key, value] of await getDefinedEntries()) {
+ config.define[`process.env.${key}`] = JSON.stringify(value);
+ }
+ const format = type === 'module' && !forceCJS ? 'esm' : 'cjs';
+
+ const outdir = 'dist';
+
+ if (!noClean) {
+ await clean(outdir);
+ }
+
+ if (!isDev) {
+ await esbuild.build({
+ ...config,
+ bundle,
+ external: bundle ? Object.keys(dependencies) : undefined,
+ entryPoints,
+ outdir,
+ outExtension: forceCJS ? { '.js': '.cjs' } : {},
+ format,
+ });
+ return;
+ }
+
+ const rebuildPlugin = {
+ name: 'astro:rebuild',
+ setup(build) {
+ build.onEnd(async (result) => {
+ if (prebuilds.length) {
+ await prebuild(...prebuilds);
+ }
+ const date = dt.format(new Date());
+ if (result && result.errors.length) {
+ console.error(dim(`[${date}] `) + red(error || result.errors.join('\n')));
+ } else {
+ if (result.warnings.length) {
+ console.info(
+ dim(`[${date}] `) + yellow('! updated with warnings:\n' + result.warnings.join('\n')),
+ );
+ }
+ console.info(dim(`[${date}] `) + green('√ updated'));
+ }
+ });
+ },
+ };
+
+ const builder = await esbuild.context({
+ ...config,
+ entryPoints,
+ outdir,
+ format,
+ sourcemap: 'linked',
+ plugins: [rebuildPlugin],
+ });
+
+ await builder.watch();
+
+ process.on('beforeExit', () => {
+ builder.stop && builder.stop();
+ });
+}
+
+async function clean(outdir) {
+ const files = await glob([`${outdir}/**`, `!${outdir}/**/*.d.ts`], { filesOnly: true });
+ await Promise.all(files.map((file) => fs.rm(file, { force: true })));
+}
+
+/**
+ * Contextual `define` values to statically replace in the built JS output.
+ * Available to all packages, but mostly useful for CLIs like `create-astro`.
+ */
+async function getDefinedEntries() {
+ const define = {
+ /** The current version (at the time of building) for the current package, such as `astro` or `@astrojs/sitemap` */
+ PACKAGE_VERSION: await getInternalPackageVersion('./package.json'),
+ /** The current version (at the time of building) for `astro` */
+ ASTRO_VERSION: await getInternalPackageVersion(
+ new URL('../../packages/astro/package.json', import.meta.url),
+ ),
+ /** The current version (at the time of building) for `@astrojs/check` */
+ ASTRO_CHECK_VERSION: await getWorkspacePackageVersion('@astrojs/check'),
+ /** The current version (at the time of building) for `typescript` */
+ TYPESCRIPT_VERSION: await getWorkspacePackageVersion('typescript'),
+ };
+ for (const [key, value] of Object.entries(define)) {
+ if (value === undefined) {
+ delete define[key];
+ }
+ }
+ return Object.entries(define);
+}
+
+async function readPackageJSON(path) {
+ return await fs.readFile(path, { encoding: 'utf8' }).then((res) => JSON.parse(res));
+}
+
+async function getInternalPackageVersion(path) {
+ return readPackageJSON(path).then((res) => res.version);
+}
+
+async function getWorkspacePackageVersion(packageName) {
+ const { dependencies, devDependencies } = await readPackageJSON(
+ new URL('../../package.json', import.meta.url),
+ );
+ const deps = { ...dependencies, ...devDependencies };
+ const version = deps[packageName];
+ if (!version) {
+ throw new Error(
+ `Unable to resolve "${packageName}". Is it a dependency of the workspace root?`,
+ );
+ }
+ return version.replace(/^\D+/, '');
+}
diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js
new file mode 100644
index 000000000..7c4174abf
--- /dev/null
+++ b/scripts/cmd/prebuild.js
@@ -0,0 +1,115 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import esbuild from 'esbuild';
+import glob from 'fast-glob';
+import { red } from 'kleur/colors';
+
+function escapeTemplateLiterals(str) {
+ return str.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${');
+}
+
+export default async function prebuild(...args) {
+ let buildToString = args.indexOf('--to-string');
+ if (buildToString !== -1) {
+ args.splice(buildToString, 1);
+ buildToString = true;
+ }
+ let minify = true;
+ let minifyIdx = args.indexOf('--no-minify');
+ if (minifyIdx !== -1) {
+ minify = false;
+ args.splice(minifyIdx, 1);
+ }
+
+ let patterns = args;
+ // NOTE: absolute paths returned are forward slashes on windows
+ let entryPoints = [].concat(
+ ...(await Promise.all(
+ patterns.map((pattern) => glob(pattern, { onlyFiles: true, absolute: true })),
+ )),
+ );
+
+ function getPrebuildURL(entryfilepath, dev = false) {
+ const entryURL = pathToFileURL(entryfilepath);
+ const basename = path.basename(entryfilepath);
+ const ext = path.extname(entryfilepath);
+ const name = basename.slice(0, basename.indexOf(ext));
+ const outname = dev ? `${name}.prebuilt-dev${ext}` : `${name}.prebuilt${ext}`;
+ const outURL = new URL('./' + outname, entryURL);
+ return outURL;
+ }
+
+ async function prebuildFile(filepath) {
+ let tscode = await fs.promises.readFile(filepath, 'utf-8');
+ // If we're bundling a client directive, modify the code to match `packages/astro/src/core/client-directive/build.ts`.
+ // If updating this code, make sure to also update that file.
+ if (filepath.includes('runtime/client')) {
+ // `export default xxxDirective` is a convention used in the current client directives that we use
+ // to make sure we bundle this right. We'll error below if this convention isn't followed.
+ const newTscode = tscode.replace(
+ /export default (.*?)Directive/,
+ (_, name) =>
+ `(self.Astro || (self.Astro = {})).${name} = ${name}Directive;window.dispatchEvent(new Event('astro:${name}'))`,
+ );
+ if (newTscode === tscode) {
+ console.error(
+ red(
+ `${filepath} doesn't follow the \`export default xxxDirective\` convention. The prebuilt output may be wrong. ` +
+ `For more information, check out ${fileURLToPath(import.meta.url)}`,
+ ),
+ );
+ }
+ tscode = newTscode;
+ }
+
+ const esbuildOptions = {
+ stdin: {
+ contents: tscode,
+ resolveDir: path.dirname(filepath),
+ loader: 'ts',
+ sourcefile: filepath,
+ },
+ format: 'iife',
+ target: ['es2018'],
+ minify,
+ bundle: true,
+ write: false,
+ };
+
+ const results = await Promise.all(
+ [
+ {
+ build: await esbuild.build(esbuildOptions),
+ dev: false,
+ },
+ filepath.includes('astro-island')
+ ? {
+ build: await esbuild.build({
+ ...esbuildOptions,
+ define: { 'process.env.NODE_ENV': '"development"' },
+ }),
+ dev: true,
+ }
+ : undefined,
+ ].filter((entry) => entry),
+ );
+
+ for (const result of results) {
+ const code = result.build.outputFiles[0].text.trim();
+ const rootURL = new URL('../../', import.meta.url);
+ const rel = path.relative(fileURLToPath(rootURL), filepath);
+ const mod = `/**
+ * This file is prebuilt from ${rel}
+ * Do not edit this directly, but instead edit that file and rerun the prebuild
+ * to generate this file.
+ */
+
+export default \`${escapeTemplateLiterals(code)}\`;`;
+ const url = getPrebuildURL(filepath, result.dev);
+ await fs.promises.writeFile(url, mod, 'utf-8');
+ }
+ }
+
+ await Promise.all(entryPoints.map(prebuildFile));
+}
diff --git a/scripts/cmd/test.js b/scripts/cmd/test.js
new file mode 100644
index 000000000..3b266ff1c
--- /dev/null
+++ b/scripts/cmd/test.js
@@ -0,0 +1,89 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { run } from 'node:test';
+import { spec } from 'node:test/reporters';
+import { pathToFileURL } from 'node:url';
+import { parseArgs } from 'node:util';
+import glob from 'fast-glob';
+
+const isCI = !!process.env.CI;
+const defaultTimeout = isCI ? 1400000 : 600000;
+
+export default async function test() {
+ const args = parseArgs({
+ allowPositionals: true,
+ options: {
+ // aka --test-name-pattern: https://nodejs.org/api/test.html#filtering-tests-by-name
+ match: { type: 'string', alias: 'm' },
+ // aka --test-only: https://nodejs.org/api/test.html#only-tests
+ only: { type: 'boolean', alias: 'o' },
+ // aka --test-concurrency: https://nodejs.org/api/test.html#test-runner-execution-model
+ parallel: { type: 'boolean', alias: 'p' },
+ // experimental: https://nodejs.org/api/test.html#watch-mode
+ watch: { type: 'boolean', alias: 'w' },
+ // Test timeout in milliseconds (default: 30000ms)
+ timeout: { type: 'string', alias: 't' },
+ // Test setup file
+ setup: { type: 'string', alias: 's' },
+ // Test teardown file
+ teardown: { type: 'string' },
+ },
+ });
+
+ const pattern = args.positionals[1];
+ if (!pattern) throw new Error('Missing test glob pattern');
+
+ const files = await glob(pattern, {
+ filesOnly: true,
+ absolute: true,
+ ignore: ['**/node_modules/**'],
+ });
+
+ // For some reason, the `only` option does not work and we need to explicitly set the CLI flag instead.
+ // Node.js requires opt-in to run .only tests :(
+ // https://nodejs.org/api/test.html#only-tests
+ if (args.values.only) {
+ process.env.NODE_OPTIONS ??= '';
+ process.env.NODE_OPTIONS += ' --test-only';
+ }
+
+ if (!args.values.parallel) {
+ // If not parallel, we create a temporary file that imports all the test files
+ // so that it all runs in a single process.
+ const tempTestFile = path.resolve('./node_modules/.astro/test.mjs');
+ await fs.mkdir(path.dirname(tempTestFile), { recursive: true });
+ await fs.writeFile(
+ tempTestFile,
+ files.map((f) => `import ${JSON.stringify(pathToFileURL(f).toString())};`).join('\n'),
+ );
+
+ files.length = 0;
+ files.push(tempTestFile);
+ }
+
+ const teardownModule = args.values.teardown
+ ? await import(pathToFileURL(path.resolve(args.values.teardown)).toString())
+ : undefined;
+
+ // https://nodejs.org/api/test.html#runoptions
+ run({
+ files,
+ testNamePatterns: args.values.match,
+ concurrency: args.values.parallel,
+ only: args.values.only,
+ setup: args.values.setup,
+ watch: args.values.watch,
+ timeout: args.values.timeout ? Number(args.values.timeout) : defaultTimeout, // Node.js defaults to Infinity, so set better fallback
+ })
+ .on('test:fail', () => {
+ // For some reason, a test fail using the JS API does not set an exit code of 1,
+ // so we set it here manually
+ process.exitCode = 1;
+ })
+ .on('end', () => {
+ const testPassed = process.exitCode === 0 || process.exitCode === undefined;
+ teardownModule?.default(testPassed);
+ })
+ .pipe(new spec())
+ .pipe(process.stdout);
+}