iifeify 0.0.0-stage → 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Florian Noever
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,203 @@
1
- # Temporary Holding Version
1
+ # iifeify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Turn an npm package into one classic browser script.
4
+
5
+ ```bash
6
+ npx iifeify vanilla-jsoneditor@3.13.0 --entry standalone.js --global-name VanillaJsonEditor
7
+ ```
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:
10
+
11
+ ```js
12
+ /*! vanilla-jsoneditor 3.13.0 - Copyright (c) 2020-2026 by Jos de Jong - ISC License - https://github.com/josdejong/svelte-jsoneditor */
13
+ var VanillaJsonEditor=(()=>{ ... })();
14
+ ```
15
+
16
+ iifeify is for hosts that accept only classic scripts and cannot `import` anything, such as control add-ins in Microsoft Dynamics 365 Business Central.
17
+
18
+ ---
19
+
20
+ ## ✨ Features
21
+
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
+ - **One file, one global** — the package and every dependency it imports, bundled with [esbuild](https://esbuild.github.io/) into an IIFE
24
+ - **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
+ - **Full license texts** — `--notices` writes the license and notice files of all bundled packages next to the script
26
+ - **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
27
+
28
+ ---
29
+
30
+ ## 🚀 Installation
31
+
32
+ Run it directly with `npx`, no installation required:
33
+
34
+ ```bash
35
+ npx iifeify <package>
36
+ ```
37
+
38
+ Or install it globally:
39
+
40
+ ```bash
41
+ npm install -g iifeify
42
+ ```
43
+
44
+ ---
45
+
46
+ ## 🧰 Usage
47
+
48
+ ```bash
49
+ npx iifeify <package>[@version] [options]
50
+ ```
51
+
52
+ `<package>` is a package name as you would give it to `npm install`: `ms`, `ms@2.1.3`, `ms@^2`, `@codemirror/state`.
53
+
54
+ | Option | Meaning | Default |
55
+ | --- | --- | --- |
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 |
58
+ | `-o`, `--outfile <file>` | Where to write the script | `./<package>.iife.js` |
59
+ | `--no-minify` | Keep the output readable | Minified |
60
+ | `--target <list>` | Language level of the output, for example `es2020` | `esnext` |
61
+ | `--notices` | Also write the full license texts next to the script, for example `ms.iife.LICENSES.txt` | Off |
62
+ | `-h`, `--help` | Show the help | |
63
+ | `-v`, `--version` | Show the version | |
64
+
65
+ The exit code is 0 on success, 1 if the package cannot be converted and 2 for a wrong command line.
66
+
67
+ ### How it works
68
+
69
+ 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.
70
+ 2. It bundles the package and the dependencies it imports into one file with esbuild, in the `iife` format with your global name.
71
+ 3. It reads `package.json` and the license file of every package that ended up in the bundle and writes the banner.
72
+ 4. It removes the temporary folder.
73
+
74
+ ### Which file is bundled
75
+
76
+ 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.
77
+
78
+ With `--entry` you choose a file inside the package, or a subpath that the package exports:
79
+
80
+ ```bash
81
+ npx iifeify vanilla-jsoneditor --entry standalone.js
82
+ npx iifeify rxjs --entry operators
83
+ ```
84
+
85
+ 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.
86
+
87
+ ### The global
88
+
89
+ - For an ES module package the global is an object with the package's exports. A default export is the property `default`.
90
+ - For a CommonJS package the global is whatever the package assigns to `module.exports`.
91
+ - If the package imports CSS, the styles are written next to the script, with `.css` in place of `.js`.
92
+ - `process.env.NODE_ENV` is replaced by `"production"`.
93
+
94
+ ### License information
95
+
96
+ The banner has one line for each package whose code is in the bundle: name, version, copyright notice, license and web address. With bundled dependencies it looks like this:
97
+
98
+ ```js
99
+ /*!
100
+ * is-odd 3.0.1 - Copyright (c) 2015-present, Jon Schlinkert. - MIT License - https://github.com/jonschlinkert/is-odd
101
+ *
102
+ * Bundled dependencies:
103
+ * is-number 6.0.0 - Copyright (c) 2014-2018, Jon Schlinkert. - MIT License - https://github.com/jonschlinkert/is-number
104
+ */
105
+ ```
106
+
107
+ - The license comes from the `license` field of `package.json`.
108
+ - The copyright notice is the first one in the package's license file if the license is MIT, ISC or BSD. Otherwise it is the `author` from `package.json`, and it is left out if there is none.
109
+ - The address is the `homepage`, else the repository, else the package's npm page.
110
+ - License comments in the source files (`/*! ... */`, `@license`, `@preserve`) are kept at the end of the script.
111
+ - `--notices` writes the complete license and notice files of all bundled packages to one text file next to the script. Use it when you have to ship the full license texts with your product.
112
+
113
+ ### Packages that cannot be converted
114
+
115
+ iifeify fails, and writes nothing, when the package cannot be bundled for a browser:
116
+
117
+ - It imports a Node.js built-in module such as `fs` or `path`. The message names the modules and the files that import them.
118
+ - It has no entry point that browser code can import, for example a command-line tool or a package whose `exports` only allow `require`. Name a file with `--entry` if you know one that works.
119
+ - It imports a file type other than JavaScript, JSON or CSS, such as an image, a font or WebAssembly.
120
+ - It uses top-level `await`, which a classic script cannot do.
121
+
122
+ A package passes this check and can still fail in the browser if it uses Node.js globals such as `process.argv` or `Buffer` without importing anything. iifeify does not look for that.
123
+
124
+ ---
125
+
126
+ ## 🧠 Requirements
127
+
128
+ - **Node.js** ≥ 22, with npm
129
+
130
+ ---
131
+
132
+ ## 🏗 Example
133
+
134
+ A Business Central control add-in loads classic scripts only, so a library such as [vanilla-jsoneditor](https://github.com/josdejong/svelte-jsoneditor) has to be one file that defines a global. The command at the top of this page generates that file. In the `controladdin` object, list it under `Scripts`:
135
+
136
+ ```al
137
+ Scripts = 'vanilla-jsoneditor.iife.js';
138
+ StartupScript = 'Startup.js';
139
+ ```
140
+
141
+ The startup script then finds the library in the global `VanillaJsonEditor` and can call, for example, `VanillaJsonEditor.createJSONEditor(...)`.
142
+
143
+ ---
144
+
145
+ ## 🧩 Repository
146
+
147
+ GitHub: [Florian-Noever/iifeify](https://github.com/Florian-Noever/iifeify)
148
+
149
+ Bug reports and feature requests are welcome via [Issues](https://github.com/Florian-Noever/iifeify/issues).
150
+
151
+ ---
152
+
153
+ ## 🛠️ Developer Notes
154
+
155
+ ### Architecture
156
+
157
+ Plain JavaScript modules, run by Node.js as they are: there is no build step, and the package ships `bin/` and `src/` unchanged.
158
+
159
+ | Module | Purpose |
160
+ | --- | --- |
161
+ | `bin/cli.js` | The command line, parsed with [cac](https://github.com/cacjs/cac), and the summary it prints |
162
+ | `src/convert.js` | One conversion from start to end, including the temporary folder |
163
+ | `src/install.js` | Reading the package spec and running `npm install` |
164
+ | `src/bundle.js` | The esbuild call, and the explanation when a package cannot be bundled |
165
+ | `src/packages.js` | Which packages ended up in the bundle, and their metadata and license files |
166
+ | `src/banner.js` | The banner and the notices file |
167
+
168
+ ### Build commands
169
+
170
+ ```bash
171
+ npm ci # install exactly what package-lock.json pins
172
+
173
+ npm run check-types # tsc over the JavaScript and its JSDoc types
174
+ npm run lint # eslint, with no warning allowed
175
+ npm test # the offline tests
176
+ npm run test:e2e # tests that install real packages from the npm registry
177
+ node bin/cli.js ms # the tool, run from the checkout
178
+ ```
179
+
180
+ **Install scripts are opt-in.** npm 11 runs a dependency's install script only when `allowScripts` in `package.json` approves it. esbuild's is approved. On Windows, the import rules of the linter need the Microsoft Visual C++ Redistributable, which most machines already have.
181
+
182
+ ### Tests
183
+
184
+ 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.
185
+
186
+ ### CI & Releases
187
+
188
+ CI runs on every push and pull request. One job type-checks and lints, one runs both test suites on Linux with Node.js 22, 24 and 26 and on Windows with Node.js 24, and a third packs a preview tarball, kept for 14 days. Dependabot's minor and patch updates are merged once all three passed. `ci.yml` and `publish.yml` only call the shared npm package workflows of [Florian-Noever/Florian-Noever](https://github.com/Florian-Noever/Florian-Noever/blob/main/.github/CI.md), which hold the jobs.
189
+
190
+ To release, bump the version with `npm version x.y.z --no-git-tag-version` and publish a GitHub release `vx.y.z` from a commit whose CI is green. The publish workflow checks, tests and packs the tag, attaches the tarball to the release with a build attestation, and publishes the same tarball to npm. A pre-release only gets the tarball on the GitHub release.
191
+
192
+ npm publishes through a trusted publisher instead of a token, and adds provenance. npm only offers trusted publishers for a package that exists, so publish the first version from your machine with `npm publish`. Shortly before the next release, add one in the package's settings on [npmjs.com](https://www.npmjs.com): GitHub Actions, user `Florian-Noever`, repository `iifeify`, workflow `publish.yml`, environment `npm`. Allow `npm publish` for it as well, since a new trusted publisher only allows staged publishing otherwise. It expires unless it publishes within 48 hours. npm checks the name of this repository's `publish.yml`, not of the shared workflow it calls; see [Trusted publishing](https://github.com/Florian-Noever/Florian-Noever/blob/main/.github/CI.md#trusted-publishing) in the shared CI docs.
193
+
194
+ ---
195
+
196
+ ## 📜 License
197
+
198
+ Licensed under the [MIT](LICENSE) License.
199
+
200
+ <br>
201
+ <br>
202
+
203
+ [!["Buy me a coffee"](https://raw.githubusercontent.com/Florian-Noever/Florian-Noever/refs/heads/main/_meta/BuyMeACoffee/Buttons%20%26%20Icons/orange-button-x180.png)](https://www.buymeacoffee.com/florian_noever)
package/bin/cli.js ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { isAbsolute, relative } from 'node:path';
4
+ import { cac } from 'cac';
5
+
6
+ import { convert } from '../src/convert.js';
7
+ import { ConversionError, UsageError } from '../src/errors.js';
8
+
9
+ const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
10
+
11
+ // cac turns a value that looks like a number into one, such as the file name in "--outfile 2024".
12
+ const text = value => (value === undefined ? undefined : String(value));
13
+
14
+ const cli = cac('iifeify');
15
+
16
+ cli.command('<package>', 'Turn an npm package into one classic browser script that defines a single global')
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)')
20
+ .option('-o, --outfile <file>', 'Where to write the script (default: ./<package>.iife.js)')
21
+ .option('--minify', 'Minify the output; --no-minify keeps it readable', { default: true })
22
+ .option('--target <list>', 'Language level of the output, e.g. es2020 (default: esnext)')
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')
26
+ .action(async (spec, options) => {
27
+ report(await convert({
28
+ spec: String(spec),
29
+ entry: text(options.entry),
30
+ globalName: text(options.globalName),
31
+ outfile: text(options.outfile),
32
+ minify: options.minify,
33
+ target: text(options.target)?.split(','),
34
+ notices: options.notices,
35
+ }));
36
+ });
37
+ cli.help();
38
+ cli.version(version);
39
+
40
+ /** @param {Awaited<ReturnType<typeof convert>>} result */
41
+ function report({ files, bytes, packages, entry, globalName, warnings, hint }) {
42
+ const [root, ...dependencies] = packages;
43
+ // Relative to the current folder if the file is inside it, else in full.
44
+ const shown = file => {
45
+ const fromHere = relative(process.cwd(), file);
46
+ return fromHere.startsWith('..') || isAbsolute(fromHere) ? file : fromHere;
47
+ };
48
+ const described = pkg => `${pkg.name} ${pkg.version} - ${pkg.license ?? 'license unknown'}`;
49
+ const size = bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
50
+
51
+ console.log(`Wrote ${shown(files.js)} (${size})`);
52
+ console.log(` package ${described(root)}`);
53
+ console.log(` entry ${entry}`);
54
+ console.log(` global ${globalName}`);
55
+ if (dependencies.length > 0) {
56
+ console.log(` bundled ${dependencies.length} ${dependencies.length === 1 ? 'dependency' : 'dependencies'}`);
57
+ for (const dependency of dependencies) {
58
+ console.log(` ${described(dependency)}`);
59
+ }
60
+ }
61
+ if (files.css) {
62
+ console.log(` styles ${shown(files.css)}`);
63
+ }
64
+ if (files.notices) {
65
+ console.log(` notices ${shown(files.notices)}`);
66
+ }
67
+
68
+ for (const warning of warnings) {
69
+ console.error(`\n${warning}`);
70
+ }
71
+ if (hint) {
72
+ console.log(`\nHint: ${hint}`);
73
+ }
74
+ }
75
+
76
+ try {
77
+ // Parsing prints the help or the version if asked to. Running is a separate step, so that
78
+ // a failure inside the conversion can be awaited and caught here.
79
+ cli.parse(process.argv, { run: false });
80
+ await cli.runMatchedCommand();
81
+ } catch (error) {
82
+ // cac does not export the class of the errors it throws for a wrong command line.
83
+ if (error instanceof UsageError || error.name === 'CACError') {
84
+ console.error(`${error.message}\nRun "iifeify --help" for the options.`);
85
+ process.exitCode = 2;
86
+ } else if (error instanceof ConversionError) {
87
+ console.error(error.message);
88
+ process.exitCode = 1;
89
+ } else {
90
+ console.error(error);
91
+ process.exitCode = 1;
92
+ }
93
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
- "name": "iifeify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "iifeify",
3
+ "version": "1.0.0",
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
+ "license": "MIT",
6
+ "author": "Florian Noever",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Florian-Noever/iifeify.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Florian-Noever/iifeify/issues"
14
+ },
15
+ "homepage": "https://github.com/Florian-Noever/iifeify",
16
+ "keywords": [
17
+ "iife",
18
+ "bundle",
19
+ "esbuild",
20
+ "browser",
21
+ "script",
22
+ "global",
23
+ "license",
24
+ "cli",
25
+ "npx",
26
+ "al",
27
+ "business-central",
28
+ "control-addin"
29
+ ],
30
+ "bin": {
31
+ "iifeify": "bin/cli.js"
32
+ },
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "check-types": "tsc --noEmit",
45
+ "lint": "eslint --max-warnings 0",
46
+ "test": "node --test \"test/*.test.js\"",
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"
49
+ },
50
+ "dependencies": {
51
+ "cac": "^7.1.0",
52
+ "esbuild": "^0.28.0"
53
+ },
54
+ "devDependencies": {
55
+ "@stylistic/eslint-plugin": "^5.10.0",
56
+ "@types/node": "^22.20.5",
57
+ "eslint": "^10.14.0",
58
+ "eslint-plugin-import-x": "^4.18.1",
59
+ "typescript": "^6.0.3"
60
+ },
61
+ "allowScripts": {
62
+ "esbuild": true
63
+ }
64
+ }
package/src/banner.js ADDED
@@ -0,0 +1,64 @@
1
+ const RULE = '='.repeat(80);
2
+
3
+ /**
4
+ * The comment that opens the generated script: one line per bundled package.
5
+ *
6
+ * @param {object[]} packages as described by describePackage, the converted package first
7
+ */
8
+ export function renderBanner([root, ...dependencies]) {
9
+ if (dependencies.length === 0) {
10
+ return `/*! ${line(root)} */`;
11
+ }
12
+ return [
13
+ '/*!',
14
+ ` * ${line(root)}`,
15
+ ' *',
16
+ ' * Bundled dependencies:',
17
+ ...dependencies.map(dependency => ` * ${line(dependency)}`),
18
+ ' */',
19
+ ].join('\n');
20
+ }
21
+
22
+ function line({ name, version, copyright, license, url }) {
23
+ return [[name, version].filter(Boolean).join(' '), copyright, licenseLabel(license), url]
24
+ .filter(Boolean)
25
+ .map(clean)
26
+ .join(' - ');
27
+ }
28
+
29
+ function licenseLabel(license) {
30
+ if (!license) {
31
+ return 'license unknown';
32
+ }
33
+ // "MIT" reads better as "MIT License"; an expression such as "(MIT OR Apache-2.0)" stays as it is.
34
+ return /^[\w.+-]+$/.test(license) ? `${license} License` : license;
35
+ }
36
+
37
+ // The values come from package metadata, which anyone can publish: nothing in them may end the
38
+ // comment or start a new line, or the rest would run as code.
39
+ function clean(value) {
40
+ return String(value).replace(/\s+/g, ' ').replaceAll('*/', '* /').trim();
41
+ }
42
+
43
+ /**
44
+ * The text of the notices file: the full license texts of all bundled packages.
45
+ *
46
+ * @param {object[]} packages as described by describePackage
47
+ * @param {string} scriptName file name of the script the notices belong to
48
+ */
49
+ export function renderNotices(packages, scriptName) {
50
+ const sections = packages.map(({ name, version, license, url, legalTexts }) => [
51
+ RULE,
52
+ [name, version].filter(Boolean).join(' '),
53
+ `License: ${license ?? 'unknown'}`,
54
+ url,
55
+ RULE,
56
+ '',
57
+ legalTexts.length > 0
58
+ ? legalTexts.map(({ text }) => text).join('\n\n')
59
+ : 'This package contains no license file.',
60
+ '',
61
+ ].join('\n'));
62
+
63
+ return [`License notices for the packages bundled in ${scriptName}`, '', ...sections].join('\n');
64
+ }
package/src/bundle.js ADDED
@@ -0,0 +1,150 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { isBuiltin } from 'node:module';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import * as esbuild from 'esbuild';
5
+
6
+ import { ConversionError, UsageError } from './errors.js';
7
+
8
+ // esbuild derives the names in its metafile from an output name. Nothing is written under it.
9
+ const OUT_JS = 'out.js';
10
+
11
+ const MAX_ERRORS_SHOWN = 10;
12
+ const MESSAGE_FORMAT = { color: false, terminalWidth: 100 };
13
+
14
+ /**
15
+ * Rejects a global name or target that esbuild will not accept. esbuild defines the rules for
16
+ * both, so it is asked directly. This is quick, so it can run before anything is installed.
17
+ *
18
+ * @param {{ globalName?: string, target?: string[] }} options
19
+ */
20
+ export async function checkOptions({ globalName, target }) {
21
+ try {
22
+ await esbuild.transform('', { format: 'iife', globalName, target });
23
+ } catch (error) {
24
+ // A bad target fails with a message that names it. A bad global name fails like a syntax error in code.
25
+ if (!Array.isArray(error.errors)) {
26
+ throw new UsageError(error.message);
27
+ }
28
+ throw new UsageError(`--global-name "${globalName}" is not a valid name. ${error.errors.map(({ text }) => text).join(' ')}`);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Bundles an installed package into one classic script that defines a global.
34
+ *
35
+ * @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
42
+ * @param {boolean} options.minify
43
+ * @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
47
+ */
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));
76
+ }
77
+
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;
88
+
89
+ // A build that succeeds always has a script and an entry point.
90
+ return {
91
+ js: /** @type {string} */ (text('.js')),
92
+ 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),
96
+ warnings: await esbuild.formatMessages(result.warnings, { kind: 'warning', ...MESSAGE_FORMAT }),
97
+ };
98
+ }
99
+
100
+ /**
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.
104
+ */
105
+ function entryPointFor(name, dir, entry) {
106
+ if (!entry) {
107
+ return name;
108
+ }
109
+
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}`);
114
+ }
115
+ if (!fromPackage) {
116
+ return name;
117
+ }
118
+ if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
119
+ return file;
120
+ }
121
+ return `${name}/${fromPackage.replaceAll('\\', '/')}`; // a subpath from the package's "exports"
122
+ }
123
+
124
+ /** Says in one sentence why the package cannot be bundled, followed by esbuild's own messages. */
125
+ async function explainFailure(errors, label, entryPoint, entry) {
126
+ const unresolved = errors.map(error => /^Could not resolve "(.+)"$/.exec(error.text)?.[1]).filter(Boolean);
127
+ const builtins = [...new Set(unresolved.filter(isBuiltin).map(module => module.replace(/^node:/, '')))].sort();
128
+
129
+ let summary;
130
+ if (builtins.length > 0) {
131
+ const modules = `module${builtins.length > 1 ? 's' : ''} ${builtins.join(', ')}`;
132
+ summary = `${label} is not browser compatible: it needs the Node.js built-in ${modules}.`;
133
+ } else if (unresolved.includes(entryPoint)) {
134
+ summary = entry
135
+ ? `${label} has no file or export "${entry}".`
136
+ : `${label} has no entry point that can be imported in a browser. Use --entry to name a file inside the package.`;
137
+ } else {
138
+ summary = `${label} could not be bundled for the browser.`;
139
+ }
140
+
141
+ // esbuild's notes also suggest options of its own, such as "You can use platform: 'node'".
142
+ // This tool has no such options, and none of them would make the package run in a browser.
143
+ const shown = errors.slice(0, MAX_ERRORS_SHOWN).map(error => ({
144
+ ...error,
145
+ notes: error.notes.filter(note => !/\bYou can (use|mark)\b/.test(note.text)),
146
+ }));
147
+ const details = await esbuild.formatMessages(shown, { kind: 'error', ...MESSAGE_FORMAT });
148
+ const more = errors.length > shown.length ? `...and ${errors.length - shown.length} more errors.` : '';
149
+ return `${summary}\n\n${details.join('')}${more}`.trimEnd();
150
+ }
package/src/convert.js ADDED
@@ -0,0 +1,105 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+
5
+ import { renderBanner, renderNotices } from './banner.js';
6
+ import { bundle, checkOptions } from './bundle.js';
7
+ import { installPackage, parseSpec } from './install.js';
8
+ import { bundledPackages } from './packages.js';
9
+
10
+ /**
11
+ * Installs an npm package into a temporary folder and writes it as one classic browser script.
12
+ *
13
+ * @param {object} options
14
+ * @param {string} options.spec package to convert, such as "vanilla-jsoneditor@3.13.0"
15
+ * @param {string} [options.entry] file or subpath inside the package; default is its browser entry
16
+ * @param {string} [options.globalName] default is the package name in PascalCase
17
+ * @param {string} [options.outfile] default is <package name>.iife.js in the current folder
18
+ * @param {boolean} [options.minify]
19
+ * @param {string[]} [options.target] esbuild targets such as ["es2020"]
20
+ * @param {boolean} [options.notices] also write the full license texts next to the script
21
+ * @returns {Promise<{ files: { js: string, css?: string, notices?: string }, bytes: number,
22
+ * packages: object[], entry: string, globalName: string, warnings: string[], hint?: string }>}
23
+ */
24
+ export async function convert({ spec, entry, globalName: requestedGlobalName, outfile, minify = true, target, notices = false }) {
25
+ const wanted = parseSpec(spec);
26
+ await checkOptions({ globalName: requestedGlobalName, target });
27
+
28
+ // The real path: on Windows the temp folder can be given as a short 8.3 alias, and esbuild
29
+ // reports files by their real paths.
30
+ const workDir = realpathSync.native(mkdtempSync(join(tmpdir(), 'iifeify-')));
31
+ try {
32
+ const { dir, manifest } = installPackage(wanted, workDir);
33
+ const globalName = requestedGlobalName ?? defaultGlobalName(manifest.name);
34
+
35
+ const label = `${manifest.name} ${manifest.version}`;
36
+ const built = await bundle({ workDir, name: wanted.name, dir, label, entry, globalName, minify, target });
37
+ const packages = bundledPackages(built.inputs, dir);
38
+ const banner = renderBanner(packages);
39
+
40
+ const files = { js: resolve(outfile ?? defaultOutfile(manifest.name)) };
41
+ const base = files.js.replace(/\.js$/i, '');
42
+ const script = `${banner}\n${built.js}`;
43
+ mkdirSync(dirname(files.js), { recursive: true });
44
+ writeFileSync(files.js, script);
45
+ if (built.css !== undefined) {
46
+ files.css = `${base}.css`;
47
+ writeFileSync(files.css, `${banner}\n${built.css}`);
48
+ }
49
+ if (notices) {
50
+ files.notices = `${base}.LICENSES.txt`;
51
+ writeFileSync(files.notices, renderNotices(packages, basename(files.js)));
52
+ }
53
+
54
+ const unlicensed = packages.filter(pkg => !pkg.license).map(pkg => `${pkg.name} ${pkg.version} does not declare a license.`);
55
+ return {
56
+ files,
57
+ bytes: Buffer.byteLength(script),
58
+ packages,
59
+ entry: relative(dir, built.entry).replaceAll('\\', '/'),
60
+ globalName,
61
+ warnings: [...built.warnings.map(warning => warning.trim()), ...unlicensed],
62
+ hint: entry ? undefined : prebuiltHint(manifest, dir, built.entry),
63
+ };
64
+ } finally {
65
+ try {
66
+ rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
67
+ } catch {
68
+ // A virus scanner can hold files open on Windows. A leftover temp folder must not turn
69
+ // a finished conversion into a failure, or hide the error that is already on its way.
70
+ }
71
+ }
72
+ }
73
+
74
+ /** "vanilla-jsoneditor" and "@scope/vanilla-jsoneditor" both give "VanillaJsoneditor". */
75
+ export function defaultGlobalName(packageName) {
76
+ const words = unscoped(packageName).split(/[^A-Za-z0-9]+/).filter(Boolean);
77
+ const name = words.map(word => word[0].toUpperCase() + word.slice(1)).join('');
78
+ return /^[A-Za-z]/.test(name) ? name : `_${name}`;
79
+ }
80
+
81
+ /** "@scope/vanilla-jsoneditor" gives "vanilla-jsoneditor.iife.js". */
82
+ export function defaultOutfile(packageName) {
83
+ return `${unscoped(packageName)}.iife.js`;
84
+ }
85
+
86
+ function unscoped(packageName) {
87
+ return packageName.slice(packageName.indexOf('/') + 1);
88
+ }
89
+
90
+ /**
91
+ * Some packages point "exports" at a file that needs its dependencies bundled in, and "browser" at
92
+ * a ready-made file that contains them. Bundlers follow "exports", so the ready-made file takes --entry.
93
+ */
94
+ function prebuiltHint(manifest, dir, entryFile) {
95
+ if (typeof manifest.browser !== 'string') {
96
+ return undefined;
97
+ }
98
+
99
+ const prebuilt = resolve(dir, manifest.browser);
100
+ if (prebuilt === entryFile || !existsSync(prebuilt)) {
101
+ return undefined;
102
+ }
103
+ const entry = relative(dir, prebuilt).replaceAll('\\', '/');
104
+ return `${manifest.name} also ships a prebuilt browser file. To bundle that instead, add: --entry ${entry}`;
105
+ }
package/src/errors.js ADDED
@@ -0,0 +1,5 @@
1
+ /** A conversion that cannot succeed as asked. The CLI prints the message and exits with 1. */
2
+ export class ConversionError extends Error {}
3
+
4
+ /** A wrong command line. The CLI prints the message and exits with 2. */
5
+ export class UsageError extends Error {}
package/src/install.js ADDED
@@ -0,0 +1,52 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { ConversionError, UsageError } from './errors.js';
6
+
7
+ // npm's rule for package names, including old names with capital letters.
8
+ const PACKAGE_NAME = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i;
9
+
10
+ const INSTALL = 'npm install --ignore-scripts --no-audit --no-fund --no-package-lock --loglevel=error';
11
+
12
+ /**
13
+ * Splits what the user typed, such as "vanilla-jsoneditor@3.13.0" or "@scope/name", into the
14
+ * package name and the version, range or tag to install.
15
+ */
16
+ export function parseSpec(spec) {
17
+ const at = spec.indexOf('@', 1); // an @ at position 0 starts a scope
18
+ const name = at === -1 ? spec : spec.slice(0, at);
19
+ const range = at === -1 ? '' : spec.slice(at + 1);
20
+ if (!PACKAGE_NAME.test(name)) {
21
+ throw new UsageError(
22
+ `"${spec}" is not a package name. ` +
23
+ 'Expected a name such as vanilla-jsoneditor, optionally with a version: vanilla-jsoneditor@3.13.0'
24
+ );
25
+ }
26
+ return { name, range: range || 'latest' };
27
+ }
28
+
29
+ /**
30
+ * Installs the package and its dependencies into workDir/node_modules.
31
+ *
32
+ * @returns {{ dir: string, manifest: object }} the package's folder and its package.json
33
+ */
34
+ export function installPackage({ name, range }, workDir) {
35
+ // A package.json of our own makes workDir the project root. Without one, npm installs into the
36
+ // nearest parent folder that has a package.json, and the temp folder lies inside the user
37
+ // profile. It also keeps the range off the command line: on Windows npm starts through cmd.exe,
38
+ // where the ^, < and > of a range mean something else.
39
+ writeFileSync(join(workDir, 'package.json'), JSON.stringify({ private: true, dependencies: { [name]: range } }));
40
+
41
+ try {
42
+ execSync(INSTALL, { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', windowsHide: true });
43
+ } catch (error) {
44
+ const output = String(error.stderr ?? '').trim() || error.message;
45
+ throw new ConversionError(`Could not install ${name}@${range}.\n\n${output}`);
46
+ }
47
+
48
+ // The real path, because that is how esbuild reports the package's files: a linked local folder
49
+ // (name@file:/some/folder) is not under node_modules at all.
50
+ const dir = realpathSync.native(join(workDir, 'node_modules', name));
51
+ return { dir, manifest: JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) };
52
+ }
@@ -0,0 +1,160 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ const NODE_MODULES = '/node_modules/';
5
+ const HOSTS = { github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org' };
6
+
7
+ const LEGAL_FILE = /^(licen[cs]e|copying|notice)\b/i;
8
+ const CODE_FILE = /\.([cm]?js|ts|json)$/i;
9
+
10
+ // License files of these kinds open with the package's own copyright notice. An Apache or GPL file
11
+ // instead holds a fill-in template or the notice of the license's publisher.
12
+ const NOTICE_FIRST_LICENSE = /\b(MIT|ISC|0?BSD)\b/i;
13
+ // "Copyright (c) <year> <copyright holders>", "Copyright [yyyy] [name of copyright owner]"
14
+ const TEMPLATE = /[<[{]\s*(year|yyyy|name|owner|fullname|copyright holders?)\b/i;
15
+ // Sentences of the license text itself, which can start a line where the text is wrapped.
16
+ const PROSE = /^copyright\s+(holders?|owners?|notice|law|and)\b/i;
17
+
18
+ /**
19
+ * The packages whose code is in the bundle: the converted package first, then the others by name.
20
+ *
21
+ * @param {string[]} inputs absolute paths of the bundled files
22
+ * @param {string} rootDir folder of the converted package
23
+ */
24
+ export function bundledPackages(inputs, rootDir) {
25
+ const dirs = new Set(inputs.map(packageDirOf).filter(Boolean).map(dir => resolve(dir)));
26
+ dirs.delete(resolve(rootDir));
27
+
28
+ // The same version can be installed more than once, in nested node_modules folders.
29
+ const others = new Map();
30
+ for (const dir of dirs) {
31
+ const pkg = describePackage(dir);
32
+ others.set(`${pkg.name}@${pkg.version}`, pkg);
33
+ }
34
+ return [describePackage(rootDir), ...[...others.keys()].sort().map(key => others.get(key))];
35
+ }
36
+
37
+ /** The folder of the package that a file belongs to, or undefined if it belongs to none. */
38
+ export function packageDirOf(file) {
39
+ const path = file.replaceAll('\\', '/');
40
+ const at = path.lastIndexOf(NODE_MODULES);
41
+ if (at !== -1) {
42
+ const [first, second] = path.slice(at + NODE_MODULES.length).split('/');
43
+ return path.slice(0, at + NODE_MODULES.length) + (first.startsWith('@') ? `${first}/${second}` : first);
44
+ }
45
+
46
+ // A linked local folder is not under node_modules. Its package.json is the nearest one with a
47
+ // name; a nested one such as dist/esm/package.json only sets the module type.
48
+ for (let dir = dirname(file); ; dir = dirname(dir)) {
49
+ if (isPackageRoot(dir)) {
50
+ return dir;
51
+ }
52
+ if (dirname(dir) === dir) {
53
+ return undefined;
54
+ }
55
+ }
56
+ }
57
+
58
+ function isPackageRoot(dir) {
59
+ try {
60
+ return typeof JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name === 'string';
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * What the banner and the notices file say about a package.
68
+ *
69
+ * @returns {{ name: string, version: string, license?: string, copyright?: string, url: string,
70
+ * legalTexts: { file: string, text: string }[] }}
71
+ */
72
+ export function describePackage(dir) {
73
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
74
+ const license = licenseOf(manifest);
75
+ const legalTexts = readdirSync(dir, { withFileTypes: true })
76
+ .filter(entry => entry.isFile() && LEGAL_FILE.test(entry.name) && !CODE_FILE.test(entry.name))
77
+ .map(entry => entry.name)
78
+ .sort()
79
+ .map(file => ({ file, text: readFileSync(join(dir, file), 'utf8').replace(/\r\n?/g, '\n').trim() }));
80
+
81
+ return {
82
+ name: manifest.name,
83
+ version: manifest.version,
84
+ license,
85
+ copyright: copyrightOf(license, legalTexts, manifest.author),
86
+ url: urlOf(manifest),
87
+ legalTexts,
88
+ };
89
+ }
90
+
91
+ function licenseOf({ license, licenses }) {
92
+ if (typeof license === 'string' && license) {
93
+ return license;
94
+ }
95
+ if (typeof license?.type === 'string') { // old form: { type, url }
96
+ return license.type;
97
+ }
98
+ if (Array.isArray(licenses)) { // old form: a list of the above
99
+ return licenses.map(entry => entry?.type ?? entry).filter(type => typeof type === 'string').join(' OR ') || undefined;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ function copyrightOf(license, legalTexts, author) {
105
+ if (license && NOTICE_FIRST_LICENSE.test(license)) {
106
+ for (const { text } of legalTexts) {
107
+ const notice = copyrightNotice(text);
108
+ if (notice) {
109
+ return notice;
110
+ }
111
+ }
112
+ }
113
+ // "Jane Doe <jane@example.com> (https://example.com)" or { name, email, url }
114
+ const name = typeof author === 'string' ? author.replace(/\s*[<(].*$/, '') : author?.name;
115
+ return typeof name === 'string' && name.trim() ? `(c) ${name.trim()}` : undefined;
116
+ }
117
+
118
+ /** The first copyright notice in a license text, such as "Copyright (c) 2020 Jane Doe". */
119
+ function copyrightNotice(text) {
120
+ // Without Markdown heading, quote, list and emphasis marks.
121
+ const lines = text.split('\n').map(line => line.replace(/^[\s#>*_-]+|[\s*_]+$/g, ''));
122
+ const start = lines.findIndex(line => /^(copyright\b|\(c\)|©)/i.test(line) && !TEMPLATE.test(line) && !PROSE.test(line));
123
+ if (start === -1) {
124
+ return undefined;
125
+ }
126
+
127
+ // A long list of holders runs on to the next line, leaving a comma at the end of this one.
128
+ let end = start;
129
+ while (lines[end].endsWith(',') && lines[end + 1]) {
130
+ end++;
131
+ }
132
+ return lines.slice(start, end + 1).join(' ').replace(/\s+/g, ' ');
133
+ }
134
+
135
+ function urlOf({ name, homepage, repository }) {
136
+ if (typeof homepage === 'string' && /^https?:\/\//.test(homepage)) {
137
+ return homepage;
138
+ }
139
+ return repositoryUrl(repository) ?? `https://www.npmjs.com/package/${name}`;
140
+ }
141
+
142
+ /** The web address of a package.json "repository", which can be a string or { url } in several notations. */
143
+ function repositoryUrl(repository) {
144
+ const raw = typeof repository === 'string' ? repository : repository?.url;
145
+ if (typeof raw !== 'string') {
146
+ return undefined;
147
+ }
148
+
149
+ const short = /^(?:(github|gitlab|bitbucket):)?([\w.-]+\/[\w.-]+)$/.exec(raw); // "user/repo", "gitlab:user/repo"
150
+ if (short) {
151
+ return `https://${HOSTS[short[1] ?? 'github']}/${short[2]}`;
152
+ }
153
+
154
+ const url = raw
155
+ .replace(/^git\+/, '')
156
+ .replace(/^git@([^:/]+):/, 'https://$1/') // git@github.com:user/repo.git
157
+ .replace(/^(git|ssh):\/\/(git@)?/, 'https://')
158
+ .replace(/\.git(#.*)?$/, '');
159
+ return /^https?:\/\//.test(url) ? url : undefined;
160
+ }