@bwat47/joplin-plugin-scripts 1.1.0 → 1.2.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
@@ -11,6 +11,7 @@ npm install -D @bwat47/joplin-plugin-scripts
11
11
 
12
12
  ```json
13
13
  "scripts": {
14
+ "typecheck": "tsc --noEmit",
14
15
  "dist": "npm run typecheck && joplin-plugin-scripts build",
15
16
  "updateVersion": "joplin-plugin-scripts update-version",
16
17
  "updateApi": "joplin-plugin-scripts update-api"
@@ -19,13 +20,13 @@ npm install -D @bwat47/joplin-plugin-scripts
19
20
 
20
21
  Commands run from the plugin project root, which npm scripts do automatically.
21
22
 
22
- | Command | What it does |
23
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
24
- | `build` | Validates the project, clears `dist/` and `publish/`, copies runtime assets from `src/`, bundles, and writes `publish/<id>.jpl` and `<id>.json`. |
25
- | `update-version` | Increments the patch version in `package.json`, `src/manifest.json`, and `package-lock.json` together, or writes nothing. |
26
- | `update-api` | Replaces `api/` with the latest generator-joplin API and warns if the category or content-script external lists here differ. |
23
+ | Command | What it does |
24
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `build` | Validates the project, clears `dist/` and `publish/`, copies runtime assets from `src/`, bundles, and writes `publish/<id>.jpl` and `<id>.json`. |
26
+ | `update-version` | Increments the patch version across `package.json`, `src/manifest.json`, and `package-lock.json`. |
27
+ | `update-api` | Replaces `api/` with the latest generator-joplin API and warns if the category or content-script external lists here differ. Requires npm, even when scripts run through Yarn or pnpm. |
27
28
 
28
- ### Project layout expected
29
+ ### Expected project layout
29
30
 
30
31
  - `src/index.ts`: main entry point, bundled to `dist/index.js` for the Node platform.
31
32
  `node:` imports are rewritten to bare names for Joplin's plugin runtime.
@@ -41,7 +42,9 @@ Commands run from the plugin project root, which npm scripts do automatically.
41
42
  {
42
43
  "extraScripts": ["contentScripts/codeMirror.ts"],
43
44
  "webviewScripts": ["dialog/dialog.ts"],
44
- "mainBuildOptions": { "external": ["jsdom"] }
45
+ "mainBuildOptions": { "external": ["jsdom"] },
46
+ "contentScriptBuildOptions": { "sourcemap": "inline" },
47
+ "webviewScriptBuildOptions": { "minify": false }
45
48
  }
46
49
  ```
47
50
 
@@ -50,13 +53,81 @@ Commands run from the plugin project root, which npm scripts do automatically.
50
53
  as CommonJS to the same path under `dist/` with a `.js` extension. Joplin's CodeMirror
51
54
  and Lezer packages stay external so they share the host's instances.
52
55
  - `webviewScripts`: scripts loaded into a dialog or panel with `addScript`, relative to
