iifeify 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  Turn an npm package into one classic browser script.
4
4
 
5
5
  ```bash
6
- npx -y iifeify vanilla-jsoneditor@3.13.0 --entry standalone.js --global-name VanillaJsonEditor
6
+ npx -y iifeify vanilla-jsoneditor@3.13.0
7
7
  ```
8
8
 
9
- This writes `vanilla-jsoneditor.iife.js`. Loaded with a plain `<script>` tag, the file defines the global `VanillaJsonEditor`, which holds everything the package exports. The file starts with a license banner generated from the package's own metadata:
9
+ This writes `vanilla-jsoneditor.iife.js`. Loaded with a plain `<script>` tag, the file defines the global `VanillaJsonEditor`, which holds everything the package exports. iifeify chose both by itself: the package's browser build `standalone.js`, which needs no other packages, and a global named after the package. The file starts with a license banner generated from the package's own metadata:
10
10
 
11
11
  ```js
12
12
  /*! vanilla-jsoneditor 3.13.0 - Copyright (c) 2020-2026 by Jos de Jong - ISC License - https://github.com/josdejong/svelte-jsoneditor */
@@ -21,6 +21,7 @@ iifeify is for hosts that accept only classic scripts and cannot `import` anythi
21
21
 
22
22
  - **Any npm package** — installed with npm itself, so versions, ranges, tags, scoped packages, private registries and proxies work as they do for `npm install`
23
23
  - **One file, one global** — the package and every dependency it imports, bundled with [esbuild](https://esbuild.github.io/) into an IIFE
24
+ - **No options needed** — the package's own browser build is used when that saves bundling its dependencies, and the global gets the name the package itself uses, holding what its documentation shows
24
25
  - **License banner** — one line per bundled package with its version, copyright notice, license and web address, read from its `package.json` and license file
25
26
  - **Full license texts** — `--notices` writes the license and notice files of all bundled packages next to the script
26
27
  - **Browser check** — a package that imports `fs`, `path` or another Node.js built-in is refused, with the modules and the files that import them
@@ -53,8 +54,8 @@ npx iifeify <package>[@version] [options]
53
54
 
54
55
  | Option | Meaning | Default |
55
56
  | --- | --- | --- |
56
- | `-e`, `--entry <path>` | File or subpath inside the package to bundle | The package's own browser entry |
57
- | `-g`, `--global-name <name>` | Global variable the script defines | The package name in PascalCase |
57
+ | `-e`, `--entry <path>` | File or subpath inside the package to bundle | Detected, see [Which file is bundled](#which-file-is-bundled) |
58
+ | `-g`, `--global-name <name>` | Global variable the script defines | Detected, see [The global](#the-global) |
58
59
  | `-o`, `--outfile <file>` | Where to write the script | `./<package>.iife.js` |
59
60
  | `--no-minify` | Keep the output readable | Minified |
60
61
  | `--target <list>` | Language level of the output, for example `es2020` | `esnext` |
@@ -65,42 +66,56 @@ npx iifeify <package>[@version] [options]
65
66
  While it runs, iifeify prints each step and how long it took on stderr, and the result on stdout:
66
67
 
67
68
  ```text
68
- Installing vanilla-jsoneditor@3.13.0 with npm... done in 3.4s
69
- Bundling vanilla-jsoneditor 3.13.0 with esbuild... done in 555ms
69
+ Installing vanilla-jsoneditor@3.13.0 with npm... done in 3.3s
70
+ Bundling vanilla-jsoneditor 3.13.0 with esbuild... done in 847ms
70
71
  Wrote vanilla-jsoneditor.iife.js (1.21 MB)
71
72
  package vanilla-jsoneditor 3.13.0 - ISC
72
- entry standalone.js
73
- global VanillaJsonEditor
73
+ entry standalone.js (browser build without dependencies)
74
+ global VanillaJsonEditor (from the package name)
74
75
  ```
75
76
 
76
- A step that fails ends with `failed`, followed by the reason. The exit code is 0 on success, 1 if the package cannot be converted and 2 for a wrong command line.
77
+ The summary says which file was bundled and where the global's name comes from. A step that fails ends with `failed`, followed by the reason. The exit code is 0 on success, 1 if the package cannot be converted and 2 for a wrong command line.
77
78
 
78
79
  ### How it works
79
80
 
80
81
  1. iifeify runs `npm install` for the package in a temporary folder. It uses your npm configuration, so private registries and proxies work. Install scripts are not run.
81
- 2. It bundles the package and the dependencies it imports into one file with esbuild, in the `iife` format with your global name.
82
+ 2. It chooses the file to bundle and the global's name, and bundles that file and the dependencies it imports into one file with esbuild, in the `iife` format.
82
83
  3. It reads `package.json` and the license file of every package that ended up in the bundle and writes the banner.
83
84
  4. It removes the temporary folder.
84
85
 
85
86
  ### Which file is bundled
86
87
 
87
- Without `--entry`, iifeify bundles what a bundler picks for an `import` of the package in browser code: the package's `exports` (conditions `browser`, `import`, `module`, `default`), or else its `browser`, `module` or `main` field. Dependencies are bundled from `node_modules`, in the versions that their version ranges resolve to on that day. The banner records the exact versions.
88
+ Without `--entry`, iifeify starts from what a bundler picks for an `import` of the package in browser code: the package's `exports` (conditions `browser`, `import`, `module`, `default`), or else its `browser`, `module` or `main` field.
89
+
90
+ When that import entry pulls in other packages, iifeify looks at the files the package names for browsers in `browser`, `unpkg` and `jsdelivr`, and takes the first that needs no other package and exports something, or is a classic script. Such a build is the one the package's authors tested, it gives the same output every time, and it is usually what the package's documentation describes for a `<script>` tag: vanilla-jsoneditor gets its `standalone.js`, Chart.js its `chart.umd.min.js` and Vue its `vue.global.js`. When the import entry cannot be bundled at all, those files are tried as well.
91
+
92
+ Otherwise iifeify bundles the import entry, together with its dependencies from `node_modules` in the versions their ranges resolve to on that day. The banner records the exact versions.
88
93
 
89
94
  With `--entry` you choose a file inside the package, or a subpath that the package exports:
90
95
 
91
96
  ```bash
