@bwat47/joplin-plugin-scripts 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bwat47
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @bwat47/joplin-plugin-scripts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shared build tooling for Joplin plugins: an esbuild-based replacement for the
4
+ generator-joplin Webpack config and its scripts.
5
+
6
+ ## Usage
7
+
8
+ ```sh
9
+ npm install -D @bwat47/joplin-plugin-scripts
10
+ ```
11
+
12
+ ```json
13
+ "scripts": {
14
+ "dist": "npm run typecheck && joplin-plugin-scripts build",
15
+ "updateVersion": "joplin-plugin-scripts update-version",
16
+ "updateApi": "joplin-plugin-scripts update-api"
17
+ }
18
+ ```
19
+
20
+ Commands run from the plugin project root, which npm scripts do automatically.
21
+
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. |
27
+
28
+ ### Project layout expected
29
+
30
+ - `src/index.ts`: main entry point, bundled to `dist/index.js` for the Node platform.
31
+ `node:` imports are rewritten to bare names for Joplin's plugin runtime.
32
+ - `src/manifest.json` and `package.json`: versions must match; the package name starts
33
+ with `joplin-plugin-` and `keywords` includes `joplin-plugin`.
34
+ - `api/`: resolved through `tsconfig.json` `paths` (`api` and `api/*`).
35
+ - Other files in `src/` are copied to `dist/`, except TypeScript sources, `*.test.*` /
36
+ `*.spec.*` files, and anything under `fixtures/` or `__tests__/` directories.
37
+
38
+ ### `plugin.config.json` (optional)
39
+
40
+ ```json
41
+ {
42
+ "extraScripts": ["contentScripts/codeMirror.ts"],
43
+ "mainBuildOptions": { "external": ["jsdom"] }
44
+ }
45
+ ```
46
+
47
+ - `extraScripts`: content scripts relative to `src/`, bundled for the browser to the same
48
+ path under `dist/` with a `.js` extension. Joplin's CodeMirror and Lezer packages stay
49
+ external so they share the host's instances.
50
+ - `mainBuildOptions`: esbuild options merged over the main bundle's defaults. Relative
51
+ `alias` targets resolve from the project root. `absWorkingDir`, `entryPoints`,
52
+ `outfile`, and `outdir` cannot be overridden.
53
+
54
+ ### Testing a plugin's bundles
55
+
56
+ The package exports the same esbuild options the build uses, so plugin tests can build
57
+ in memory and run the result:
58
+
59
+ ```js
60
+ import { createBuildOptions, createContentScriptBuildOptions, readPluginConfig } from '@bwat47/joplin-plugin-scripts';
61
+
62
+ const { mainBuildOptions } = await readPluginConfig(root);
63
+ const result = await build({ ...createBuildOptions(root, mainBuildOptions), write: false });
64
+ ```
65
+
66
+ `contentScriptExternals` is also exported.
67
+
68
+ ## Development
69
+
70
+ ```sh
71
+ npm test
72
+ npm run lint
73
+ npm run format
74
+ ```
75
+
76
+ To try a change in a plugin before publishing, run `npm pack` here and
77
+ `npm install -D ../joplin-plugin-scripts/bwat47-joplin-plugin-scripts-<version>.tgz` in the plugin.
78
+
79
+ Release: bump `version`, then `npm publish --access public` (`prepublishOnly` runs lint,
80
+ format check, and tests).
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { buildPlugin } from '../lib/build.mjs';
3
+ import { updateApi } from '../lib/updateApi.mjs';
4
+ import { updateVersion } from '../lib/updateVersion.mjs';
5
+
6
+ const USAGE = `Usage: joplin-plugin-scripts <command>
7
+
8
+ Run from a Joplin plugin project root (npm scripts do this automatically).
9
+
10
+ Commands:
11
+ build Bundle the plugin with esbuild and create publish/<id>.jpl
12
+ update-version Increment the patch version in package.json, src/manifest.json, and package-lock.json
13
+ update-api Replace api/ with the declarations from the latest generator-joplin`;
14
+
15
+ const commands = {
16
+ build: async (root) => {
17
+ await buildPlugin(root);
18
+ },
19
+ 'update-version': async (root) => {
20
+ console.info(`Updated version to ${await updateVersion(root)}.`);
21
+ },
22
+ 'update-api': async (root) => {
23
+ const { version, warnings } = await updateApi(root);
24
+ console.info(`Updated api/ from generator-joplin ${version}. Review with: git diff api/`);
25
+ for (const warning of warnings) console.warn(`WARNING: ${warning}`);
26
+ },
27
+ };
28
+
29
+ const [command, ...rest] = process.argv.slice(2);
30
+ if (command === '--help' || command === '-h') {
31
+ console.info(USAGE);
32
+ process.exit(0);
33
+ }
34
+ const run = Object.hasOwn(commands, command) ? commands[command] : undefined;
35
+ if (!run || rest.length > 0) {
36
+ console.error(USAGE);
37
+ process.exit(1);
38
+ }
39
+ // Unhandled rejections print the stack and exit non-zero, as the original scripts did.
40
+ await run(process.cwd());
package/lib/build.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import { cp, mkdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { build } from 'esbuild';
4
+
5
+ import {
6
+ createBuildOptions,
7
+ createContentScriptBuildOptions,
8
+ isRuntimeAsset,
9
+ readPluginConfig,
10
+ } from './buildConfig.mjs';
11
+ import { packagePlugin, readProject } from './packagePlugin.mjs';
12
+
13
+ /**
14
+ * Builds the plugin at `root`: validates the project, clears `dist/` and `publish/`, copies runtime
15
+ * assets from `src/`, bundles the main entry point and extra scripts, and creates the `.jpl` archive.
16
+ */
17
+ export async function buildPlugin(root) {
18
+ const dist = path.join(root, 'dist');
19
+ const publish = path.join(root, 'publish');
20
+ const { manifest } = await readProject(root);
21
+ const { extraScripts, mainBuildOptions } = await readPluginConfig(root);
22
+ const extraBuilds = extraScripts.map((script) => createContentScriptBuildOptions(root, script));
23
+
24
+ // Only these fixed, project-local output directories are removed.
25
+ await rm(dist, { recursive: true, force: true });
26
+ await rm(publish, { recursive: true, force: true });
27
+ await mkdir(dist, { recursive: true });
28
+
29
+ // Copy first so a JavaScript source asset cannot overwrite a compiled bundle.
30
+ await cp(path.join(root, 'src'), dist, {
31
+ recursive: true,
32
+ filter: (source) => isRuntimeAsset(root, source),
33
+ });
34
+ await Promise.all([
35
+ build(createBuildOptions(root, mainBuildOptions)),
36
+ ...extraBuilds.map((options) => build(options)),
37
+ ]);
38
+
39
+ await packagePlugin(
40
+ root,
41
+ manifest,
42
+ extraBuilds.map((options) => path.relative(dist, path.resolve(root, options.outfile)))
43
+ );
44
+ }
@@ -0,0 +1,170 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** Name of the optional per-plugin build configuration file at the project root. */
5
+ export const CONFIG_FILE = 'plugin.config.json';
6
+
7
+ /**
8
+ * Matches TypeScript sources, which are bundled rather than copied,
9
+ * e.g. `index.ts`, `view.tsx`, `worker.mts`, `config.cts`, `types.d.ts` (not `notes.txt`).
10
+ */
11
+ const TYPESCRIPT_SOURCE_PATTERN = /\.(?:[cm]?ts|tsx)$/;
12
+
13
+ /** Matches a bundleable script extension, e.g. `a.ts`, `a.tsx`, `a.js`, `a.jsx` (not `a.json`, `a.css`). */
14
+ const SCRIPT_EXTENSION_PATTERN = /\.[jt]sx?$/;
15
+
16
+ /** Splits a path on either separator, e.g. `a/b\c` becomes `['a', 'b', 'c']`. */
17
+ const PATH_SEPARATOR_PATTERN = /[\\/]/;
18
+
19
+ /** Matches test files, e.g. `pipeline.test.ts` or `render.spec.js`. */
20
+ const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
21
+
22
+ /** Matches a Node built-in import with the `node:` prefix, e.g. `node:path`. */
23
+ const NODE_PREFIX_PATTERN = /^node:/;
24
+
25
+ /** esbuild options that the package owns and `mainBuildOptions` may not override. */
26
+ const RESERVED_BUILD_OPTIONS = ['absWorkingDir', 'entryPoints', 'outfile', 'outdir'];
27
+
28
+ /** Excludes bundled sources, tests, and development fixtures in `<root>/src` from copied runtime assets. */
29
+ export function isRuntimeAsset(root, source) {
30
+ const parts = path.relative(path.join(root, 'src'), source).split(PATH_SEPARATOR_PATTERN);
31
+ return (
32
+ !TYPESCRIPT_SOURCE_PATTERN.test(source) &&
33
+ !TEST_FILE_PATTERN.test(source) &&
34
+ !parts.some((part) => part === 'fixtures' || part === '__tests__')
35
+ );
36
+ }
37
+
38
+ // Keep the generator's explicit host-library list: these must share Joplin's instances.
39
+ // `update-api` warns when this list differs from the generator's.
40
+ export const contentScriptExternals = [
41
+ '@codemirror/view',
42
+ '@codemirror/state',
43
+ '@codemirror/search',
44
+ '@codemirror/language',
45
+ '@codemirror/autocomplete',
46
+ '@codemirror/commands',
47
+ '@codemirror/highlight',
48
+ '@codemirror/lint',
49
+ '@codemirror/lang-html',
50
+ '@codemirror/lang-markdown',
51
+ '@codemirror/language-data',
52
+ '@lezer/common',
53
+ '@lezer/markdown',
54
+ '@lezer/highlight',
55
+ ];
56
+
57
+ /**
58
+ * Rewrites Node built-in imports to their bare names, e.g. `node:path` to `path`,
59
+ * matching the generator's Webpack build for Joplin's plugin runtime.
60
+ * @type {import('esbuild').Plugin}
61
+ */
62
+ const stripNodePrefix = {
63
+ name: 'strip-node-prefix',
64
+ setup(pluginBuild) {
65
+ pluginBuild.onResolve({ filter: NODE_PREFIX_PATTERN }, (args) => ({
66
+ path: args.path.slice('node:'.length),
67
+ external: true,
68
+ }));
69
+ },
70
+ };
71
+
72
+ /**
73
+ * Options shared by the main plugin and content-script bundles.
74
+ * `api` resolves through tsconfig.json `paths`: api/index.ts exports Joplin's injected global,
75
+ * and api/types contains runtime enums, so both are bundled.
76
+ * @returns {import('esbuild').BuildOptions}
77
+ */
78
+ function sharedOptions(root) {
79
+ return {
80
+ absWorkingDir: root,
81
+ bundle: true,
82
+ format: 'cjs',
83
+ target: 'es2020',
84
+ minify: true,
85
+ // Matches Webpack's production mode. esbuild only sets this automatically for minified browser builds,
86
+ // and the main bundle may run in a webview without `process` (mobile).
87
+ define: { 'process.env.NODE_ENV': '"production"' },
88
+ logLevel: 'info',
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Returns the esbuild options for the main plugin bundle, `src/index.ts` to `dist/index.js`.
94
+ * `mainBuildOptions` from the plugin config are merged over the defaults.
95
+ * @param {string} root Plugin project root.
96
+ * @param {import('esbuild').BuildOptions} [overrides] The plugin config's `mainBuildOptions`.
97
+ * @returns {import('esbuild').BuildOptions}
98
+ */
99
+ export function createBuildOptions(root, overrides = {}) {
100
+ return {
101
+ ...sharedOptions(root),
102
+ entryPoints: ['src/index.ts'],
103
+ outfile: 'dist/index.js',
104
+ platform: 'node',
105
+ plugins: [stripNodePrefix],
106
+ ...overrides,
107
+ };
108
+ }
109
+
110
+ /**
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.
114
+ * @param {string} script Script path relative to `src/`.
115
+ * @returns {import('esbuild').BuildOptions}
116
+ */
117
+ export function createContentScriptBuildOptions(root, script) {
118
+ if (
119
+ !SCRIPT_EXTENSION_PATTERN.test(script) ||
120
+ path.isAbsolute(script) ||
121
+ script.split(PATH_SEPARATOR_PATTERN).includes('..')
122
+ ) {
123
+ throw new Error(`Invalid extra script path: ${script}`);
124
+ }
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.
127
+ return {
128
+ ...sharedOptions(root),
129
+ entryPoints: [`src/${script}`],
130
+ outfile: `dist/${script.replace(SCRIPT_EXTENSION_PATTERN, '.js')}`,
131
+ external: contentScriptExternals,
132
+ };
133
+ }
134
+
135
+ function isPlainObject(value) {
136
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
137
+ }
138
+
139
+ /**
140
+ * Reads and validates `<root>/plugin.config.json`. The file is optional; a plugin without one
141
+ * has no extra scripts and uses the default main build options.
142
+ * @returns {Promise<{ extraScripts: string[], mainBuildOptions: import('esbuild').BuildOptions }>}
143
+ */
144
+ export async function readPluginConfig(root) {
145
+ const configPath = path.join(root, CONFIG_FILE);
146
+ let config;
147
+ try {
148
+ config = JSON.parse(await readFile(configPath, 'utf8'));
149
+ } catch (error) {
150
+ if (error.code === 'ENOENT') return { extraScripts: [], mainBuildOptions: {} };
151
+ throw new Error(`Could not read ${configPath}: ${error.message}`, { cause: error });
152
+ }
153
+ if (!isPlainObject(config)) throw new Error(`${configPath} must contain a JSON object.`);
154
+ const { extraScripts = [], mainBuildOptions = {}, ...unknown } = config;
155
+ const unknownKeys = Object.keys(unknown);
156
+ if (unknownKeys.length > 0) {
157
+ throw new Error(`${configPath} has unknown keys: ${unknownKeys.join(', ')}.`);
158
+ }
159
+ if (!Array.isArray(extraScripts) || extraScripts.some((script) => typeof script !== 'string')) {
160
+ throw new Error(`${configPath} "extraScripts" must be an array of paths relative to src/.`);
161
+ }
162
+ if (!isPlainObject(mainBuildOptions)) {
163
+ throw new Error(`${configPath} "mainBuildOptions" must be an object of esbuild options.`);
164
+ }
165
+ const reserved = RESERVED_BUILD_OPTIONS.filter((key) => key in mainBuildOptions);
166
+ if (reserved.length > 0) {
167
+ throw new Error(`${configPath} "mainBuildOptions" cannot set ${reserved.join(', ')}.`);
168
+ }
169
+ return { extraScripts, mainBuildOptions };
170
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ // Public API for plugin projects' own build tests. The CLI in bin/ is the main entry point.
2
+ export {
3
+ contentScriptExternals,
4
+ createBuildOptions,
5
+ createContentScriptBuildOptions,
6
+ readPluginConfig,
7
+ } from './buildConfig.mjs';
@@ -0,0 +1,128 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { create } from 'tar';
6
+
7
+ /**
8
+ * Plugin repository categories accepted in `manifest.categories`.
9
+ * Mirrors generator-joplin's list; `update-api` warns when they differ.
10
+ */
11
+ export const VALID_CATEGORIES = new Set([
12
+ 'appearance',
13
+ 'developer tools',
14
+ 'productivity',
15
+ 'themes',
16
+ 'integrations',
17
+ 'viewer',
18
+ 'search',
19
+ 'tags',
20
+ 'editor',
21
+ 'files',
22
+ 'personal knowledge management',
23
+ ]);
24
+
25
+ /** File extensions accepted for local manifest screenshots. */
26
+ const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
27
+
28
+ /** Maximum size of a local manifest screenshot. */
29
+ const MAX_SCREENSHOT_BYTES = 1024 * 1024;
30
+
31
+ /** Matches a remote screenshot URL, which is not validated locally, e.g. `https://example.com/a.png`. */
32
+ const REMOTE_URL_PATTERN = /^https?:\/\//;
33
+
34
+ /** Matches a valid plugin ID, e.g. `com.example.my-plugin` (letters, digits, `_`, `.`, `-`). */
35
+ const PLUGIN_ID_PATTERN = /^[\w.-]+$/;
36
+
37
+ /**
38
+ * Reads `package.json` and `src/manifest.json` from `root` and validates the fields the plugin
39
+ * repository depends on: plugin ID, matching versions, package name and keyword, categories, and
40
+ * local screenshots.
41
+ */
42
+ 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'));
45
+ 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.');
47
+ }
48
+ if (manifest.version !== packageJson.version) {
49
+ throw new Error('package.json and src/manifest.json versions must match.');
50
+ }
51
+ 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.');
53
+ }
54
+ const selectedCategories = manifest.categories ?? [];
55
+ if (
56
+ new Set(selectedCategories).size !== selectedCategories.length ||
57
+ selectedCategories.some((category) => !VALID_CATEGORIES.has(category))
58
+ ) {
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
+ }
70
+ }
71
+ return { packageJson, manifest };
72
+ }
73
+
74
+ /** Branch recorded in `_publish_commit` when the build runs from a detached HEAD. */
75
+ const DEFAULT_BRANCH = 'main';
76
+
77
+ function currentGitInfo(root) {
78
+ try {
79
+ // Git is intentionally resolved from the developer's PATH, with fixed arguments and no shell.
80
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- Local build tool using the developer's Git installation.
81
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' })
82
+ .toString()
83
+ .trim();
84
+ // eslint-disable-next-line sonarjs/no-os-command-from-path -- Fixed Git arguments, without a shell.
85
+ const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();
86
+ // A detached HEAD (e.g. a CI tag checkout) reports `HEAD`; record the default branch instead.
87
+ return `${branch === 'HEAD' ? DEFAULT_BRANCH : branch}:${commit}`;
88
+ } catch {
89
+ return '';
90
+ }
91
+ }
92
+
93
+ /** Lists files under `directory`, skipping dotfiles and dot-directories (e.g. `.DS_Store`, `.gitkeep`). */
94
+ async function listFiles(directory, prefix = '') {
95
+ const files = [];
96
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
97
+ if (entry.name.startsWith('.')) continue;
98
+ const relativePath = `${prefix}${entry.name}`;
99
+ if (entry.isDirectory()) {
100
+ files.push(...(await listFiles(path.join(directory, entry.name), `${relativePath}/`)));
101
+ } else {
102
+ files.push(relativePath);
103
+ }
104
+ }
105
+ return files.sort();
106
+ }
107
+
108
+ /**
109
+ * Archives `<root>/dist` into `<root>/publish/<id>.jpl` and writes `<id>.json` publish metadata.
110
+ * `extraScripts` are bundle paths relative to `dist/` that must exist before packaging.
111
+ */
112
+ export async function packagePlugin(root, manifest, extraScripts = []) {
113
+ const dist = path.join(root, 'dist');
114
+ const publish = path.join(root, 'publish');
115
+ // Require the entry point and manifest, rather than publishing an incomplete build.
116
+ await stat(path.join(dist, 'index.js'));
117
+ await stat(path.join(dist, 'manifest.json'));
118
+ for (const script of extraScripts) await stat(path.join(dist, script));
119
+ await mkdir(publish, { recursive: true });
120
+ const archivePath = path.join(publish, `${manifest.id}.jpl`);
121
+ // Package files only, matching the generator and excluding empty source directories.
122
+ await create({ cwd: dist, file: archivePath, portable: true, strict: true }, await listFiles(dist));
123
+ const hash = createHash('sha256')
124
+ .update(await readFile(archivePath))
125
+ .digest('hex');
126
+ const info = { ...manifest, _publish_hash: `sha256:${hash}`, _publish_commit: currentGitInfo(root) };
127
+ await writeFile(path.join(publish, `${manifest.id}.json`), `${JSON.stringify(info, null, 4)}\n`);
128
+ }
@@ -0,0 +1,155 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { cp, mkdtemp, readdir, readFile, rename, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { extract } from 'tar';
6
+
7
+ import { contentScriptExternals } from './buildConfig.mjs';
8
+ import { VALID_CATEGORIES } from './packagePlugin.mjs';
9
+
10
+ const templates = 'package/generators/app/templates';
11
+
12
+ /**
13
+ * Matches the generator's category list, e.g.
14
+ * `const allPossibleCategories = [{'name':'appearance'},{'name':'editor'}];`
15
+ */
16
+ const GENERATOR_CATEGORIES_PATTERN = /const allPossibleCategories = \[(.*?)\];/s;
17
+
18
+ /** Matches one category entry within that list, e.g. `{'name':'developer tools'}` captures `developer tools`. */
19
+ const GENERATOR_CATEGORY_NAME_PATTERN = /["']name["']\s*:\s*["']([^"']+)["']/g;
20
+
21
+ /**
22
+ * Matches the generator's content-script externals, e.g.
23
+ * `const externalContentScriptLibraries = [\n\t'@codemirror/view',\n\t'@lezer/common',\n];`
24
+ */
25
+ const GENERATOR_EXTERNALS_PATTERN = /const externalContentScriptLibraries = \[([^\]]*)\]/;
26
+
27
+ /** Matches one quoted string, e.g. `'@codemirror/view'` captures `@codemirror/view`. */
28
+ const QUOTED_STRING_PATTERN = /["']([^"']+)["']/g;
29
+
30
+ /** Where the hand-maintained lists live, for warnings shown in plugin projects. */
31
+ const LIST_SOURCE = '@bwat47/joplin-plugin-scripts';
32
+
33
+ /**
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.
36
+ */
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
+
49
+ function extractList(text, listPattern, itemPattern) {
50
+ const list = listPattern.exec(text)?.[1];
51
+ return list === undefined ? undefined : [...list.matchAll(itemPattern)].map((match) => match[1]);
52
+ }
53
+
54
+ function describeListDifference(label, generatorItems, localItems, location) {
55
+ if (generatorItems === undefined) {
56
+ return `Could not find the ${label} in generator-joplin's webpack.config.js; compare ${location} manually.`;
57
+ }
58
+ const local = new Set(localItems);
59
+ const generator = new Set(generatorItems);
60
+ const missing = [...generator].filter((item) => !local.has(item));
61
+ const extra = [...local].filter((item) => !generator.has(item));
62
+ if (missing.length === 0 && extra.length === 0) return undefined;
63
+ const details = [
64
+ missing.length > 0 ? `missing ${missing.join(', ')}` : '',
65
+ extra.length > 0 ? `not in generator ${extra.join(', ')}` : '',
66
+ ].filter(Boolean);
67
+ return `The ${label} differ from generator-joplin (${details.join('; ')}); update ${location}.`;
68
+ }
69
+
70
+ /**
71
+ * Compares the hand-maintained category and content-script external lists with the ones in
72
+ * generator-joplin's `webpack.config.js` text. Returns one warning per list that differs or
73
+ * cannot be found; an empty array means both match.
74
+ * @returns {string[]}
75
+ */
76
+ export function compareGeneratorLists(webpackConfig) {
77
+ return [
78
+ describeListDifference(
79
+ 'plugin categories',
80
+ extractList(webpackConfig, GENERATOR_CATEGORIES_PATTERN, GENERATOR_CATEGORY_NAME_PATTERN),
81
+ VALID_CATEGORIES,
82
+ `VALID_CATEGORIES in ${LIST_SOURCE} (lib/packagePlugin.mjs)`
83
+ ),
84
+ describeListDifference(
85
+ 'content-script externals',
86
+ extractList(webpackConfig, GENERATOR_EXTERNALS_PATTERN, QUOTED_STRING_PATTERN),
87
+ contentScriptExternals,
88
+ `contentScriptExternals in ${LIST_SOURCE} (lib/buildConfig.mjs)`
89
+ ),
90
+ ].filter((warning) => warning !== undefined);
91
+ }
92
+
93
+ /**
94
+ * Replaces `<root>/api` with a copy of `source`. The copy is staged next to `api/` and swapped in
95
+ * by rename, so a failed copy leaves the existing `api/` untouched. `copy` defaults to a recursive copy.
96
+ */
97
+ export async function replaceApi(root, source, copy = (from, to) => cp(from, to, { recursive: true })) {
98
+ // Fail before changing anything if the generator layout has changed.
99
+ await stat(path.join(source, 'index.ts'));
100
+ const destination = path.join(root, 'api');
101
+ const staging = await mkdtemp(path.join(root, '.api-staging-'));
102
+ const backup = `${staging}-previous`;
103
+ try {
104
+ await copy(source, staging);
105
+ let hadPrevious = true;
106
+ try {
107
+ await rename(destination, backup);
108
+ } catch (error) {
109
+ if (error.code !== 'ENOENT') throw error;
110
+ hadPrevious = false;
111
+ }
112
+ try {
113
+ await rename(staging, destination);
114
+ } catch (error) {
115
+ if (hadPrevious) await rename(backup, destination);
116
+ throw error;
117
+ }
118
+ if (hadPrevious) await rm(backup, { recursive: true, force: true });
119
+ } finally {
120
+ await rm(staging, { recursive: true, force: true });
121
+ }
122
+ }
123
+
124
+ async function readGeneratorWarnings(webpackConfigPath) {
125
+ let webpackConfig;
126
+ try {
127
+ webpackConfig = await readFile(webpackConfigPath, 'utf8');
128
+ } catch (error) {
129
+ return [`Could not read ${webpackConfigPath} (${error.message}); compare the build lists manually.`];
130
+ }
131
+ return compareGeneratorLists(webpackConfig);
132
+ }
133
+
134
+ /**
135
+ * Replaces `<root>/api` with the API declarations from the latest generator-joplin package.
136
+ * Nothing else in the project is touched. Returns the generator version used and warnings for
137
+ * hand-maintained build lists that no longer match the generator.
138
+ * @returns {Promise<{ version: string, warnings: string[] }>}
139
+ */
140
+ export async function updateApi(root, packageSpec = 'generator-joplin@latest') {
141
+ const temp = await mkdtemp(path.join(tmpdir(), 'joplin-api-'));
142
+ try {
143
+ npm(['pack', packageSpec, '--pack-destination', temp], temp);
144
+ // The --json output shape differs between npm versions, so locate the tarball instead.
145
+ const tarball = (await readdir(temp)).find((name) => name.endsWith('.tgz'));
146
+ if (!tarball) throw new Error(`npm pack did not produce a tarball for ${packageSpec}`);
147
+ await extract({ file: path.join(temp, tarball), cwd: temp, strict: true });
148
+ const { version } = JSON.parse(await readFile(path.join(temp, 'package/package.json'), 'utf8'));
149
+ await replaceApi(root, path.join(temp, templates, 'api'));
150
+ const warnings = await readGeneratorWarnings(path.join(temp, templates, 'webpack.config.js'));
151
+ return { version, warnings };
152
+ } finally {
153
+ await rm(temp, { recursive: true, force: true });
154
+ }
155
+ }
@@ -0,0 +1,64 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { readProject } from './packagePlugin.mjs';
5
+
6
+ /** Matches a `major.minor.patch` version, e.g. `1.14.2`. */
7
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
8
+
9
+ /**
10
+ * Returns the text of a hand-formatted JSON file with its single `"version": "<from>"` entry
11
+ * replaced, leaving every other byte untouched so a version bump produces a one-line diff.
12
+ */
13
+ async function replaceVersion(filePath, from, to) {
14
+ const text = await readFile(filePath, 'utf8');
15
+ // e.g. `"version": "1.14.2"`; `from` is validated semver, so only its dots need escaping.
16
+ const pattern = new RegExp(`("version"\\s*:\\s*")${from.replaceAll('.', '\\.')}(")`, 'g');
17
+ const matches = text.match(pattern)?.length ?? 0;
18
+ if (matches !== 1) {
19
+ throw new Error(`Expected one "version": "${from}" entry in ${filePath}, found ${matches}.`);
20
+ }
21
+ return text.replace(pattern, `$1${to}$2`);
22
+ }
23
+
24
+ /**
25
+ * Increments the patch version in `package.json`, `src/manifest.json`, and `package-lock.json`
26
+ * together. Writes nothing unless all three files can be updated.
27
+ * @returns {Promise<string>} The new version.
28
+ */
29
+ export async function updateVersion(root) {
30
+ const { packageJson } = await readProject(root);
31
+ const current = packageJson.version;
32
+ if (!SEMVER_PATTERN.test(current)) {
33
+ throw new Error('update-version requires a version in major.minor.patch format.');
34
+ }
35
+ const [major, minor, patch] = current.split('.').map(Number);
36
+ const version = `${major}.${minor}.${patch + 1}`;
37
+
38
+ const lockPath = path.join(root, 'package-lock.json');
39
+ const lock = JSON.parse(await readFile(lockPath, 'utf8'));
40
+ const lockRoot = lock.packages?.[''];
41
+ if (!lockRoot) {
42
+ throw new Error(`${lockPath} has no root package entry (packages[""]); regenerate it with npm install.`);
43
+ }
44
+ if (lock.version !== current || lockRoot.version !== current) {
45
+ throw new Error(
46
+ `${lockPath} versions (${lock.version}, ${lockRoot.version}) do not match package.json (${current}); ` +
47
+ 'run npm install to resync the lockfile.'
48
+ );
49
+ }
50
+ lock.version = version;
51
+ lockRoot.version = version;
52
+
53
+ const packagePath = path.join(root, 'package.json');
54
+ const manifestPath = path.join(root, 'src/manifest.json');
55
+ // Prepare every file before writing any, so a failed check leaves all versions in sync.
56
+ const updates = [
57
+ [packagePath, await replaceVersion(packagePath, current, version)],
58
+ [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`],
61
+ ];
62
+ for (const [filePath, text] of updates) await writeFile(filePath, text);
63
+ return version;
64
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
- "name": "@bwat47/joplin-plugin-scripts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@bwat47/joplin-plugin-scripts",
3
+ "version": "1.0.0",
4
+ "description": "Shared esbuild build, packaging, version, and API-update scripts for Joplin plugins",
5
+ "license": "MIT",
6
+ "author": "bwat47",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "bin": {
12
+ "joplin-plugin-scripts": "bin/joplin-plugin-scripts.mjs"
13
+ },
14
+ "exports": {
15
+ ".": "./lib/index.mjs"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "lib"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.mjs",
23
+ "lint": "eslint .",
24
+ "lint:fix": "eslint . --fix",
25
+ "format": "prettier --write .",
26
+ "format:check": "prettier --check .",
27
+ "prepublishOnly": "npm run lint && npm run format:check && npm test"
28
+ },
29
+ "dependencies": {
30
+ "esbuild": "^0.28.2",
31
+ "tar": "^7.5.22"
32
+ },
33
+ "devDependencies": {
34
+ "@eslint/js": "^10.0.1",
35
+ "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
36
+ "eslint": "^10.14.0",
37
+ "eslint-config-prettier": "^10.1.8",
38
+ "eslint-plugin-sonarjs": "^4.2.2",
39
+ "globals": "^17.14.0",
40
+ "prettier": "^3.9.10"
41
+ }
42
+ }