53
- `src/`. A webview loads them as plain `<script>` tags with no `module`, `exports`, or
54
- `require`, so each is bundled as a self-contained IIFE with nothing external. Exports
55
- are dropped, so the script must start itself, e.g. by calling its init function at the
56
- top level. Plain `.js` files in `src/` are copied as-is and don't need listing.
57
- - `mainBuildOptions`: esbuild options merged over the main bundle's defaults. Relative
58
- `alias` targets resolve from the project root. `absWorkingDir`, `entryPoints`,
59
- `outfile`, and `outdir` cannot be overridden.
56
+ `src/`. Each builds as a self-contained IIFE and must initialize itself. Plain `.js`
57
+ files in `src/` are copied automatically and don't need listing.
58
+
59
+ Script output paths must be unique after normalization. They are compared without case on
60
+ every platform, since the archive may be installed on a case-insensitive filesystem.
61
+ `dist/index.js` is reserved for the main bundle. Invalid paths and collisions are rejected
62
+ before existing build output is cleared.
63
+
64
+ #### Advanced build options
65
+
66
+ Each option group merges esbuild options over the defaults for its bundle type:
67
+
68
+ | Option group | Applies to |
69
+ | --------------------------- | -------------------- |
70
+ | `mainBuildOptions` | Main bundle only |
71
+ | `contentScriptBuildOptions` | Every content script |
72
+ | `webviewScriptBuildOptions` | Every webview script |
73
+
74
+ - Relative `alias` targets resolve from the project root.
75
+ - `define` entries merge individually, preserving `process.env.NODE_ENV` as
76
+ `"production"` unless explicitly overridden.
77
+ - Reserved options: `absWorkingDir`, `entryPoints`, `outfile`, `outdir`, `write`,
78
+ `format`, `plugins`, `bundle`, and `packages`. Content and webview scripts also
79
+ cannot override `platform`.
80
+ - `external` keeps packages as runtime imports. Content-script entries extend the
81
+ CodeMirror and Lezer externals, which cannot be removed. Webview scripts cannot
82
+ set `external`, because they have no runtime `require`.
83
+ - For debugging, prefer `"sourcemap": "inline"`. Setting it to `true` writes `.js.map`
84
+ files into `dist/` that ship in the `.jpl`.
85
+
86
+ ### Migrating from the standard Webpack setup
87
+
88
+ #### Resolve the Joplin API through TypeScript paths
89
+
90
+ Add these mappings to your existing `tsconfig.json` `compilerOptions` so `api` and
91
+ `api/types` imports resolve for both TypeScript and esbuild:
92
+
93
+ ```json
94
+ {
95
+ "compilerOptions": {
96
+ "paths": {
97
+ "api": ["./api/index.ts"],
98
+ "api/*": ["./api/*"]
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Remove `baseUrl` ([deprecated in TypeScript 6](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html#deprecated---baseurl))
105
+ and make all path targets relative to the declaring `tsconfig.json`. Add explicit
106
+ mappings for any other imports that relied on `baseUrl`.
107
+
108
+ #### Run type checking separately
109
+
110
+ esbuild does not check types. Keep `tsc --noEmit` before the build, as shown in Usage.
111
+
112
+ #### Translate Webpack overrides to esbuild options
113
+
114
+ `webpackOverrides` is not supported. Replace it in `plugin.config.json` with
115
+ equivalent esbuild options:
116
+
117
+ | Webpack setting | esbuild equivalent |
118
+ | ----------------------- | ------------------------------------------------------ |
119
+ | `resolve.alias` | `mainBuildOptions.alias`, or suitable tsconfig `paths` |
120
+ | `devtool` | `mainBuildOptions.sourcemap` |
121
+ | `optimization.minimize` | `mainBuildOptions.minify` |
122
+ | `externals` | `mainBuildOptions.external`, using esbuild's syntax |
123
+ | Loaders and plugins | Require an equivalent approach; they cannot be reused |
124
+
125
+ External dependencies remain runtime imports and must be available in Joplin's
126
+ plugin runtime; marking a package external does not include it in the archive.
127
+
128
+ The standard setup applies `webpackOverrides` to both main and content-script
129
+ builds. Here, `mainBuildOptions` affects only the main bundle. Move settings that
130
+ should also apply to content scripts into `contentScriptBuildOptions`.
60
131
 
61
132
  ### Testing a plugin's bundles
62
133
 
@@ -64,18 +135,21 @@ The package exports the same esbuild options the build uses, so plugin tests can
64
135
  in memory and run the result:
65
136
 
66
137
  ```js
67
- import { createBuildOptions, createContentScriptBuildOptions, readPluginConfig } from '@bwat47/joplin-plugin-scripts';
138
+ import { createBuildOptions, readPluginConfig } from '@bwat47/joplin-plugin-scripts';
139
+ import { build } from 'esbuild';
68
140
 
69
141
  const { mainBuildOptions } = await readPluginConfig(root);
70
142
  const result = await build({ ...createBuildOptions(root, mainBuildOptions), write: false });
71
143
  ```
72
144
 
73
- `createWebviewScriptBuildOptions` and `contentScriptExternals` are also exported.
145
+ Also exported: `createContentScriptBuildOptions`, `createWebviewScriptBuildOptions`,
146
+ `contentScriptExternals`, and `PluginScriptsError` for expected configuration and validation errors.
74
147
 
75
148
  ## Development
76
149
 
77
150
  ```sh
78
151
  npm test
152
+ npm run typecheck
79
153
  npm run lint
80
154
  npm run format