92
- npx iifeify vanilla-jsoneditor --entry standalone.js
93
- npx iifeify rxjs --entry operators
97
+ npx -y iifeify vanilla-jsoneditor --entry index.js
98
+ npx -y iifeify rxjs --entry operators
94
99
  ```
95
100
 
96
- Some packages ship a ready-made browser file with all dependencies already inside, like `standalone.js` above. Bundling that file gives the same output every time. When a package names such a file in its `browser` field but `exports` points elsewhere, iifeify prints a hint with the `--entry` to use.
101
+ **Classic scripts are used as they are.** A UMD or IIFE build that defines its globals itself, such as `vue.global.js`, is not wrapped in another IIFE, which would hide its global. iifeify writes it with the banner on top, minified unless `--no-minify`. Its global keeps the name the script gives it, so `--global-name` cannot be used with it.
97
102
 
98
103
  ### The global
99
104
 
100
- - For an ES module package the global is an object with the package's exports. A default export is the property `default`.
101
- - For a CommonJS package the global is whatever the package assigns to `module.exports`.
102
- - If the package imports CSS, the styles are written next to the script, with `.css` in place of `.js`.
103
- - `process.env.NODE_ENV` is replaced by `"production"`.
105
+ Without `--global-name`, the global gets the name the package uses itself:
106
+
107
+ 1. the one its type declarations declare with `export as namespace`, such as `ApexCharts`;
108
+ 2. else the one its own UMD or IIFE build assigns, such as `Chart`, `dayjs` or `DOMPurify`;
109
+ 3. else a name made from the package name: `chart.js` gives `Chart`, and `vanilla-jsoneditor` gives `VanillaJsonEditor`, spelled as the package's export `JsonEditor`.
110
+
111
+ What the global holds:
112
+
113
+ - For an ES module with a default export, the default export, with the module's other exports as its properties. That is what a package's own browser build defines, so `DOMPurify.sanitize()` works as its documentation shows. A default export that cannot have properties, such as a number, leaves the global an object with all exports.
114
+ - For an ES module without a default export, an object with the module's exports.
115
+ - For a CommonJS package, whatever the package assigns to `module.exports`.
116
+ - A classic script defines its globals itself.
117
+
118
+ If the package imports CSS, the styles are written next to the script, with `.css` in place of `.js`. `process.env.NODE_ENV` is replaced by `"production"`.
104
119
 
105
120
  ### License information
106
121
 
@@ -172,7 +187,9 @@ Plain JavaScript modules, run by Node.js as they are: there is no build step, an
172
187
  | `bin/cli.js` | The command line, parsed with [cac](https://github.com/cacjs/cac), and the summary it prints |
173
188
  | `src/convert.js` | One conversion from start to end, including the temporary folder |
174
189
  | `src/install.js` | Reading the package spec and running `npm install` |
175
- | `src/bundle.js` | The esbuild call, and the explanation when a package cannot be bundled |
190
+ | `src/entry.js` | Which file to bundle: the import entry, or a browser build, and whether it is a classic script |
191
+ | `src/global.js` | The global's name: from the type declarations, the package's own build, or the package name |
192
+ | `src/bundle.js` | The esbuild calls that probe an entry and build the script, and the explanation when a package cannot be bundled |
176
193
  | `src/packages.js` | Which packages ended up in the bundle, and their metadata and license files |
177
194
  | `src/banner.js` | The banner and the notices file |
178
195
 
@@ -192,7 +209,7 @@ node bin/cli.js ms # the tool, run from the checkout
192
209
 
193
210
  ### Tests
194
211
 
195
- Both suites use Node.js's own test runner. `npm test` runs offline: it bundles small packages written into a temporary `node_modules`, and runs the CLI without installing anything. `npm run test:e2e` converts `ms`, `is-odd` and `mkdirp` from the npm registry.
212
+ Both suites use Node.js's own test runner. `npm test` runs offline: it bundles small packages written into a temporary `node_modules`, and runs the CLI on packages in local folders, which npm installs without the registry. `npm run test:e2e` converts `ms`, `is-odd`, `mkdirp`, `vanilla-jsoneditor`, `chart.js` and `dompurify` from the npm registry.
196
213
 
197
214
  ### CI & Releases
198
215
 
package/bin/cli.js CHANGED
@@ -15,14 +15,14 @@ const cli = cac('iifeify');
15
15
 
16
16
  cli.command('<package>', 'Turn an npm package into one classic browser script that defines a single global')
17
17
  .usage('<package>[@version] [options]')
18
- .option('-e, --entry <path>', 'File or subpath inside the package to bundle (default: its own browser entry)')
19
- .option('-g, --global-name <name>', 'Global variable the script defines (default: the package name in PascalCase)')
18
+ .option('-e, --entry <path>', 'File or subpath inside the package to bundle (default: detected)')
19
+ .option('-g, --global-name <name>', 'Global variable the script defines (default: detected)')
20
20
  .option('-o, --outfile <file>', 'Where to write the script (default: ./<package>.iife.js)')
21
21
  .option('--minify', 'Minify the output; --no-minify keeps it readable', { default: true })
22
22
  .option('--target <list>', 'Language level of the output, e.g. es2020 (default: esnext)')
23
23
  .option('--notices', 'Also write the full license texts of all bundled packages, e.g. to ms.iife.LICENSES.txt')
24
- .example(' $ iifeify ms')
25
- .example(' $ iifeify vanilla-jsoneditor@3.13.0 --entry standalone.js --global-name VanillaJsonEditor')
24
+ .example(' $ iifeify vanilla-jsoneditor@3.13.0')
25
+ .example(' $ iifeify chart.js --entry dist/chart.js --global-name ChartModule')
26
26
  .action(async (spec, options) => {
27
27
  const result = await convert({
28
28
  spec: String(spec),
@@ -70,7 +70,7 @@ function endStep(outcome) {
70
70
  }
71
71
 
72
72
  /** @param {Awaited<ReturnType<typeof convert>>} result */
73
- function report({ files, bytes, packages, entry, globalName, warnings, hint }) {
73
+ function report({ files, bytes, packages, entry, global, warnings }) {
74
74
  const [root, ...dependencies] = packages;
75
75
  // Relative to the current folder if the file is inside it, else in full.
76
76
  const shown = file => {
@@ -82,8 +82,9 @@ function report({ files, bytes, packages, entry, globalName, warnings, hint }) {
82
82
 
83
83
  console.log(`Wrote ${shown(files.js)} (${size})`);
84
84
  console.log(` package ${described(root)}`);
85
- console.log(` entry ${entry}`);
86
- console.log(` global ${globalName}`);
85
+ console.log(` entry ${entry.file} (${entry.reason})`);
86
+ const holds = global.defaultExport ? ', holds the default export' : '';
87
+ console.log(` global ${global.name ? `${global.name} (${global.reason}${holds})` : global.reason}`);
87
88
  if (dependencies.length > 0) {
88
89
  console.log(` bundled ${dependencies.length} ${dependencies.length === 1 ? 'dependency' : 'dependencies'}`);
89
90
  for (const dependency of dependencies) {
@@ -100,9 +101,6 @@ function report({ files, bytes, packages, entry, globalName, warnings, hint }) {
100
101
  for (const warning of warnings) {
101
102
  console.error(`\n${warning}`);
102
103
  }
103
- if (hint) {
104
- console.log(`\nHint: ${hint}`);
105
- }
106
104
  }
107
105
 
108
106
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iifeify",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Turn any browser-compatible npm package into a single classic script (an IIFE that defines one global), with a generated license banner.",
5
5
  "license": "MIT",
6
6
  "author": "Florian Noever",
@@ -45,7 +45,7 @@
45
45
  "lint": "eslint --max-warnings 0",
46
46
  "test": "node --test \"test/*.test.js\"",
47
47
  "test:e2e": "node --test \"test/e2e/*.test.js\"",
48
- "build:example": "node bin/cli.js vanilla-jsoneditor@3.13.0 --entry standalone.js --global-name VanillaJsonEditor --outfile examples/bc-json-viewer/vanilla-jsoneditor.iife.js"
48
+ "build:example": "node bin/cli.js vanilla-jsoneditor@3.13.0 --outfile examples/bc-json-viewer/vanilla-jsoneditor.iife.js"
49
49
  },
50
50
  "dependencies": {
51
51
  "cac": "^7.1.0",
package/src/bundle.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { isBuiltin } from 'node:module';
3
- import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { relative, resolve } from 'node:path';
4
4
  import * as esbuild from 'esbuild';
5
5
 
6
6
  import { ConversionError, UsageError } from './errors.js';
@@ -11,6 +11,30 @@ const OUT_JS = 'out.js';
11
11
  const MAX_ERRORS_SHOWN = 10;
12
12
  const MESSAGE_FORMAT = { color: false, terminalWidth: 100 };
13
13
 
14
+ // What the probes and the build have in common, so that both see the package alike.
15
+ const COMMON = /** @type {const} */ ({
16
+ bundle: true,
17
+ // Makes an import of a Node built-in such as "fs" an error.
18
+ platform: 'browser',
19
+ // esbuild sets this by itself only when minifying.
20
+ define: { 'process.env.NODE_ENV': '"production"' },
21
+ outfile: OUT_JS,
22
+ write: false,
23
+ metafile: true,
24
+ logLevel: 'silent',
25
+ });
26
+
27
+ /**
28
+ * What bundling an entry point as an ES module tells about it.
29
+ *
30
+ * @typedef {object} Probe
31
+ * @property {string} entryPoint as handed to esbuild: a file, or a package path
32
+ * @property {string} file the entry file, as an absolute path
33
+ * @property {'esm' | 'cjs' | undefined} format undefined for a file without import, export or require
34
+ * @property {string[]} exports the names the entry exports
35
+ * @property {string[]} inputs the files whose code is in the bundle, as absolute paths
36
+ */
37
+
14
38
  /**
15
39
  * Rejects a global name or target that esbuild will not accept. esbuild defines the rules for
16
40
  * both, so it is asked directly. This is quick, so it can run before anything is installed.
@@ -30,95 +54,141 @@ export async function checkOptions({ globalName, target }) {
30
54
  }
31
55
 
32
56
  /**
33
- * Bundles an installed package into one classic script that defines a global.
57
+ * Bundles an entry point as an ES module, only to learn what it is: which file, what kind of
58
+ * code, what it exports, and which files it takes in.
34
59
  *
35
60
  * @param {object} options
36
- * @param {string} options.workDir folder whose node_modules holds the package
37
- * @param {string} options.name the package's folder name inside node_modules
38
- * @param {string} options.dir the package's folder
39
- * @param {string} options.label the package in messages, such as "ms 2.1.3"
40
- * @param {string} [options.entry] file or subpath inside the package; default is its browser entry
41
- * @param {string} options.globalName
61
+ * @param {string} options.workDir folder whose node_modules holds the package
62
+ * @param {string} options.entryPoint a file, or a package path such as "ms" or "rxjs/operators"
63
+ * @param {string} options.label the package in messages, such as "ms 2.1.3"
64
+ * @param {string} [options.entry] what the user gave as --entry, for messages
65
+ * @returns {Promise<Probe>}
66
+ */
67
+ export async function probe({ workDir, entryPoint, label, entry }) {
68
+ const result = await build({ ...COMMON, absWorkingDir: workDir, entryPoints: [entryPoint], format: 'esm' }, label, entryPoint, entry);
69
+ const output = result.metafile.outputs[OUT_JS];
70
+ // A build that succeeds always has an entry point.
71
+ const entryFile = /** @type {string} */ (output.entryPoint);
72
+ return {
73
+ entryPoint,
74
+ file: resolve(workDir, entryFile),
75
+ format: result.metafile.inputs[entryFile]?.format,
76
+ exports: output.exports,
77
+ inputs: inputsOf(result.metafile, workDir),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Builds the script. A module is bundled into an IIFE that defines the global; a classic script
83
+ * defines its globals itself, so it is only minified, or copied as it is.
84
+ *
85
+ * @param {object} options
86
+ * @param {string} options.workDir
87
+ * @param {import('./entry.js').Plan} options.plan what to build, from chooseEntry
88
+ * @param {string} options.label
89
+ * @param {string} [options.entry]
90
+ * @param {string} [options.globalName] the global a module's IIFE defines
42
91
  * @param {boolean} options.minify
43
92
  * @param {string[]} [options.target]
44
- * @returns {Promise<{ js: string, css?: string, entry: string, inputs: string[], warnings: string[] }>}
45
- * the script, the stylesheet if the package imports CSS, the entry file, the files whose code
46
- * is in the output (both as absolute paths), and esbuild's warnings as text
93
+ * @returns {Promise<{ js: string, css?: string, entry: string, inputs: string[], warnings: string[],
94
+ * defaultExport: boolean }>} the script, the stylesheet if the package imports CSS, the entry
95
+ * file, the files whose code is in the output (both as absolute paths), esbuild's warnings as
96
+ * text, and whether the global holds the module's default export rather than all its exports
47
97
  */
48
- export async function bundle({ workDir, name, dir, label, entry, globalName, minify, target }) {
49
- const entryPoint = entryPointFor(name, dir, entry);
50
-
51
- let result;
52
- try {
53
- result = await esbuild.build({
54
- absWorkingDir: workDir,
55
- entryPoints: [entryPoint],
56
- outfile: OUT_JS,
57
- write: false,
58
- metafile: true,
59
- bundle: true,
60
- format: 'iife',
61
- globalName,
62
- // Makes an import of a Node built-in such as "fs" an error.
63
- platform: 'browser',
64
- minify,
65
- target,
66
- legalComments: 'eof',
67
- // esbuild sets this by itself only when minifying.
68
- define: { 'process.env.NODE_ENV': '"production"' },
69
- logLevel: 'silent',
70
- });
71
- } catch (error) {
72
- if (!Array.isArray(error.errors)) {
73
- throw error;
74
- }
75
- throw new ConversionError(await explainFailure(error.errors, label, entryPoint, entry));
98
+ export async function bundle({ workDir, plan, label, entry, globalName, minify, target }) {
99
+ if (plan.kind === 'script') {
100
+ return minifyScript({ workDir, file: plan.probe.file, label, minify, target });
76
101
  }
77
102
 
78
- const { outputs } = result.metafile;
79
- const inputs = new Set();
80
- for (const output of Object.values(outputs)) {
81
- for (const [file, { bytesInOutput }] of Object.entries(output.inputs)) {
82
- if (bytesInOutput > 0) {
83
- inputs.add(resolve(workDir, file));
84
- }
85
- }
86
- }
87
- const text = extension => result.outputFiles.find(file => file.path.endsWith(extension))?.text;
103
+ // Holding the default export is what a library's own browser build does, and what its
104
+ // documentation then shows: DOMPurify.sanitize() rather than DOMPurify.default.sanitize().
105
+ const defaultExport = plan.probe.format === 'esm' && plan.probe.exports.includes('default');
106
+ const { entryPoint } = plan.probe;
107
+ const result = await build({
108
+ ...COMMON,
109
+ absWorkingDir: workDir,
110
+ entryPoints: [entryPoint],
111
+ format: 'iife',
112
+ globalName,
113
+ minify,
114
+ target,
115
+ legalComments: 'eof',
116
+ footer: defaultExport ? { js: holdDefaultExport(/** @type {string} */ (globalName)) } : undefined,
117
+ }, label, entryPoint, entry);
88
118
 
89
- // A build that succeeds always has a script and an entry point.
119
+ const text = extension => result.outputFiles.find(file => file.path.endsWith(extension))?.text;
90
120
  return {
121
+ // A build that succeeds always has a script and an entry point.
91
122
  js: /** @type {string} */ (text('.js')),
92
123
  css: text('.css'),
93
- entry: resolve(workDir, /** @type {string} */ (outputs[OUT_JS].entryPoint)),
94
- // A module that the package's "browser" field switches off is listed under a made-up path.
95
- inputs: [...inputs].filter(existsSync),
124
+ entry: resolve(workDir, /** @type {string} */ (result.metafile.outputs[OUT_JS].entryPoint)),
125
+ inputs: inputsOf(result.metafile, workDir),
96
126
  warnings: await esbuild.formatMessages(result.warnings, { kind: 'warning', ...MESSAGE_FORMAT }),
127
+ defaultExport,
97
128
  };
98
129
  }
99
130
 
100
131
  /**
101
- * What to hand esbuild as entry point: a file inside the package, or a package path. esbuild
102
- * resolves a package path like an import in browser code: through "exports" with the conditions
103
- * browser, import, module and default, else through the fields browser, module and main.
132
+ * Code after the IIFE that makes the global the module's default export. The module's other
133
+ * exports become properties of it, unless it has a property of that name already. A default
134
+ * export that cannot have properties, such as a number, leaves the global as it was.
104
135
  */
105
- function entryPointFor(name, dir, entry) {
106
- if (!entry) {
107
- return name;
136
+ function holdDefaultExport(globalName) {
137
+ return `${globalName}=function(n){var d=n.default,k;if(d===null||typeof d!=="object"&&typeof d!=="function")return n;`
138
+ + `for(k in n)if(k!=="default"&&!(k in d))try{d[k]=n[k]}catch(e){}return d}(${globalName});`;
139
+ }
140
+
141
+ /**
142
+ * A classic script, minified without a format: esbuild then keeps the names at its top level,
143
+ * which are the globals it defines. Without --minify and --target it stays exactly as it is.
144
+ */
145
+ async function minifyScript({ workDir, file, label, minify, target }) {
146
+ const source = readFileSync(file, 'utf8');
147
+ const script = { css: undefined, entry: file, inputs: [file], defaultExport: false };
148
+ if (!minify && !target) {
149
+ return { ...script, js: source.endsWith('\n') ? source : `${source}\n`, warnings: [] };
108
150
  }
109
151
 
110
- const file = resolve(dir, entry);
111
- const fromPackage = relative(dir, file);
112
- if (fromPackage === '..' || fromPackage.startsWith(`..${sep}`) || isAbsolute(fromPackage)) {
113
- throw new UsageError(`--entry must name something inside the package, got: ${entry}`);
152
+ const sourcefile = relative(workDir, file).replaceAll('\\', '/');
153
+ try {
154
+ const result = await esbuild.transform(source, { minify, target, legalComments: 'eof', sourcefile, logLevel: 'silent' });
155
+ return { ...script, js: result.code, warnings: await esbuild.formatMessages(result.warnings, { kind: 'warning', ...MESSAGE_FORMAT }) };
156
+ } catch (error) {
157
+ if (!Array.isArray(error.errors)) {
158
+ throw error;
159
+ }
160
+ throw new ConversionError(await explainFailure(error.errors, label, sourcefile));
114
161
  }
115
- if (!fromPackage) {
116
- return name;
162
+ }
163
+
164
+ /**
165
+ * Runs esbuild, and explains a failure.
166
+ *
167
+ * @param {import('esbuild').BuildOptions & { metafile: true, write: false }} options
168
+ */
169
+ async function build(options, label, entryPoint, entry) {
170
+ try {
171
+ return await esbuild.build(options);
172
+ } catch (error) {
173
+ if (!Array.isArray(error.errors)) {
174
+ throw error;
175
+ }
176
+ throw new ConversionError(await explainFailure(error.errors, label, entryPoint, entry));
117
177
  }
118
- if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
119
- return file;
178
+ }
179
+
180
+ /** The files whose code ends up in the output, as absolute paths. */
181
+ function inputsOf(metafile, workDir) {
182
+ const inputs = new Set();
183
+ for (const output of Object.values(metafile.outputs)) {
184
+ for (const [file, { bytesInOutput }] of Object.entries(output.inputs)) {
185
+ if (bytesInOutput > 0) {
186
+ inputs.add(resolve(workDir, file));
187
+ }
188
+ }
120
189
  }
121
- return `${name}/${fromPackage.replaceAll('\\', '/')}`; // a subpath from the package's "exports"
190
+ // A module that the package's "browser" field switches off is listed under a made-up path.
191
+ return [...inputs].filter(existsSync);
122
192
  }
123
193
 
124
194
  /** Says in one sentence why the package cannot be bundled, followed by esbuild's own messages. */
package/src/convert.js CHANGED
@@ -1,9 +1,12 @@
1
- import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { basename, dirname, join, relative, resolve } from 'node:path';
4
4
 
5
5
  import { renderBanner, renderNotices } from './banner.js';
6
6
  import { bundle, checkOptions } from './bundle.js';
7
+ import { chooseEntry } from './entry.js';
8
+ import { UsageError } from './errors.js';
9
+ import { chooseGlobalName, ownGlobal } from './global.js';
7
10
  import { installPackage, parseSpec } from './install.js';
8
11
  import { bundledPackages } from './packages.js';
9
12
 
@@ -18,15 +21,18 @@ import { bundledPackages } from './packages.js';
18
21
  *
19
22
  * @param {object} options
20
23
  * @param {string} options.spec package to convert, such as "vanilla-jsoneditor@3.13.0"
21
- * @param {string} [options.entry] file or subpath inside the package; default is its browser entry
22
- * @param {string} [options.globalName] default is the package name in PascalCase
24
+ * @param {string} [options.entry] file or subpath inside the package; detected if not given
25
+ * @param {string} [options.globalName] detected if not given
23
26
  * @param {string} [options.outfile] default is <package name>.iife.js in the current folder
24
27
  * @param {boolean} [options.minify]
25
28
  * @param {string[]} [options.target] esbuild targets such as ["es2020"]
26
29
  * @param {boolean} [options.notices] also write the full license texts next to the script
27
30
  * @param {(step: Step) => void} [options.onStep] called as each step starts, to report progress
28
31
  * @returns {Promise<{ files: { js: string, css?: string, notices?: string }, bytes: number,
29
- * packages: object[], entry: string, globalName: string, warnings: string[], hint?: string }>}
32
+ * packages: object[], entry: { file: string, reason: string },
33
+ * global: { name?: string, reason: string, defaultExport: boolean }, warnings: string[] }>}
34
+ * the entry and the global come with the reason they were chosen; a classic script's global
35
+ * has no name when it cannot be read from the script
30
36
  */
31
37
  export async function convert({ spec, entry, globalName: requestedGlobalName, outfile, minify = true, target, notices = false, onStep }) {
32
38
  const wanted = parseSpec(spec);
@@ -38,11 +44,14 @@ export async function convert({ spec, entry, globalName: requestedGlobalName, ou
38
44
  try {
39
45
  onStep?.({ name: 'install', spec });
40
46
  const { dir, manifest } = await installPackage(wanted, workDir);
41
- const globalName = requestedGlobalName ?? defaultGlobalName(manifest.name);
42
47
 
43
48
  const label = `${manifest.name} ${manifest.version}`;
44
49
  onStep?.({ name: 'bundle', label });
45
- const built = await bundle({ workDir, name: wanted.name, dir, label, entry, globalName, minify, target });
50
+ const plan = await chooseEntry({ workDir, name: wanted.name, dir, manifest, label, entry });
51
+ const entryFile = relative(dir, plan.probe.file).replaceAll('\\', '/');
52
+ const global = await globalFor({ plan, entryFile, requestedGlobalName, packageName: manifest.name, dir, manifest });
53
+
54
+ const built = await bundle({ workDir, plan, label, entry, globalName: global.name, minify, target });
46
55
  const packages = bundledPackages(built.inputs, dir);
47
56
  const banner = renderBanner(packages);
48
57
 
@@ -65,10 +74,9 @@ export async function convert({ spec, entry, globalName: requestedGlobalName, ou
65
74
  files,
66
75
  bytes: Buffer.byteLength(script),
67
76
  packages,
68
- entry: relative(dir, built.entry).replaceAll('\\', '/'),
69
- globalName,
77
+ entry: { file: entryFile, reason: plan.kind === 'script' ? `${plan.reason}, a classic script already` : plan.reason },
78
+ global: { ...global, defaultExport: built.defaultExport },
70
79
  warnings: [...built.warnings.map(warning => warning.trim()), ...unlicensed],
71
- hint: entry ? undefined : prebuiltHint(manifest, dir, built.entry),
72
80
  };
73
81
  } finally {
74
82
  try {
@@ -80,35 +88,30 @@ export async function convert({ spec, entry, globalName: requestedGlobalName, ou
80
88
  }
81
89
  }
82
90
 
83
- /** "vanilla-jsoneditor" and "@scope/vanilla-jsoneditor" both give "VanillaJsoneditor". */
84
- export function defaultGlobalName(packageName) {
85
- const words = unscoped(packageName).split(/[^A-Za-z0-9]+/).filter(Boolean);
86
- const name = words.map(word => word[0].toUpperCase() + word.slice(1)).join('');
87
- return /^[A-Za-z]/.test(name) ? name : `_${name}`;
88
- }
89
-
90
91
  /** "@scope/vanilla-jsoneditor" gives "vanilla-jsoneditor.iife.js". */
91
92
  export function defaultOutfile(packageName) {
92
- return `${unscoped(packageName)}.iife.js`;
93
- }
94
-
95
- function unscoped(packageName) {
96
- return packageName.slice(packageName.indexOf('/') + 1);
93
+ return `${packageName.slice(packageName.indexOf('/') + 1)}.iife.js`;
97
94
  }
98
95
 
99
96
  /**
100
- * Some packages point "exports" at a file that needs its dependencies bundled in, and "browser" at
101
- * a ready-made file that contains them. Bundlers follow "exports", so the ready-made file takes --entry.
97
+ * The global's name, and where it comes from. A classic script defines its globals itself, so
98
+ * there is nothing to name; the summary shows the one it defines, if that can be read from it.
99
+ *
100
+ * @returns {Promise<{ name?: string, reason: string }>}
102
101
  */
103
- function prebuiltHint(manifest, dir, entryFile) {
104
- if (typeof manifest.browser !== 'string') {
105
- return undefined;
102
+ async function globalFor({ plan, entryFile, requestedGlobalName, packageName, dir, manifest }) {
103
+ if (plan.kind === 'script') {
104
+ const own = ownGlobal(plan.probe.file);
105
+ if (requestedGlobalName) {
106
+ throw new UsageError(
107
+ `${entryFile} is a classic script that defines its own global${own ? `, ${own}` : ''}, so --global-name cannot rename it. `
108
+ + 'Leave out --global-name, or choose another file with --entry.'
109
+ );
110
+ }
111
+ return { name: own, reason: 'set by the script itself' };
106
112
  }
107
-
108
- const prebuilt = resolve(dir, manifest.browser);
109
- if (prebuilt === entryFile || !existsSync(prebuilt)) {
110
- return undefined;
113
+ if (requestedGlobalName) {
114
+ return { name: requestedGlobalName, reason: 'given with --global-name' };
111
115
  }
112
- const entry = relative(dir, prebuilt).replaceAll('\\', '/');
113
- return `${manifest.name} also ships a prebuilt browser file. To bundle that instead, add: --entry ${entry}`;
116
+ return chooseGlobalName({ packageName, dir, manifest, plan });
114
117
  }
package/src/entry.js ADDED
@@ -0,0 +1,128 @@
1
+ import { statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ import { probe } from './bundle.js';
5
+ import { UsageError } from './errors.js';
6
+ import { ownGlobal } from './global.js';
7
+ import { packageDirOf } from './packages.js';
8
+
9
+ /**
10
+ * What to build, and why.
11
+ *
12
+ * @typedef {object} Plan
13
+ * @property {import('./bundle.js').Probe} probe
14
+ * @property {'module' | 'script'} kind a module is bundled into an IIFE. A classic script defines
15
+ * its globals itself; wrapping it would hide them inside the IIFE, so it is used as it is.
16
+ * @property {string} reason why this entry, for the summary
17
+ */
18
+
19
+ /**
20
+ * Chooses the entry point: the one the user named, else the package's import entry. When that
21
+ * needs other packages and the package names a browser build that does not, the browser build:
22
+ * it is the file the package's authors tested, and the one its documentation describes.
23
+ *
24
+ * @param {object} options
25
+ * @param {string} options.workDir
26
+ * @param {string} options.name the package's folder name inside node_modules
27
+ * @param {string} options.dir the package's folder
28
+ * @param {object} options.manifest its package.json
29
+ * @param {string} options.label the package in messages, such as "ms 2.1.3"
30
+ * @param {string} [options.entry] file or subpath inside the package, given with --entry
31
+ * @returns {Promise<Plan>}
32
+ */
33
+ export async function chooseEntry({ workDir, name, dir, manifest, label, entry }) {
34
+ if (entry) {
35
+ return plan(await probe({ workDir, entryPoint: entryPointFor(name, dir, entry), label, entry }), 'given with --entry');
36
+ }
37
+
38
+ // esbuild resolves the package name like an import in browser code: through "exports" with
39
+ // the conditions browser, import, module and default, else through browser, module and main.
40
+ let imported;
41
+ let failure;
42
+ try {
43
+ imported = await probe({ workDir, entryPoint: name, label });
44
+ } catch (error) {
45
+ failure = error;
46
+ }
47
+ if (imported && usesOnlyItself(imported, dir)) {
48
+ return plan(imported, 'the import entry');
49
+ }
50
+
51
+ for (const file of browserBuilds(dir, manifest)) {
52
+ if (file === imported?.file) {
53
+ continue;
54
+ }
55
+ let build;
56
+ try {
57
+ build = await probe({ workDir, entryPoint: file, label });
58
+ } catch {
59
+ continue; // a browser build that cannot be bundled either is no better choice
60
+ }
61
+ const candidate = plan(build, 'browser build without dependencies');
62
+ // A module needs exports, or its global would be empty; a script defines its own.
63
+ if (usesOnlyItself(build, dir) && (candidate.kind === 'script' || build.exports.length > 0)) {
64
+ return candidate;
65
+ }
66
+ }
67
+
68
+ if (imported) {
69
+ return plan(imported, 'the import entry');
70
+ }
71
+ throw failure;
72
+ }
73
+
74
+ /**
75
+ * The files a package names for use in a browser: "browser", and "unpkg" and "jsdelivr", the
76
+ * files those CDNs serve for a plain <script> tag.
77
+ */
78
+ export function browserBuilds(dir, manifest) {
79
+ const files = [manifest.browser, manifest.unpkg, manifest.jsdelivr]
80
+ .filter(field => typeof field === 'string')
81
+ .map(field => resolve(dir, field))
82
+ .filter(file => isInside(dir, file) && statSync(file, { throwIfNoEntry: false })?.isFile());
83
+ return [...new Set(files)];
84
+ }
85
+
86
+ function plan(found, reason) {
87
+ return { probe: found, kind: isClassicScript(found) ? /** @type {const} */ ('script') : /** @type {const} */ ('module'), reason };
88
+ }
89
+
90
+ /**
91
+ * A file that defines its globals itself, as a UMD or IIFE build does: one without import, export
92
+ * or require, or one that is an ES module only because of its package's "type", imports and
93
+ * exports nothing, and assigns a global.
94
+ *
95
+ * @param {import('./bundle.js').Probe} found
96
+ */
97
+ function isClassicScript({ file, format, exports, inputs }) {
98
+ if (!/\.c?js$/i.test(file) || inputs.length > 1) {
99
+ return false;
100
+ }
101
+ return format === undefined || (format === 'esm' && exports.length === 0 && ownGlobal(file) !== undefined);
102
+ }
103
+
104
+ /** Whether every file in the bundle belongs to the package itself. */
105
+ function usesOnlyItself({ inputs }, dir) {
106
+ return inputs.every(file => resolve(packageDirOf(file) ?? file) === resolve(dir));
107
+ }
108
+
109
+ function isInside(dir, file) {
110
+ const fromDir = relative(dir, file);
111
+ return fromDir !== '' && fromDir !== '..' && !fromDir.startsWith(`..${sep}`) && !isAbsolute(fromDir);
112
+ }
113
+
114
+ /** What to hand esbuild for --entry: a file inside the package, or a subpath of its "exports". */
115
+ function entryPointFor(name, dir, entry) {
116
+ const file = resolve(dir, entry);
117
+ const fromPackage = relative(dir, file);
118
+ if (fromPackage === '..' || fromPackage.startsWith(`..${sep}`) || isAbsolute(fromPackage)) {
119
+ throw new UsageError(`--entry must name something inside the package, got: ${entry}`);
120
+ }
121
+ if (!fromPackage) {
122
+ return name;
123
+ }
124
+ if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
125
+ return file;
126
+ }
127
+ return `${name}/${fromPackage.replaceAll('\\', '/')}`; // a subpath from the package's "exports"
128
+ }
package/src/global.js ADDED
@@ -0,0 +1,165 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ import { checkOptions } from './bundle.js';
5
+
6
+ const ID = '[A-Za-z_$][\\w$]*';
7
+
8
+ // How UMD builds assign their global, as rollup, webpack and browserify write it, minified or not:
9
+ // root["Name"] = factory(), global.Name = factory(), (t = ... || self).Name = e(),
10
+ // global.Name = global.Name || {} and (e = e || self).Name = {}.
11
+ const UMD_ASSIGNMENTS = [
12
+ new RegExp(`\\[\\s*["'](${ID})["']\\s*\\]\\s*=\\s*${ID}\\s*\\(`, 'g'),
13
+ new RegExp(`\\.\\s*(${ID})\\s*=\\s*${ID}\\s*\\(`, 'g'),
14
+ new RegExp(`\\.\\s*(${ID})\\s*=\\s*(?:${ID}\\.\\1\\s*\\|\\|\\s*)?\\{\\s*\\}`, 'g'),
15
+ ];
16
+ // A UMD header ends where the factory is handed over: }(this, function ...) and the like.
17
+ const UMD_HEADER_END = /\}\s*\)?\s*\(\s*(?:this|self|window|globalThis|"undefined"|typeof)/;
18
+ // An IIFE build starts with its global: var Name = (function (exports) {, var Name = (() => {,
19
+ // var Name=function(e){. The same line further down is only a variable of some module.
20
+ const IIFE = new RegExp(`^var\\s+(${ID})\\s*=\\s*(?:\\(\\s*)?(?:function\\b|\\(\\s*\\)\\s*=>|async\\b)`);
21
+ const LEADING_COMMENTS = /^(?:\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*\n))*\s*(?:["']use strict["'];?\s*)?/;
22
+
23
+ // Names a UMD header assigns that are not the library's global.
24
+ const NOT_A_GLOBAL = new Set(['exports', 'module', 'amd', 'define', 'require', 'window', 'self', 'globalThis', 'global', 'document']);
25
+
26
+ /**
27
+ * Chooses the global's name when --global-name is not given, from what the package says itself:
28
+ * the global its type declarations declare, the one its own browser build assigns, or else a
29
+ * name made from the package name.
30
+ *
31
+ * @param {object} options
32
+ * @param {string} options.packageName
33
+ * @param {string} options.dir the package's folder
34
+ * @param {object} options.manifest its package.json
35
+ * @param {import('./entry.js').Plan} options.plan
36
+ * @returns {Promise<{ name: string, reason: string }>}
37
+ */
38
+ export async function chooseGlobalName({ packageName, dir, manifest, plan }) {
39
+ const candidates = [];
40
+ const declared = typesNamespace(dir, manifest);
41
+ if (declared) {
42
+ candidates.push({ name: declared, reason: 'from the type declarations' });
43
+ }
44
+ for (const file of [plan.probe.file, ...packageFiles(dir, manifest)]) {
45
+ const name = ownGlobal(file);
46
+ if (name) {
47
+ candidates.push({ name, reason: `from ${relative(dir, file).replaceAll('\\', '/')}` });
48
+ }
49
+ }
50
+
51
+ for (const candidate of candidates) {
52
+ if (await isUsable(candidate.name)) {
53
+ return candidate;
54
+ }
55
+ }
56
+ return { name: guessGlobalName(packageName, plan.probe.exports), reason: 'from the package name' };
57
+ }
58
+
59
+ /**
60
+ * The global a UMD or IIFE build assigns, read from its first lines, such as "Chart" from
61
+ * Chart.js's chart.umd.min.js. Undefined for any other file.
62
+ */
63
+ export function ownGlobal(file) {
64
+ let text;
65
+ try {
66
+ text = readFileSync(file, 'utf8').slice(0, 20000).replace(LEADING_COMMENTS, '');
67
+ } catch {
68
+ return undefined;
69
+ }
70
+
71
+ // A UMD header always checks for an AMD loader; a compiled CommonJS module does not.
72
+ const start = text.slice(0, 2500);
73
+ if (/define\.amd/.test(start)) {
74
+ const end = start.search(UMD_HEADER_END);
75
+ const header = end === -1 ? start : start.slice(0, end);
76
+ // The header assigns module.exports first and the global last.
77
+ const assigned = UMD_ASSIGNMENTS.flatMap(pattern => [...header.matchAll(pattern)])
78
+ .sort((a, b) => /** @type {number} */ (a.index) - /** @type {number} */ (b.index))
79
+ .map(match => match[1])
80
+ .filter(name => !NOT_A_GLOBAL.has(name));
81
+ if (assigned.length > 0) {
82
+ return assigned.at(-1);
83
+ }
84
+ }
85
+ const iife = IIFE.exec(text.slice(0, 3000))?.[1];
86
+ return iife && !NOT_A_GLOBAL.has(iife) ? iife : undefined;
87
+ }
88
+
89
+ /** The global a package's type declarations declare with "export as namespace Name;". */
90
+ export function typesNamespace(dir, manifest) {
91
+ for (const field of [manifest.types, manifest.typings, 'index.d.ts']) {
92
+ const file = typeof field === 'string' ? resolve(dir, field) : undefined;
93
+ if (file && isInside(dir, file) && statSync(file, { throwIfNoEntry: false })?.isFile()) {
94
+ return /^\s*export\s+as\s+namespace\s+([A-Za-z_$][\w$]*)\s*;/m.exec(readFileSync(file, 'utf8'))?.[1];
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * A global name made from the package name, for a package that names none itself:
102
+ * "vanilla-jsoneditor" gives "VanillaJsonEditor" when the package exports JsonEditor, "chart.js"
103
+ * gives "Chart", and "@scope/my-lib" gives "ScopeMyLib".
104
+ *
105
+ * @param {string} packageName
106
+ * @param {string[]} [exportNames] the names the package exports, whose spelling is taken over
107
+ */
108
+ export function guessGlobalName(packageName, exportNames = []) {
109
+ const [scope, bare] = packageName.startsWith('@') ? packageName.slice(1).split('/') : [undefined, packageName];
110
+ // "chart.js" and "highlight.js" are named after their language, "htmx.org" after its site.
111
+ const words = [scope, bare.replace(/[.-]js$/i, '').replace(/\.(?:org|io|com|net|dev)$/i, '')]
112
+ .filter(part => part !== undefined)
113
+ .flatMap(part => part.split(/[^A-Za-z0-9]+/))
114
+ .filter(Boolean);
115
+
116
+ // A word that an export spells out, such as "jsoneditor" for JsonEditor or "sortablejs" for
117
+ // Sortable, takes that spelling.
118
+ const spellings = new Map();
119
+ for (const name of exportNames) {
120
+ for (const key of [name.toLowerCase(), `${name.toLowerCase()}js`]) {
121
+ spellings.set(key, [...(spellings.get(key) ?? []), name]);
122
+ }
123
+ }
124
+ const name = words.map(word => spell(word, spellings.get(word.toLowerCase()))).join('');
125
+ return /^[A-Za-z]/.test(name) ? name : `_${name}`;
126
+ }
127
+
128
+ /**
129
+ * Of several spellings, JsonEditor rather than JSONEditor: the one with the fewest capitals,
130
+ * but at least one. Without any spelling, the word with a capital first letter.
131
+ */
132
+ function spell(word, spellings) {
133
+ if (!spellings) {
134
+ return word[0].toUpperCase() + word.slice(1);
135
+ }
136
+ const capitals = name => name.replace(/[^A-Z]/g, '').length;
137
+ const cased = spellings.filter(name => capitals(name) > 0).sort((a, b) => capitals(a) - capitals(b));
138
+ return cased[0] ?? spellings[0];
139
+ }
140
+
141
+ /** The files a package's own browser build can be in. */
142
+ function packageFiles(dir, manifest) {
143
+ const files = [manifest.unpkg, manifest.jsdelivr, manifest.browser, manifest.main]
144
+ .filter(field => typeof field === 'string')
145
+ .map(field => resolve(dir, field))
146
+ .filter(file => isInside(dir, file) && statSync(file, { throwIfNoEntry: false })?.isFile());
147
+ return [...new Set(files)];
148
+ }
149
+
150
+ async function isUsable(name) {
151
+ if (NOT_A_GLOBAL.has(name)) {
152
+ return false;
153
+ }
154
+ try {
155
+ await checkOptions({ globalName: name });
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function isInside(dir, file) {
163
+ const fromDir = relative(dir, file);
164
+ return fromDir !== '' && fromDir !== '..' && !fromDir.startsWith(`..${sep}`) && !isAbsolute(fromDir);
165
+ }