diff --git a/package.json b/package.json index 2ef035d..046fe4f 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "@babel/register": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "0.2.3", + "acorn": "^8.18.0", "ava": "^3.0.0", "babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-istanbul": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6dc8d1..a3f2c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@rolldown/plugin-babel': specifier: 0.2.3 version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(rolldown@1.2.0) + acorn: + specifier: ^8.18.0 + version: 8.18.0 ava: specifier: ^3.0.0 version: 3.15.0(supports-color@7.2.0) @@ -1330,8 +1333,8 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -5853,15 +5856,15 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk@8.3.5: dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} aggregate-error@3.1.0: dependencies: @@ -6006,7 +6009,7 @@ snapshots: ava@3.15.0(supports-color@7.2.0): dependencies: '@concordance/react': 2.0.0 - acorn: 8.17.0 + acorn: 8.18.0 acorn-walk: 8.3.5 ansi-styles: 5.2.0 arrgv: 1.0.2 @@ -6993,14 +6996,14 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esprima@4.0.1: {} @@ -8915,7 +8918,7 @@ snapshots: terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 diff --git a/test/acceptance/test-es5-conformance.js b/test/acceptance/test-es5-conformance.js new file mode 100644 index 0000000..a5266c3 --- /dev/null +++ b/test/acceptance/test-es5-conformance.js @@ -0,0 +1,50 @@ +import test from 'ava'; +import fs from 'fs'; +import path from 'path'; +import * as acorn from 'acorn'; + +/** + * `es5.js` and `bundled.js` exist to serve browsers that predate ES2015. If a + * single arrow function or template literal reaches either file, the whole + * script is a SyntaxError there and bowser is not merely degraded, it is dead. + * + * A grep for backticks is not enough. When the webpack build was replaced by + * tsdown, rolldown's `__commonJS` interop helper — appended *after* babel runs, + * and left alone by terser, which avoids introducing new syntax but does not + * transpile — shipped arrow functions into `bundled.js`: + * + * var t=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports) + * + * Parsing the emitted files at `ecmaVersion: 5` is the only check that covers + * the whole file, including helpers no source-level transform ever sees. + * + * These are build outputs — run `pnpm build` before `pnpm test`. + */ +const root = path.join(__dirname, '..', '..'); + +const legacyBundles = ['es5.js', 'bundled.js']; + +legacyBundles.forEach((file) => { + test(`${file} parses as ES5`, (t) => { + const source = fs.readFileSync(path.join(root, file), 'utf8'); + t.notThrows( + () => acorn.parse(source, { ecmaVersion: 5 }), + `${file} contains syntax newer than ES5 — it will throw on load in the ` + + 'browsers this bundle exists to support', + ); + }); + + test(`${file} contains no template literals`, (t) => { + // Backticks inside string literals are fine (core-js has a few). Only a + // real template-literal token is a problem, so tokenise rather than grep. + const source = fs.readFileSync(path.join(root, file), 'utf8'); + const templates = [...acorn.tokenizer(source, { ecmaVersion: 2020 })] + .filter((token) => token.type.label === '`' || token.type.label === 'template'); + t.is(templates.length, 0, `${file} contains a template literal`); + }); +}); + +test('bowser.mjs is a valid ES module', (t) => { + const source = fs.readFileSync(path.join(root, 'bowser.mjs'), 'utf8'); + t.notThrows(() => acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 60f85d3..f634ba2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'tsdown'; import babel from '@rolldown/plugin-babel'; import { minify } from 'terser'; +import { transformAsync } from '@babel/core'; const banner = `/*! * Bowser - a browser detector @@ -34,6 +35,51 @@ const legacyBabel = (useBuiltIns: false | 'entry') => babel({ }]], }); +/** + * Lowers the *emitted chunk* to ES5, after bundling and before terser. + * + * `legacyBabel()` above only transforms input modules. Rolldown appends its own + * runtime helpers afterwards — notably the `__commonJS` wrapper it injects for + * CommonJS dependencies — and emits them in modern syntax: + * + * var t=(t,e)=>()=>(e||(t((e={exports:{}}).exports,e),t=null),e.exports) + * + * terser's `ecma: 5` does not transpile; it only avoids *introducing* newer + * syntax. So those arrow functions survived into the published `bundled.js`, + * making the whole file a SyntaxError in the ES5 engines it exists to serve. + * `es5.js` has no CommonJS dependencies, so it never got a helper — which is + * why only `bundled.js` was affected, and why this has to run on the output + * rather than being folded into `legacyBabel()`. + * + * `useBuiltIns: false` here on purpose: `bundled.js` already has its polyfills + * inlined by the input pass, and re-expanding them would recurse. + */ +const lowerChunkToEs5 = () => ({ + name: 'bowser:babel-output', + async renderChunk(code: string, chunk: { fileName: string }) { + const result = await transformAsync(code, { + babelrc: false, + configFile: false, + // The emitted chunk is a UMD IIFE, i.e. a script, not a module. + sourceType: 'script', + // core-js is large and already ES5; skipping its size guard keeps babel + // from silently bailing out of compiling `bundled.js`. + compact: false, + generatorOpts: { comments: true }, + presets: [['@babel/preset-env', { + modules: false, + loose: true, + useBuiltIns: false, + targets: legacyTargets, + }]], + }); + if (typeof result?.code !== 'string') { + throw new Error(`babel produced no output for ${chunk.fileName}`); + } + return { code: result.code }; + }, +}); + /** * Minifies the UMD chunks with terser instead of rolldown's built-in (oxc) * minifier. @@ -78,7 +124,7 @@ const umd = (name: string, entry: string, useBuiltIns: false | 'entry') => ({ }, outDir: '.', platform: 'browser' as const, - plugins: [legacyBabel(useBuiltIns), terser()], + plugins: [legacyBabel(useBuiltIns), lowerChunkToEs5(), terser()], // webpack ran in `mode: 'production'`; minification happens in `terser()` // above, so rolldown's own minifier stays off. See its comment for why. minify: false,