81
155
  ```
@@ -83,5 +157,5 @@ npm run format
83
157
  To try a change in a plugin before publishing, run `npm pack` here and
84
158
  `npm install -D ../joplin-plugin-scripts/bwat47-joplin-plugin-scripts-<version>.tgz` in the plugin.
85
159
 
86
- Release: bump `version`, then `npm publish --access public` (`prepublishOnly` runs lint,
87
- format check, and tests).
160
+ Release: bump `version`, then `npm publish --access public` (`prepublishOnly` runs type checking,
161
+ lint, format check, and tests; `prepack` then generates the declarations).
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildPlugin } from '../lib/build.mjs';
3
+ import { PluginScriptsError } from '../lib/errors.mjs';
3
4
  import { updateApi } from '../lib/updateApi.mjs';
4
5
  import { updateVersion } from '../lib/updateVersion.mjs';
5
6
 
@@ -12,6 +13,7 @@ Commands:
12
13
  update-version Increment the patch version in package.json, src/manifest.json, and package-lock.json
13
14
  update-api Replace api/ with the declarations from the latest generator-joplin`;
14
15
 
16
+ /** @type {Record<string, (root: string) => Promise<void>>} */
15
17
  const commands = {
16
18
  build: async (root) => {
17
19
  await buildPlugin(root);
@@ -36,5 +38,11 @@ if (!run || rest.length > 0) {
36
38
  console.error(USAGE);
37
39
  process.exit(1);
38
40
  }
39
- // Unhandled rejections print the stack and exit non-zero, as the original scripts did.
40
- await run(process.cwd());
41
+ try {
42
+ await run(process.cwd());
43
+ } catch (error) {
44
+ // Expected failures print only their message; anything else is a bug, so its stack is shown.
45
+ if (!(error instanceof PluginScriptsError)) throw error;
46
+ console.error(`Error: ${error.message}`);
47
+ process.exitCode = 1;
48
+ }
package/lib/build.mjs CHANGED
@@ -10,21 +10,24 @@ import {
10
10
  readPluginConfig,
11
11
  scriptOutputPaths,
12
12
  } from './buildConfig.mjs';
13
+ import { PluginScriptsError } from './errors.mjs';
13
14
  import { packagePlugin, readProject } from './packagePlugin.mjs';
14
15
 
15
16
  /**
16
17
  * Builds the plugin at `root`: validates the project, clears `dist/` and `publish/`, copies runtime
17
18
  * assets from `src/`, bundles the main entry point, extra scripts, and webview scripts, and creates
18
19
  * the `.jpl` archive.
20
+ * @param {string} root Plugin project root.
19
21
  */
20
22
  export async function buildPlugin(root) {
21
23
  const dist = path.join(root, 'dist');
22
24
  const publish = path.join(root, 'publish');
23
25
  const { manifest } = await readProject(root);
24
- const { extraScripts, webviewScripts, mainBuildOptions } = await readPluginConfig(root);
26
+ const { extraScripts, webviewScripts, mainBuildOptions, contentScriptBuildOptions, webviewScriptBuildOptions } =
27
+ await readPluginConfig(root);
25
28
  const scriptBuilds = [
26
- ...extraScripts.map((script) => createContentScriptBuildOptions(root, script)),
27
- ...webviewScripts.map((script) => createWebviewScriptBuildOptions(root, script)),
29
+ ...extraScripts.map((script) => createContentScriptBuildOptions(root, script, contentScriptBuildOptions)),
30
+ ...webviewScripts.map((script) => createWebviewScriptBuildOptions(root, script, webviewScriptBuildOptions)),
28
31
  ];
29
32
 
30
33
  // Only these fixed, project-local output directories are removed.
@@ -37,10 +40,27 @@ export async function buildPlugin(root) {
37
40
  recursive: true,
38
41
  filter: (source) => isRuntimeAsset(root, source),
39
42
  });
40
- await Promise.all([
43
+ // Wait for every bundle, so esbuild has printed all of its errors before the build stops.
44
+ const results = await Promise.allSettled([
41
45
  build(createBuildOptions(root, mainBuildOptions)),
42
46
  ...scriptBuilds.map((options) => build(options)),
43
47
  ]);
48
+ const failures = results.filter((result) => result.status === 'rejected').map((result) => result.reason);
49
+ if (failures.length > 0) {
50
+ // esbuild's own failures carry `errors` and have already been logged; anything else is unexpected.
51
+ const unexpected = failures.find((failure) => !isEsbuildFailure(failure));
52
+ if (unexpected) throw unexpected;
53
+ throw new PluginScriptsError('Bundling failed; see the esbuild errors above.', { cause: failures[0] });
54
+ }
44
55
 
45
56
  await packagePlugin(root, manifest, scriptOutputPaths(extraScripts, webviewScripts));
46
57
  }
58
+
59
+ /**
60
+ * Checks whether a rejected build is an esbuild `BuildFailure`, whose messages esbuild has already logged.
61
+ * @param {unknown} error
62
+ * @returns {boolean}
63
+ */
64
+ function isEsbuildFailure(error) {
65
+ return error instanceof Error && 'errors' in error && Array.isArray(error.errors);
66
+ }
@@ -1,6 +1,8 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { errorMessage, hasErrorCode, PluginScriptsError } from './errors.mjs';
5
+
4
6
  /** Name of the optional per-plugin build configuration file at the project root. */
5
7
  export const CONFIG_FILE = 'plugin.config.json';
6
8
 
@@ -22,10 +24,56 @@ const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
22
24
  /** Matches a Node built-in import with the `node:` prefix, e.g. `node:path`. */
23
25
  const NODE_PREFIX_PATTERN = /^node:/;
24
26
 
25
- /** esbuild options that the package owns and `mainBuildOptions` may not override. */
26
- const RESERVED_BUILD_OPTIONS = ['absWorkingDir', 'entryPoints', 'outfile', 'outdir'];
27
+ /**
28
+ * esbuild options that the package owns and no override may set: the input and output locations,
29
+ * `write` (packaging needs `dist/index.js` on disk), `format` (Joplin loads the main bundle as
30
+ * CommonJS), `plugins` (replacing them drops the `node:` prefix rewrite), and `bundle` and
31
+ * `packages`. Each output is loaded as a single file: with `bundle: false` or
32
+ * `packages: "external"`, its imports would stay as `require` calls for modules that are not in
33
+ * the archive or, in a webview, for a `require` that does not exist. Individual packages can
34
+ * still be made external with `external`.
35
+ */
36
+ const RESERVED_BUILD_OPTIONS = [
37
+ 'absWorkingDir',
38
+ 'entryPoints',
39
+ 'outfile',
40
+ 'outdir',
41
+ 'write',
42
+ 'format',
43
+ 'plugins',
44
+ 'bundle',
45
+ 'packages',
46
+ ];
47
+
48
+ /**
49
+ * Further options that content-script and webview-script overrides may not set. Both kinds of
50
+ * script run in a webview, so `platform` stays `browser`: under `node`, a Node built-in import
51
+ * would become a `require` that fails at runtime instead of failing the build.
52
+ */
53
+ const RESERVED_SCRIPT_BUILD_OPTIONS = [...RESERVED_BUILD_OPTIONS, 'platform'];
54
+
55
+ /**
56
+ * Webview scripts also may not set `external`: an IIFE loaded by a `<script>` tag has no
57
+ * `require`, so any external import would fail at runtime.
58
+ */
59
+ const RESERVED_WEBVIEW_BUILD_OPTIONS = [...RESERVED_SCRIPT_BUILD_OPTIONS, 'external'];
60
+
61
+ /**
62
+ * Configuration keys holding esbuild overrides, with the options each may not set.
63
+ * @type {Record<'mainBuildOptions' | 'contentScriptBuildOptions' | 'webviewScriptBuildOptions', string[]>}
64
+ */
65
+ const BUILD_OPTION_KEYS = {
66
+ mainBuildOptions: RESERVED_BUILD_OPTIONS,
67
+ contentScriptBuildOptions: RESERVED_SCRIPT_BUILD_OPTIONS,
68
+ webviewScriptBuildOptions: RESERVED_WEBVIEW_BUILD_OPTIONS,
69
+ };
27
70
 
28
- /** Excludes bundled sources, tests, and development fixtures in `<root>/src` from copied runtime assets. */
71
+ /**
72
+ * Excludes bundled sources, tests, and development fixtures in `<root>/src` from copied runtime assets.
73
+ * @param {string} root Plugin project root.
74
+ * @param {string} source
75
+ * @returns {boolean}
76
+ */
29
77
  export function isRuntimeAsset(root, source) {
30
78
  const parts = path.relative(path.join(root, 'src'), source).split(PATH_SEPARATOR_PATTERN);
31
79
  return (
@@ -73,6 +121,7 @@ const stripNodePrefix = {
73
121
  * Options shared by the main plugin and content-script bundles.
74
122
  * `api` resolves through tsconfig.json `paths`: api/index.ts exports Joplin's injected global,
75
123
  * and api/types contains runtime enums, so both are bundled.
124
+ * @param {string} root Plugin project root.
76
125
  * @returns {import('esbuild').BuildOptions}
77
126
  */
78
127
  function sharedOptions(root) {
@@ -92,43 +141,72 @@ function sharedOptions(root) {
92
141
  /**
93
142
  * Returns the esbuild options for the main plugin bundle, `src/index.ts` to `dist/index.js`.
94
143
  * `mainBuildOptions` from the plugin config are merged over the defaults.
144
+ * `define` entries are merged individually so unrelated definitions preserve the defaults.
95
145
  * @param {string} root Plugin project root.
96
146
  * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `mainBuildOptions`.
97
147
  * @returns {import('esbuild').BuildOptions}
98
148
  */
99
149
  export function createBuildOptions(root, overrides = {}) {
150
+ const defaults = sharedOptions(root);
100
151
  return {
101
- ...sharedOptions(root),
152
+ ...defaults,
102
153
  entryPoints: ['src/index.ts'],
103
154
  outfile: 'dist/index.js',
104
155
  platform: 'node',
105
156
  plugins: [stripNodePrefix],
106
157
  ...overrides,
158
+ define: { ...defaults.define, ...overrides.define },
107
159
  };
108
160
  }
109
161
 
110
162
  /**
111
- * Returns the esbuild options for one content script, e.g. `contentScripts/codeMirror.ts`
112
- * (relative to `src/`) to `dist/contentScripts/codeMirror.js`.
113
- * @param {string} root Plugin project root.
163
+ * Rejects a script path that is not a bundleable script inside `src/`, e.g. `../a.ts` or `notes.txt`.
114
164
  * @param {string} script Script path relative to `src/`.
115
- * @returns {import('esbuild').BuildOptions}
116
165
  */
117
- export function createContentScriptBuildOptions(root, script) {
166
+ function assertValidScriptPath(script) {
118
167
  if (
119
168
  !SCRIPT_EXTENSION_PATTERN.test(script) ||
120
169
  path.isAbsolute(script) ||
121
170
  script.split(PATH_SEPARATOR_PATTERN).includes('..')
122
171
  ) {
123
- throw new Error(`Invalid extra script path: ${script}`);
172
+ throw new PluginScriptsError(`Invalid extra script path: ${script}`);
124
173
  }
125
- // Content scripts run in the editor webview, so they use esbuild's default browser platform:
126
- // a Node built-in import fails the build instead of becoming a runtime require.
174
+ }
175
+
176
+ /**
177
+ * Options shared by content-script and webview-script bundles, with `overrides` merged over them.
178
+ * Scripts run in a webview, so they use esbuild's default browser platform: a Node built-in
179
+ * import fails the build instead of becoming a runtime require.
180
+ * @param {string} root Plugin project root.
181
+ * @param {string} script Script path relative to `src/`.
182
+ * @param {import('esbuild').BuildOptions} overrides
183
+ * @returns {import('esbuild').BuildOptions}
184
+ */
185
+ function scriptOptions(root, script, overrides) {
186
+ assertValidScriptPath(script);
187
+ const defaults = sharedOptions(root);
127
188
  return {
128
- ...sharedOptions(root),
189
+ ...defaults,
129
190
  entryPoints: [`src/${script}`],
130
191
  outfile: `dist/${script.replace(SCRIPT_EXTENSION_PATTERN, '.js')}`,
131
- external: contentScriptExternals,
192
+ ...overrides,
193
+ define: { ...defaults.define, ...overrides.define },
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Returns the esbuild options for one content script, e.g. `contentScripts/codeMirror.ts`
199
+ * (relative to `src/`) to `dist/contentScripts/codeMirror.js`. `overrides` are merged over the
200
+ * defaults; their `external` entries are added to the host libraries, which always stay external.
201
+ * @param {string} root Plugin project root.
202
+ * @param {string} script Script path relative to `src/`.
203
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `contentScriptBuildOptions`.
204
+ * @returns {import('esbuild').BuildOptions}
205
+ */
206
+ export function createContentScriptBuildOptions(root, script, overrides = {}) {
207
+ return {
208
+ ...scriptOptions(root, script, overrides),
209
+ external: [...new Set([...contentScriptExternals, ...(overrides.external ?? [])])],
132
210
  };
133
211
  }
134
212
 
@@ -139,34 +217,151 @@ export function createContentScriptBuildOptions(root, script) {
139
217
  * self-contained IIFE. Their exports are dropped; the script runs for its side effects.
140
218
  * @param {string} root Plugin project root.
141
219
  * @param {string} script Script path relative to `src/`.
220
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `webviewScriptBuildOptions`.
142
221
  * @returns {import('esbuild').BuildOptions}
143
222
  */
144
- export function createWebviewScriptBuildOptions(root, script) {
223
+ export function createWebviewScriptBuildOptions(root, script, overrides = {}) {
145
224
  return {
146
- ...createContentScriptBuildOptions(root, script),
225
+ ...scriptOptions(root, script, overrides),
147
226
  format: 'iife',
148
227
  // A webview has no host CodeMirror instance to share, so everything is bundled.
149
228
  external: [],
150
229
  };
151
230
  }
152
231
 
153
- /** Returns `dist/`-relative output paths for every script the plugin config registers. */
232
+ /**
233
+ * Returns `dist/`-relative output paths for every script the plugin config registers.
234
+ * @param {string[]} extraScripts Content-script paths relative to `src/`.
235
+ * @param {string[]} webviewScripts Webview-script paths relative to `src/`.
236
+ * @returns {string[]} Bundle paths relative to `dist/`, using native separators.
237
+ */
154
238
  export function scriptOutputPaths(extraScripts, webviewScripts) {
155
- return [...extraScripts, ...webviewScripts].map((script) => script.replace(SCRIPT_EXTENSION_PATTERN, '.js'));
239
+ return [...extraScripts, ...webviewScripts].map((script) =>
240
+ path.normalize(script.replace(SCRIPT_EXTENSION_PATTERN, '.js'))
241
+ );
242
+ }
243
+
244
+ /**
245
+ * Shows a `dist/`-relative output path with `/` separators on every platform, e.g. `scripts\a.js` as `scripts/a.js`.
246
+ * @param {string} output Bundle path relative to `dist/`.
247
+ * @returns {string}
248
+ */
249
+ function displayOutputPath(output) {
250
+ return output.replaceAll('\\', '/');
251
+ }
252
+
253
+ /**
254
+ * Output paths are compared without case on every platform: the archive may be installed on a
255
+ * case-insensitive filesystem (Windows, macOS), where `Editor.js` and `editor.js` are one file.
256
+ * @param {string} output Bundle path relative to `dist/`.
257
+ * @returns {string}
258
+ */
259
+ function outputPathKey(output) {
260
+ return displayOutputPath(output).toLowerCase();
156
261
  }
157
262
 
263
+ /**
264
+ * Checks whether a configuration value is an array of strings.
265
+ * @param {unknown} value
266
+ * @returns {value is string[]}
267
+ */
158
268
  function isStringArray(value) {
159
269
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
160
270
  }
161
271
 
272
+ /**
273
+ * Rejects a configuration entry that is not an array of script paths.
274
+ * @param {unknown} value
275
+ * @param {string} key
276
+ * @param {string} configPath
277
+ * @returns {asserts value is string[]}
278
+ */
279
+ function assertScriptArray(value, key, configPath) {
280
+ if (!isStringArray(value)) {
281
+ throw new PluginScriptsError(`${configPath} "${key}" must be an array of paths relative to src/.`);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Checks whether a configuration value is a non-null, non-array object.
287
+ * @param {unknown} value
288
+ * @returns {value is Record<string, unknown>}
289
+ */
162
290
  function isPlainObject(value) {
163
291
  return typeof value === 'object' && value !== null && !Array.isArray(value);
164
292
  }
165
293
 
294
+ /**
295
+ * Rejects malformed script paths, scripts that build to the main bundle's `dist/index.js`,
296
+ * and scripts that build to the same output path.
297
+ * @param {string} configPath Configuration file path used in error messages.
298
+ * @param {string[]} extraScripts Content-script paths relative to `src/`.
299
+ * @param {string[]} webviewScripts Webview-script paths relative to `src/`.
300
+ */
301
+ function assertValidScripts(configPath, extraScripts, webviewScripts) {
302
+ // Report a malformed path as such, before normalization can make it look like a collision.
303
+ for (const script of [...extraScripts, ...webviewScripts]) assertValidScriptPath(script);
304
+ const mainOutput = outputPathKey('index.js');
305
+ const seen = new Set();
306
+ for (const output of scriptOutputPaths(extraScripts, webviewScripts)) {
307
+ const key = outputPathKey(output);
308
+ if (key === mainOutput) {
309
+ throw new PluginScriptsError(
310
+ `${configPath} lists a script that builds to dist/index.js, which is reserved for the main bundle.`
311
+ );
312
+ }
313
+ if (seen.has(key)) {
314
+ throw new PluginScriptsError(
315
+ `${configPath} lists more than one script that builds to dist/${displayOutputPath(output)}.`
316
+ );
317
+ }
318
+ seen.add(key);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Rejects build overrides that are not an object, set an option the package owns, or have
324
+ * `define` or `external` values the build cannot merge with its defaults.
325
+ * @param {unknown} options
326
+ * @param {keyof typeof BUILD_OPTION_KEYS} key
327
+ * @param {string} configPath
328
+ * @returns {asserts options is import('esbuild').BuildOptions}
329
+ */
330
+ function assertBuildOptions(options, key, configPath) {
331
+ if (!isPlainObject(options)) {
332
+ throw new PluginScriptsError(`${configPath} "${key}" must be an object of esbuild options.`);
333
+ }
334
+ const reserved = BUILD_OPTION_KEYS[key].filter((option) => option in options);
335
+ if (reserved.length > 0) {
336
+ throw new PluginScriptsError(`${configPath} "${key}" cannot set ${reserved.join(', ')}.`);
337
+ }
338
+ const { define, external } = options;
339
+ if (
340
+ define !== undefined &&
341
+ !(isPlainObject(define) && Object.values(define).every((value) => typeof value === 'string'))
342
+ ) {
343
+ throw new PluginScriptsError(`${configPath} "${key}.define" must be an object of string replacements.`);
344
+ }
345
+ if (external !== undefined && !isStringArray(external)) {
346
+ throw new PluginScriptsError(`${configPath} "${key}.external" must be an array of module names.`);
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Validated contents of `plugin.config.json`, with defaults for omitted keys.
352
+ * @typedef {object} PluginConfig
353
+ * @property {string[]} extraScripts Content-script paths relative to `src/`.
354
+ * @property {string[]} webviewScripts Webview-script paths relative to `src/`.
355
+ * @property {import('esbuild').BuildOptions} mainBuildOptions Overrides for the main bundle.
356
+ * @property {import('esbuild').BuildOptions} contentScriptBuildOptions Overrides for every content script.
357
+ * @property {import('esbuild').BuildOptions} webviewScriptBuildOptions Overrides for every webview script.
358
+ */
359
+
166
360
  /**
167
361
  * Reads and validates `<root>/plugin.config.json`. The file is optional; a plugin without one
168
- * has no extra or webview scripts and uses the default main build options.
169
- * @returns {Promise<{ extraScripts: string[], webviewScripts: string[], mainBuildOptions: import('esbuild').BuildOptions }>}
362
+ * has no extra or webview scripts and uses the default build options.
363
+ * @param {string} root Plugin project root.
364
+ * @returns {Promise<PluginConfig>}
170
365
  */
171
366
  export async function readPluginConfig(root) {
172
367
  const configPath = path.join(root, CONFIG_FILE);
@@ -174,34 +369,29 @@ export async function readPluginConfig(root) {
174
369
  try {
175
370
  config = JSON.parse(await readFile(configPath, 'utf8'));
176
371
  } catch (error) {
177
- if (error.code === 'ENOENT') return { extraScripts: [], webviewScripts: [], mainBuildOptions: {} };
178
- throw new Error(`Could not read ${configPath}: ${error.message}`, { cause: error });
372
+ if (!hasErrorCode(error, 'ENOENT')) {
373
+ throw new PluginScriptsError(`Could not read ${configPath}: ${errorMessage(error)}`, { cause: error });
374
+ }
375
+ config = {};
179
376
  }
180
- if (!isPlainObject(config)) throw new Error(`${configPath} must contain a JSON object.`);
181
- const { extraScripts = [], webviewScripts = [], mainBuildOptions = {}, ...unknown } = config;
377
+ if (!isPlainObject(config)) throw new PluginScriptsError(`${configPath} must contain a JSON object.`);
378
+ const {
379
+ extraScripts = [],
380
+ webviewScripts = [],
381
+ mainBuildOptions = {},
382
+ contentScriptBuildOptions = {},
383
+ webviewScriptBuildOptions = {},
384
+ ...unknown
385
+ } = config;
182
386
  const unknownKeys = Object.keys(unknown);
183
387
  if (unknownKeys.length > 0) {
184
- throw new Error(`${configPath} has unknown keys: ${unknownKeys.join(', ')}.`);
185
- }
186
- for (const [key, value] of [
187
- ['extraScripts', extraScripts],
188
- ['webviewScripts', webviewScripts],
189
- ]) {
190
- if (!isStringArray(value)) {
191
- throw new Error(`${configPath} "${key}" must be an array of paths relative to src/.`);
192
- }
193
- }
194
- const outputs = scriptOutputPaths(extraScripts, webviewScripts);
195
- const duplicate = outputs.find((output, index) => outputs.indexOf(output) !== index);
196
- if (duplicate !== undefined) {
197
- throw new Error(`${configPath} lists more than one script that builds to dist/${duplicate}.`);
198
- }
199
- if (!isPlainObject(mainBuildOptions)) {
200
- throw new Error(`${configPath} "mainBuildOptions" must be an object of esbuild options.`);
201
- }
202
- const reserved = RESERVED_BUILD_OPTIONS.filter((key) => key in mainBuildOptions);
203
- if (reserved.length > 0) {
204
- throw new Error(`${configPath} "mainBuildOptions" cannot set ${reserved.join(', ')}.`);
388
+ throw new PluginScriptsError(`${configPath} has unknown keys: ${unknownKeys.join(', ')}.`);
205
389
  }
206
- return { extraScripts, webviewScripts, mainBuildOptions };
390
+ assertScriptArray(extraScripts, 'extraScripts', configPath);
391
+ assertScriptArray(webviewScripts, 'webviewScripts', configPath);
392
+ assertValidScripts(configPath, extraScripts, webviewScripts);
393
+ assertBuildOptions(mainBuildOptions, 'mainBuildOptions', configPath);
394
+ assertBuildOptions(contentScriptBuildOptions, 'contentScriptBuildOptions', configPath);
395
+ assertBuildOptions(webviewScriptBuildOptions, 'webviewScriptBuildOptions', configPath);
396
+ return { extraScripts, webviewScripts, mainBuildOptions, contentScriptBuildOptions, webviewScriptBuildOptions };
207
397
  }
package/lib/errors.mjs ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Checks whether a caught value is an error with the given Node.js error code, e.g. `ENOENT`.
3
+ * @param {unknown} error
4
+ * @param {string} code
5
+ * @returns {boolean}
6
+ */
7
+ export function hasErrorCode(error, code) {
8
+ return error instanceof Error && 'code' in error && error.code === code;
9
+ }
10
+
11
+ /**
12
+ * Returns a caught value's message for use in a wrapping error or warning.
13
+ * @param {unknown} error
14
+ * @returns {string}
15
+ */
16
+ export function errorMessage(error) {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+
20
+ /**
21
+ * An expected failure caused by the plugin project or its environment, such as invalid
22
+ * configuration or out-of-sync versions. The CLI prints only its message, without a stack trace;
23
+ * any other error is treated as a bug and shown in full.
24
+ */
25
+ export class PluginScriptsError extends Error {
26
+ name = 'PluginScriptsError';
27
+ }
package/lib/index.mjs CHANGED
@@ -6,3 +6,4 @@ export {
6
6
  createWebviewScriptBuildOptions,
7
7
  readPluginConfig,
8
8
  } from './buildConfig.mjs';
9
+ export { PluginScriptsError } from './errors.mjs';