@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 +92 -18
- package/bin/joplin-plugin-scripts.mjs +10 -2
- package/lib/build.mjs +24 -4
- package/lib/buildConfig.mjs +236 -46
- package/lib/errors.mjs +27 -0
- package/lib/index.mjs +1 -0
- package/lib/npm.mjs +58 -0
- package/lib/packagePlugin.mjs +98 -17
- package/lib/updateApi.mjs +33 -17
- package/lib/updateVersion.mjs +37 -7
- package/package.json +14 -5
- package/types/build.d.mts +7 -0
- package/types/buildConfig.d.mts +88 -0
- package/types/errors.d.mts +20 -0
- package/types/index.d.mts +2 -0
- package/types/npm.d.mts +21 -0
- package/types/packagePlugin.d.mts +61 -0
- package/types/updateApi.d.mts +32 -0
- package/types/updateVersion.d.mts +7 -0
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
|
+
}
|
package/lib/packagePlugin.mjs
CHANGED
|
@@ -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
|
-
|
|
44
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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}`);
|
package/lib/updateVersion.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
60
|
-
|
|
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.
|
|
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
|
-
".":
|
|
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
|
+
}
|
package/types/npm.d.mts
ADDED
|
@@ -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;
|