@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/lib/npm.mjs ADDED
@@ -0,0 +1,58 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { hasErrorCode, PluginScriptsError } from './errors.mjs';
6
+
7
+ const NPM_NOT_FOUND = 'Could not find npm. Install npm and ensure its directory is on PATH, then retry update-api.';
8
+
9
+ /**
10
+ * Finds npm without treating another package manager's npm_execpath as npm.
11
+ * Windows uses npm.exe or npm-cli.js directly to avoid a shell for npm.cmd.
12
+ * @param {{ platform?: string, npmExecPath?: string, nodePath?: string, searchPath?: string }} [options]
13
+ * @returns {{ file: string, args: string[] }}
14
+ */
15
+ export function resolveNpmCommand({
16
+ platform = process.platform,
17
+ npmExecPath = process.env.npm_execpath,
18
+ nodePath = process.execPath,
19
+ searchPath = process.env.PATH ?? '',
20
+ } = {}) {
21
+ if (npmExecPath && path.basename(npmExecPath) === 'npm-cli.js' && isFile(npmExecPath)) {
22
+ return { file: nodePath, args: [npmExecPath] };
23
+ }
24
+ if (platform !== 'win32') return { file: 'npm', args: [] };
25
+
26
+ const directories = [...searchPath.split(path.delimiter).filter(Boolean), path.dirname(nodePath)];
27
+ for (const directory of directories) {
28
+ const npmExe = path.join(directory, 'npm.exe');
29
+ if (isFile(npmExe)) return { file: npmExe, args: [] };
30
+ const npmCli = path.join(directory, 'node_modules', 'npm', 'bin', 'npm-cli.js');
31
+ if (isFile(npmCli)) return { file: nodePath, args: [npmCli] };
32
+ }
33
+ throw new PluginScriptsError(NPM_NOT_FOUND);
34
+ }
35
+
36
+ /** @param {string} file */
37
+ function isFile(file) {
38
+ try {
39
+ return statSync(file).isFile();
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Runs npm with fixed arguments, without a shell.
47
+ * @param {string[]} args
48
+ * @param {string} cwd
49
+ */
50
+ export function runNpm(args, cwd) {
51
+ const command = resolveNpmCommand();
52
+ try {
53
+ execFileSync(command.file, [...command.args, ...args], { cwd, stdio: 'pipe' });
54
+ } catch (error) {
55
+ if (hasErrorCode(error, 'ENOENT')) throw new PluginScriptsError(NPM_NOT_FOUND);
56
+ throw error;
57
+ }
58
+ }
@@ -4,6 +4,25 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { create } from 'tar';
6
6
 
7
+ import { errorMessage, hasErrorCode, PluginScriptsError } from './errors.mjs';
8
+
9
+ /**
10
+ * Package metadata used by the build and version commands. Other fields are preserved.
11
+ * @typedef {object} PackageMetadata
12
+ * @property {string} name
13
+ * @property {string} version
14
+ * @property {string[]} [keywords]
15
+ */
16
+
17
+ /**
18
+ * Plugin manifest fields used by validation and packaging. Other fields are preserved.
19
+ * @typedef {object} PluginManifest
20
+ * @property {string} id
21
+ * @property {string} version
22
+ * @property {string[]} [categories]
23
+ * @property {{ src: string }[]} [screenshots] Local or remote screenshots.
24
+ */
25
+
7
26
  /**
8
27
  * Plugin repository categories accepted in `manifest.categories`.
9
28
  * Mirrors generator-joplin's list; `update-api` warns when they differ.
@@ -34,46 +53,100 @@ const REMOTE_URL_PATTERN = /^https?:\/\//;
34
53
  /** Matches a valid plugin ID, e.g. `com.example.my-plugin` (letters, digits, `_`, `.`, `-`). */
35
54
  const PLUGIN_ID_PATTERN = /^[\w.-]+$/;
36
55
 
56
+ /**
57
+ * Reads and parses a project JSON file, explaining a missing or malformed file without a stack trace.
58
+ * @param {string} root Plugin project root.
59
+ * @param {string} relativePath Path relative to `root`, e.g. `src/manifest.json`.
60
+ * @returns {Promise<any>}
61
+ */
62
+ async function readProjectJson(root, relativePath) {
63
+ const filePath = path.join(root, relativePath);
64
+ let text;
65
+ try {
66
+ text = await readFile(filePath, 'utf8');
67
+ } catch (error) {
68
+ if (!hasErrorCode(error, 'ENOENT')) throw error;
69
+ throw new PluginScriptsError(`${filePath} not found; run this command from the plugin project root.`, {
70
+ cause: error,
71
+ });
72
+ }
73
+ try {
74
+ return JSON.parse(text);
75
+ } catch (error) {
76
+ throw new PluginScriptsError(`${filePath} is not valid JSON: ${errorMessage(error)}`, { cause: error });
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Rejects a manifest screenshot without `src`, or a local one that is missing, of an unsupported
82
+ * type, or too large. Remote screenshots are not checked.
83
+ * @param {string} root Plugin project root.
84
+ * @param {{ src: string }} screenshot
85
+ */
86
+ async function assertValidScreenshot(root, screenshot) {
87
+ if (!screenshot.src) throw new PluginScriptsError('Manifest screenshot must specify src.');
88
+ if (REMOTE_URL_PATTERN.test(screenshot.src)) return;
89
+ if (!SCREENSHOT_EXTENSIONS.has(path.extname(screenshot.src))) {
90
+ throw new PluginScriptsError(`Unsupported screenshot type: ${screenshot.src}`);
91
+ }
92
+ let size;
93
+ try {
94
+ ({ size } = await stat(path.join(root, screenshot.src)));
95
+ } catch (error) {
96
+ if (!hasErrorCode(error, 'ENOENT')) throw error;
97
+ throw new PluginScriptsError(`Screenshot not found: ${screenshot.src}`, { cause: error });
98
+ }
99
+ if (size > MAX_SCREENSHOT_BYTES) {
100
+ throw new PluginScriptsError(
101
+ `Screenshot exceeds ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB: ${screenshot.src}`
102
+ );
103
+ }
104
+ }
105
+
37
106
  /**
38
107
  * Reads `package.json` and `src/manifest.json` from `root` and validates the fields the plugin
39
108
  * repository depends on: plugin ID, matching versions, package name and keyword, categories, and
40
109
  * local screenshots.
110
+ * @param {string} root Plugin project root.
111
+ * @returns {Promise<{ packageJson: PackageMetadata, manifest: PluginManifest }>}
41
112
  */
42
113
  export async function readProject(root) {
43
- const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
44
- const manifest = JSON.parse(await readFile(path.join(root, 'src/manifest.json'), 'utf8'));
114
+ /** @type {PackageMetadata} */
115
+ const packageJson = await readProjectJson(root, 'package.json');
116
+ /** @type {PluginManifest} */
117
+ const manifest = await readProjectJson(root, 'src/manifest.json');
45
118
  if (!manifest.id || !PLUGIN_ID_PATTERN.test(manifest.id)) {
46
- throw new Error('Manifest plugin ID must contain only letters, digits, underscores, dots, or hyphens.');
119
+ throw new PluginScriptsError(
120
+ 'Manifest plugin ID must contain only letters, digits, underscores, dots, or hyphens.'
121
+ );
47
122
  }
48
123
  if (manifest.version !== packageJson.version) {
49
- throw new Error('package.json and src/manifest.json versions must match.');
124
+ throw new PluginScriptsError('package.json and src/manifest.json versions must match.');
50
125
  }
51
126
  if (!packageJson.name?.startsWith('joplin-plugin-') || !packageJson.keywords?.includes('joplin-plugin')) {
52
- throw new Error('Package name must start with joplin-plugin- and keywords must include joplin-plugin.');
127
+ throw new PluginScriptsError(
128
+ 'Package name must start with joplin-plugin- and keywords must include joplin-plugin.'
129
+ );
53
130
  }
54
131
  const selectedCategories = manifest.categories ?? [];
55
132
  if (
56
133
  new Set(selectedCategories).size !== selectedCategories.length ||
57
134
  selectedCategories.some((category) => !VALID_CATEGORIES.has(category))
58
135
  ) {
59
- throw new Error('Manifest contains invalid or repeated categories.');
60
- }
61
- for (const screenshot of manifest.screenshots ?? []) {
62
- if (!screenshot.src) throw new Error('Manifest screenshot must specify src.');
63
- if (REMOTE_URL_PATTERN.test(screenshot.src)) continue;
64
- if (!SCREENSHOT_EXTENSIONS.has(path.extname(screenshot.src))) {
65
- throw new Error(`Unsupported screenshot type: ${screenshot.src}`);
66
- }
67
- if ((await stat(path.join(root, screenshot.src))).size > MAX_SCREENSHOT_BYTES) {
68
- throw new Error(`Screenshot exceeds ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB: ${screenshot.src}`);
69
- }
136
+ throw new PluginScriptsError('Manifest contains invalid or repeated categories.');
70
137
  }
138
+ for (const screenshot of manifest.screenshots ?? []) await assertValidScreenshot(root, screenshot);
71
139
  return { packageJson, manifest };
72
140
  }
73
141
 
74
142
  /** Branch recorded in `_publish_commit` when the build runs from a detached HEAD. */
75
143
  const DEFAULT_BRANCH = 'main';
76
144
 
145
+ /**
146
+ * Reads the branch and commit for publish metadata, or an empty string outside Git.
147
+ * @param {string} root Plugin project root.
148
+ * @returns {string}
149
+ */
77
150
  function currentGitInfo(root) {
78
151
  try {
79
152
  // Git is intentionally resolved from the developer's PATH, with fixed arguments and no shell.
@@ -90,7 +163,12 @@ function currentGitInfo(root) {
90
163
  }
91
164
  }
92
165
 
93
- /** Lists files under `directory`, skipping dotfiles and dot-directories (e.g. `.DS_Store`, `.gitkeep`). */
166
+ /**
167
+ * Lists files under `directory`, skipping dotfiles and dot-directories (e.g. `.DS_Store`, `.gitkeep`).
168
+ * @param {string} directory
169
+ * @param {string} [prefix] Relative parent path, including its trailing slash.
170
+ * @returns {Promise<string[]>} Sorted file paths with forward slashes and the supplied prefix.
171
+ */
94
172
  async function listFiles(directory, prefix = '') {
95
173
  const files = [];
96
174
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -108,6 +186,9 @@ async function listFiles(directory, prefix = '') {
108
186
  /**
109
187
  * Archives `<root>/dist` into `<root>/publish/<id>.jpl` and writes `<id>.json` publish metadata.
110
188
  * `extraScripts` are bundle paths relative to `dist/` that must exist before packaging.
189
+ * @param {string} root Plugin project root.
190
+ * @param {PluginManifest} manifest Validated plugin manifest, including its plugin ID.
191
+ * @param {string[]} [extraScripts] Required bundle paths relative to `dist/`.
111
192
  */
112
193
  export async function packagePlugin(root, manifest, extraScripts = []) {
113
194
  const dist = path.join(root, 'dist');
package/lib/updateApi.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { cp, mkdtemp, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
3
2
  import { tmpdir } from 'node:os';
4
3
  import path from 'node:path';
5
4
  import { extract } from 'tar';
6
5
 
7
6
  import { contentScriptExternals } from './buildConfig.mjs';
7
+ import { errorMessage, hasErrorCode } from './errors.mjs';
8
+ import { runNpm } from './npm.mjs';
8
9
  import { VALID_CATEGORIES } from './packagePlugin.mjs';
9
10
 
10
11
  const templates = 'package/generators/app/templates';
@@ -31,26 +32,30 @@ const QUOTED_STRING_PATTERN = /["']([^"']+)["']/g;
31
32
  const LIST_SOURCE = '@bwat47/joplin-plugin-scripts';
32
33
 
33
34
  /**
34
- * Runs npm with fixed arguments. Under `npm run`, npm_execpath points at npm's CLI script,
35
- * which avoids spawning npm.cmd through a shell on Windows.
35
+ * Copies a source directory into a destination directory.
36
+ * @typedef {(from: string, to: string) => Promise<void>} CopyDirectory
36
37
  */
37
- function npm(args, cwd) {
38
- const npmCli = process.env.npm_execpath;
39
- if (npmCli?.endsWith('.js')) {
40
- execFileSync(process.execPath, [npmCli, ...args], { cwd, stdio: 'pipe' });
41
- } else if (process.platform === 'win32') {
42
- throw new Error('Run update-api through an npm script, e.g. npm run updateApi');
43
- } else {
44
- // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer npm installation; arguments are passed without a shell.
45
- execFileSync('npm', args, { cwd, stdio: 'pipe' });
46
- }
47
- }
48
38
 
39
+ /**
40
+ * Extracts string entries from a generator configuration list.
41
+ * @param {string} text
42
+ * @param {RegExp} listPattern Pattern capturing the list contents.
43
+ * @param {RegExp} itemPattern Global pattern capturing each entry.
44
+ * @returns {string[] | undefined}
45
+ */
49
46
  function extractList(text, listPattern, itemPattern) {
50
47
  const list = listPattern.exec(text)?.[1];
51
48
  return list === undefined ? undefined : [...list.matchAll(itemPattern)].map((match) => match[1]);
52
49
  }
53
50
 
51
+ /**
52
+ * Describes missing or unexpected entries in a maintained list.
53
+ * @param {string} label List name for warning messages.
54
+ * @param {string[] | undefined} generatorItems Entries found in the generator, or `undefined` if the list was not found.
55
+ * @param {Iterable<string>} localItems Locally maintained entries.
56
+ * @param {string} location Source location to update.
57
+ * @returns {string | undefined}
58
+ */
54
59
  function describeListDifference(label, generatorItems, localItems, location) {
55
60
  if (generatorItems === undefined) {
56
61
  return `Could not find the ${label} in generator-joplin's webpack.config.js; compare ${location} manually.`;
@@ -71,6 +76,7 @@ function describeListDifference(label, generatorItems, localItems, location) {
71
76
  * Compares the hand-maintained category and content-script external lists with the ones in
72
77
  * generator-joplin's `webpack.config.js` text. Returns one warning per list that differs or
73
78
  * cannot be found; an empty array means both match.
79
+ * @param {string} webpackConfig
74
80
  * @returns {string[]}
75
81
  */
76
82
  export function compareGeneratorLists(webpackConfig) {
@@ -93,6 +99,9 @@ export function compareGeneratorLists(webpackConfig) {
93
99
  /**
94
100
  * Replaces `<root>/api` with a copy of `source`. The copy is staged next to `api/` and swapped in
95
101
  * by rename, so a failed copy leaves the existing `api/` untouched. `copy` defaults to a recursive copy.
102
+ * @param {string} root Plugin project root.
103
+ * @param {string} source
104
+ * @param {CopyDirectory} [copy] Copies the API into the staging directory.
96
105
  */
97
106
  export async function replaceApi(root, source, copy = (from, to) => cp(from, to, { recursive: true })) {
98
107
  // Fail before changing anything if the generator layout has changed.
@@ -106,7 +115,7 @@ export async function replaceApi(root, source, copy = (from, to) => cp(from, to,
106
115
  try {
107
116
  await rename(destination, backup);
108
117
  } catch (error) {
109
- if (error.code !== 'ENOENT') throw error;
118
+ if (!hasErrorCode(error, 'ENOENT')) throw error;
110
119
  hadPrevious = false;
111
120
  }
112
121
  try {
@@ -121,12 +130,17 @@ export async function replaceApi(root, source, copy = (from, to) => cp(from, to,
121
130
  }
122
131
  }
123
132
 
133
+ /**
134
+ * Reads the generator configuration and reports maintained-list differences.
135
+ * @param {string} webpackConfigPath
136
+ * @returns {Promise<string[]>}
137
+ */
124
138
  async function readGeneratorWarnings(webpackConfigPath) {
125
139
  let webpackConfig;
126
140
  try {
127
141
  webpackConfig = await readFile(webpackConfigPath, 'utf8');
128
142
  } catch (error) {
129
- return [`Could not read ${webpackConfigPath} (${error.message}); compare the build lists manually.`];
143
+ return [`Could not read ${webpackConfigPath} (${errorMessage(error)}); compare the build lists manually.`];
130
144
  }
131
145
  return compareGeneratorLists(webpackConfig);
132
146
  }
@@ -135,12 +149,14 @@ async function readGeneratorWarnings(webpackConfigPath) {
135
149
  * Replaces `<root>/api` with the API declarations from the latest generator-joplin package.
136
150
  * Nothing else in the project is touched. Returns the generator version used and warnings for
137
151
  * hand-maintained build lists that no longer match the generator.
152
+ * @param {string} root Plugin project root.
153
+ * @param {string} [packageSpec] npm package specification for the generator to download.
138
154
  * @returns {Promise<{ version: string, warnings: string[] }>}
139
155
  */
140
156
  export async function updateApi(root, packageSpec = 'generator-joplin@latest') {
141
157
  const temp = await mkdtemp(path.join(tmpdir(), 'joplin-api-'));
142
158
  try {
143
- npm(['pack', packageSpec, '--pack-destination', temp], temp);
159
+ runNpm(['pack', packageSpec, '--pack-destination', temp], temp);
144
160
  // The --json output shape differs between npm versions, so locate the tarball instead.
145
161
  const tarball = (await readdir(temp)).find((name) => name.endsWith('.tgz'));
146
162
  if (!tarball) throw new Error(`npm pack did not produce a tarball for ${packageSpec}`);
@@ -1,14 +1,39 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { PluginScriptsError } from './errors.mjs';
4
5
  import { readProject } from './packagePlugin.mjs';
5
6
 
6
7
  /** Matches a `major.minor.patch` version, e.g. `1.14.2`. */
7
8
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
8
9
 
10
+ /**
11
+ * Captures the newline and indent after a JSON file's opening brace, e.g. `{\r\n "name"` captures
12
+ * `\r\n` and two spaces. This is how npm detects a lockfile's formatting.
13
+ */
14
+ const JSON_FORMAT_PATTERN = /^\s*[{[]((?:\r?\n)+)([ \t]*)/;
15
+
16
+ /**
17
+ * Serializes `value` in the formatting of `original`, as npm does when it rewrites a lockfile:
18
+ * the same indent and newline style, ending with one newline. Text without a newline after its
19
+ * opening brace (e.g. minified JSON) is written on one line, without a trailing newline.
20
+ * @param {unknown} value
21
+ * @param {string} original Text of the file being rewritten.
22
+ * @returns {string}
23
+ */
24
+ function stringifyLike(value, original) {
25
+ const [, newlines = '', indent = ''] = JSON_FORMAT_PATTERN.exec(original) ?? [];
26
+ const newline = newlines.startsWith('\r\n') ? '\r\n' : newlines.slice(0, 1);
27
+ return `${JSON.stringify(value, null, indent)}\n`.replaceAll('\n', newline);
28
+ }
29
+
9
30
  /**
10
31
  * Returns the text of a hand-formatted JSON file with its single `"version": "<from>"` entry
11
32
  * replaced, leaving every other byte untouched so a version bump produces a one-line diff.
33
+ * @param {string} filePath
34
+ * @param {string} from Current version in major.minor.patch format.
35
+ * @param {string} to Replacement version in major.minor.patch format.
36
+ * @returns {Promise<string>}
12
37
  */
13
38
  async function replaceVersion(filePath, from, to) {
14
39
  const text = await readFile(filePath, 'utf8');
@@ -16,7 +41,7 @@ async function replaceVersion(filePath, from, to) {
16
41
  const pattern = new RegExp(`("version"\\s*:\\s*")${from.replaceAll('.', '\\.')}(")`, 'g');
17
42
  const matches = text.match(pattern)?.length ?? 0;
18
43
  if (matches !== 1) {
19
- throw new Error(`Expected one "version": "${from}" entry in ${filePath}, found ${matches}.`);
44
+ throw new PluginScriptsError(`Expected one "version": "${from}" entry in ${filePath}, found ${matches}.`);
20
45
  }
21
46
  return text.replace(pattern, `$1${to}$2`);
22
47
  }
@@ -24,25 +49,29 @@ async function replaceVersion(filePath, from, to) {
24
49
  /**
25
50
  * Increments the patch version in `package.json`, `src/manifest.json`, and `package-lock.json`
26
51
  * together. Writes nothing unless all three files can be updated.
52
+ * @param {string} root Plugin project root.
27
53
  * @returns {Promise<string>} The new version.
28
54
  */
29
55
  export async function updateVersion(root) {
30
56
  const { packageJson } = await readProject(root);
31
57
  const current = packageJson.version;
32
58
  if (!SEMVER_PATTERN.test(current)) {
33
- throw new Error('update-version requires a version in major.minor.patch format.');
59
+ throw new PluginScriptsError('update-version requires a version in major.minor.patch format.');
34
60
  }
35
61
  const [major, minor, patch] = current.split('.').map(Number);
36
62
  const version = `${major}.${minor}.${patch + 1}`;
37
63
 
38
64
  const lockPath = path.join(root, 'package-lock.json');
39
- const lock = JSON.parse(await readFile(lockPath, 'utf8'));
65
+ const lockText = await readFile(lockPath, 'utf8');
66
+ const lock = JSON.parse(lockText);
40
67
  const lockRoot = lock.packages?.[''];
41
68
  if (!lockRoot) {
42
- throw new Error(`${lockPath} has no root package entry (packages[""]); regenerate it with npm install.`);
69
+ throw new PluginScriptsError(
70
+ `${lockPath} has no root package entry (packages[""]); regenerate it with npm install.`
71
+ );
43
72
  }
44
73
  if (lock.version !== current || lockRoot.version !== current) {
45
- throw new Error(
74
+ throw new PluginScriptsError(
46
75
  `${lockPath} versions (${lock.version}, ${lockRoot.version}) do not match package.json (${current}); ` +
47
76
  'run npm install to resync the lockfile.'
48
77
  );
@@ -56,8 +85,9 @@ export async function updateVersion(root) {
56
85
  const updates = [
57
86
  [packagePath, await replaceVersion(packagePath, current, version)],
58
87
  [manifestPath, await replaceVersion(manifestPath, current, version)],
59
- // npm owns the lockfile format, which is JSON.stringify output; dependencies may share the version string.
60
- [lockPath, `${JSON.stringify(lock, null, 4)}\n`],
88
+ // npm owns the lockfile format, which is JSON.stringify output in the file's detected indent and
89
+ // newline style; it is reserialized rather than patched because dependencies may share the version string.
90
+ [lockPath, stringifyLike(lock, lockText)],
61
91
  ];
62
92
  for (const [filePath, text] of updates) await writeFile(filePath, text);
63
93
  return version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bwat47/joplin-plugin-scripts",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shared esbuild build, packaging, version, and API-update scripts for Joplin plugins",
5
5
  "license": "MIT",
6
6
  "author": "bwat47",
@@ -16,19 +16,26 @@
16
16
  "joplin-plugin-scripts": "bin/joplin-plugin-scripts.mjs"
17
17
  },
18
18
  "exports": {
19
- ".": "./lib/index.mjs"
19
+ ".": {
20
+ "types": "./types/index.d.mts",
21
+ "default": "./lib/index.mjs"
22
+ }
20
23
  },
21
24
  "files": [
22
25
  "bin",
23
- "lib"
26
+ "lib",
27
+ "types"
24
28
  ],
25
29
  "scripts": {
26
30
  "test": "node --test test/*.test.mjs",
31
+ "typecheck": "tsc --project tsconfig.json",
32
+ "build:types": "node -e \"require('node:fs').rmSync('types', { recursive: true, force: true })\" && tsc --project tsconfig.build.json",
33
+ "prepack": "npm run build:types",
27
34
  "lint": "eslint .",
28
35
  "lint:fix": "eslint . --fix",
29
36
  "format": "prettier --write .",
30
37
  "format:check": "prettier --check .",
31
- "prepublishOnly": "npm run lint && npm run format:check && npm test"
38
+ "prepublishOnly": "npm run typecheck && npm run lint && npm run format:check && npm test"
32
39
  },
33
40
  "dependencies": {
34
41
  "esbuild": "^0.28.2",
@@ -37,10 +44,12 @@
37
44
  "devDependencies": {
38
45
  "@eslint/js": "^10.0.1",
39
46
  "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
47
+ "@types/node": "^22.20.5",
40
48
  "eslint": "^10.14.0",
41
49
  "eslint-config-prettier": "^10.1.8",
42
50
  "eslint-plugin-sonarjs": "^4.2.2",
43
51
  "globals": "^17.14.0",
44
- "prettier": "^3.9.10"
52
+ "prettier": "^3.9.10",
53
+ "typescript": "^6.0.3"
45
54
  }
46
55
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Builds the plugin at `root`: validates the project, clears `dist/` and `publish/`, copies runtime
3
+ * assets from `src/`, bundles the main entry point, extra scripts, and webview scripts, and creates
4
+ * the `.jpl` archive.
5
+ * @param {string} root Plugin project root.
6
+ */
7
+ export function buildPlugin(root: string): Promise<void>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Excludes bundled sources, tests, and development fixtures in `<root>/src` from copied runtime assets.
3
+ * @param {string} root Plugin project root.
4
+ * @param {string} source
5
+ * @returns {boolean}
6
+ */
7
+ export function isRuntimeAsset(root: string, source: string): boolean;
8
+ /**
9
+ * Returns the esbuild options for the main plugin bundle, `src/index.ts` to `dist/index.js`.
10
+ * `mainBuildOptions` from the plugin config are merged over the defaults.
11
+ * `define` entries are merged individually so unrelated definitions preserve the defaults.
12
+ * @param {string} root Plugin project root.
13
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `mainBuildOptions`.
14
+ * @returns {import('esbuild').BuildOptions}
15
+ */
16
+ export function createBuildOptions(root: string, overrides?: import("esbuild").BuildOptions): import("esbuild").BuildOptions;
17
+ /**
18
+ * Returns the esbuild options for one content script, e.g. `contentScripts/codeMirror.ts`
19
+ * (relative to `src/`) to `dist/contentScripts/codeMirror.js`. `overrides` are merged over the
20
+ * defaults; their `external` entries are added to the host libraries, which always stay external.
21
+ * @param {string} root Plugin project root.
22
+ * @param {string} script Script path relative to `src/`.
23
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `contentScriptBuildOptions`.
24
+ * @returns {import('esbuild').BuildOptions}
25
+ */
26
+ export function createContentScriptBuildOptions(root: string, script: string, overrides?: import("esbuild").BuildOptions): import("esbuild").BuildOptions;
27
+ /**
28
+ * Returns the esbuild options for one webview script, e.g. `dialog/resizeDialog.ts` (relative to
29
+ * `src/`) to `dist/dialog/resizeDialog.js`. Webview scripts are loaded by a `<script>` tag in a
30
+ * dialog or panel, which has no `module`, `exports`, or `require`, so they are bundled as a
31
+ * self-contained IIFE. Their exports are dropped; the script runs for its side effects.
32
+ * @param {string} root Plugin project root.
33
+ * @param {string} script Script path relative to `src/`.
34
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `webviewScriptBuildOptions`.
35
+ * @returns {import('esbuild').BuildOptions}
36
+ */
37
+ export function createWebviewScriptBuildOptions(root: string, script: string, overrides?: import("esbuild").BuildOptions): import("esbuild").BuildOptions;
38
+ /**
39
+ * Returns `dist/`-relative output paths for every script the plugin config registers.
40
+ * @param {string[]} extraScripts Content-script paths relative to `src/`.
41
+ * @param {string[]} webviewScripts Webview-script paths relative to `src/`.
42
+ * @returns {string[]} Bundle paths relative to `dist/`, using native separators.
43
+ */
44
+ export function scriptOutputPaths(extraScripts: string[], webviewScripts: string[]): string[];
45
+ /**
46
+ * Validated contents of `plugin.config.json`, with defaults for omitted keys.
47
+ * @typedef {object} PluginConfig
48
+ * @property {string[]} extraScripts Content-script paths relative to `src/`.
49
+ * @property {string[]} webviewScripts Webview-script paths relative to `src/`.
50
+ * @property {import('esbuild').BuildOptions} mainBuildOptions Overrides for the main bundle.
51
+ * @property {import('esbuild').BuildOptions} contentScriptBuildOptions Overrides for every content script.
52
+ * @property {import('esbuild').BuildOptions} webviewScriptBuildOptions Overrides for every webview script.
53
+ */
54
+ /**
55
+ * Reads and validates `<root>/plugin.config.json`. The file is optional; a plugin without one
56
+ * has no extra or webview scripts and uses the default build options.
57
+ * @param {string} root Plugin project root.
58
+ * @returns {Promise<PluginConfig>}
59
+ */
60
+ export function readPluginConfig(root: string): Promise<PluginConfig>;
61
+ /** Name of the optional per-plugin build configuration file at the project root. */
62
+ export const CONFIG_FILE: "plugin.config.json";
63
+ export const contentScriptExternals: string[];
64
+ /**
65
+ * Validated contents of `plugin.config.json`, with defaults for omitted keys.
66
+ */
67
+ export type PluginConfig = {
68
+ /**
69
+ * Content-script paths relative to `src/`.
70
+ */
71
+ extraScripts: string[];
72
+ /**
73
+ * Webview-script paths relative to `src/`.
74
+ */
75
+ webviewScripts: string[];
76
+ /**
77
+ * Overrides for the main bundle.
78
+ */
79
+ mainBuildOptions: import("esbuild").BuildOptions;
80
+ /**
81
+ * Overrides for every content script.
82
+ */
83
+ contentScriptBuildOptions: import("esbuild").BuildOptions;
84
+ /**
85
+ * Overrides for every webview script.
86
+ */
87
+ webviewScriptBuildOptions: import("esbuild").BuildOptions;
88
+ };
@@ -0,0 +1,20 @@
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: unknown, code: string): boolean;
8
+ /**
9
+ * Returns a caught value's message for use in a wrapping error or warning.
10
+ * @param {unknown} error
11
+ * @returns {string}
12
+ */
13
+ export function errorMessage(error: unknown): string;
14
+ /**
15
+ * An expected failure caused by the plugin project or its environment, such as invalid
16
+ * configuration or out-of-sync versions. The CLI prints only its message, without a stack trace;
17
+ * any other error is treated as a bug and shown in full.
18
+ */
19
+ export class PluginScriptsError extends Error {
20
+ }
@@ -0,0 +1,2 @@
1
+ export { PluginScriptsError } from "./errors.mjs";
2
+ export { contentScriptExternals, createBuildOptions, createContentScriptBuildOptions, createWebviewScriptBuildOptions, readPluginConfig } from "./buildConfig.mjs";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Finds npm without treating another package manager's npm_execpath as npm.
3
+ * Windows uses npm.exe or npm-cli.js directly to avoid a shell for npm.cmd.
4
+ * @param {{ platform?: string, npmExecPath?: string, nodePath?: string, searchPath?: string }} [options]
5
+ * @returns {{ file: string, args: string[] }}
6
+ */
7
+ export function resolveNpmCommand({ platform, npmExecPath, nodePath, searchPath, }?: {
8
+ platform?: string;
9
+ npmExecPath?: string;
10
+ nodePath?: string;
11
+ searchPath?: string;
12
+ }): {
13
+ file: string;
14
+ args: string[];
15
+ };
16
+ /**
17
+ * Runs npm with fixed arguments, without a shell.
18
+ * @param {string[]} args
19
+ * @param {string} cwd
20
+ */
21
+ export function runNpm(args: string[], cwd: string): void;