@gjsify/cli 0.52.0 → 0.53.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.
Files changed (81) hide show
  1. package/dist/cli.gjs.mjs +329 -298
  2. package/lib/actions/build.d.ts +22 -0
  3. package/lib/actions/build.js +37 -9
  4. package/lib/bundler-pick.d.ts +37 -1
  5. package/lib/bundler-pick.js +108 -17
  6. package/lib/cli-app.js +5 -2
  7. package/lib/commands/env.d.ts +31 -0
  8. package/lib/commands/env.js +114 -0
  9. package/lib/commands/exec.d.ts +10 -0
  10. package/lib/commands/exec.js +208 -0
  11. package/lib/commands/foreach.js +5 -97
  12. package/lib/commands/format.js +26 -6
  13. package/lib/commands/index.d.ts +3 -0
  14. package/lib/commands/index.js +3 -0
  15. package/lib/commands/install.d.ts +2 -2
  16. package/lib/commands/install.js +5 -5
  17. package/lib/commands/self-update.js +2 -1
  18. package/lib/commands/test.d.ts +18 -9
  19. package/lib/commands/test.js +56 -11
  20. package/lib/commands/tsc.js +4 -4
  21. package/lib/commands/upgrade.d.ts +15 -0
  22. package/lib/commands/upgrade.js +76 -2
  23. package/lib/commands/webext/index.d.ts +2 -0
  24. package/lib/commands/webext/index.js +15 -0
  25. package/lib/commands/webext/subcommands.d.ts +16 -0
  26. package/lib/commands/webext/subcommands.js +204 -0
  27. package/lib/types/config-data.d.ts +1 -1
  28. package/lib/utils/bin-shim.d.ts +8 -2
  29. package/lib/utils/bin-shim.js +6 -5
  30. package/lib/utils/bundle-inputs.d.ts +84 -0
  31. package/lib/utils/bundle-inputs.js +268 -0
  32. package/lib/utils/check-system-deps.d.ts +4 -2
  33. package/lib/utils/check-system-deps.js +21 -26
  34. package/lib/utils/dev-link.d.ts +9 -6
  35. package/lib/utils/dev-link.js +71 -19
  36. package/lib/utils/exec-bin.d.ts +137 -0
  37. package/lib/utils/exec-bin.js +321 -0
  38. package/lib/utils/gi-runtime-paths.js +3 -2
  39. package/lib/utils/gitignore.d.ts +15 -0
  40. package/lib/utils/gitignore.js +178 -0
  41. package/lib/utils/gjsify-shim.d.ts +47 -0
  42. package/lib/utils/gjsify-shim.js +97 -8
  43. package/lib/utils/install-backend-native.d.ts +19 -1
  44. package/lib/utils/install-backend-native.js +93 -7
  45. package/lib/utils/install-lock.js +4 -25
  46. package/lib/utils/native-skip-scan.d.ts +20 -0
  47. package/lib/utils/native-skip-scan.js +343 -0
  48. package/lib/utils/node-script.js +1 -1
  49. package/lib/utils/oxc-resolve.js +42 -3
  50. package/lib/utils/process-table.d.ts +22 -0
  51. package/lib/utils/process-table.js +188 -0
  52. package/lib/utils/prune-prefix.js +1 -1
  53. package/lib/utils/run-gjs.d.ts +15 -1
  54. package/lib/utils/run-gjs.js +16 -7
  55. package/lib/utils/run-node.d.ts +2 -0
  56. package/lib/utils/run-node.js +2 -1
  57. package/lib/utils/runtimes.js +14 -13
  58. package/lib/utils/ship/gzip.js +1 -1
  59. package/lib/utils/ship/icons.d.ts +6 -0
  60. package/lib/utils/ship/icons.js +6 -5
  61. package/lib/utils/spawn.d.ts +13 -0
  62. package/lib/utils/spawn.js +10 -5
  63. package/lib/utils/watch-loop.d.ts +15 -1
  64. package/lib/utils/watch-loop.js +9 -5
  65. package/lib/utils/webext/build.d.ts +42 -0
  66. package/lib/utils/webext/build.js +120 -0
  67. package/lib/utils/webext/config.d.ts +72 -0
  68. package/lib/utils/webext/config.js +234 -0
  69. package/lib/utils/webext/icons.d.ts +24 -0
  70. package/lib/utils/webext/icons.js +83 -0
  71. package/lib/utils/webext/launch.d.ts +32 -0
  72. package/lib/utils/webext/launch.js +77 -0
  73. package/lib/utils/webext/manifest.d.ts +53 -0
  74. package/lib/utils/webext/manifest.js +219 -0
  75. package/lib/utils/webext/pages.d.ts +26 -0
  76. package/lib/utils/webext/pages.js +61 -0
  77. package/lib/utils/webext/targets.d.ts +13 -0
  78. package/lib/utils/webext/targets.js +36 -0
  79. package/lib/utils/win32-command.d.ts +17 -0
  80. package/lib/utils/win32-command.js +21 -6
  81. package/package.json +19 -19
@@ -21,12 +21,20 @@ import { captureNativeBacktrace, createHangWatchdog, formatHangReport, hangGrace
21
21
  * variable is set is host-dependent (`LD_LIBRARY_PATH` / `DYLD_LIBRARY_PATH` / `PATH`) — see
22
22
  * `buildNativeEnv`.
23
23
  */
24
- export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inherited = process.env) {
24
+ export function computeNativeEnvForBundle(bundlePath, cwd = process.cwd(), inherited = process.env, extraRoots = []) {
25
25
  const resolvedBundle = resolve(bundlePath);
26
- const cwdPackages = detectNativePackages(cwd);
27
- const bundlePackages = detectNativePackages(dirname(resolvedBundle));
28
- const seen = new Set(cwdPackages.map((p) => p.name));
29
- const nativePackages = [...cwdPackages, ...bundlePackages.filter((p) => !seen.has(p.name))];
26
+ // First root wins per package name: the project, then the bundle's own tree, then
27
+ // whatever the caller adds (see `RunGjsBundleOptions.nativeRoots`).
28
+ const nativePackages = [];
29
+ const seen = new Set();
30
+ for (const root of [cwd, dirname(resolvedBundle), ...extraRoots]) {
31
+ for (const pkg of detectNativePackages(root)) {
32
+ if (seen.has(pkg.name))
33
+ continue;
34
+ seen.add(pkg.name);
35
+ nativePackages.push(pkg);
36
+ }
37
+ }
30
38
  // `inherited` feeds BOTH halves — composition and comparison. Split them and the win32
31
39
  // branch disagrees with itself: `buildNativeEnv` writes the library variable back under
32
40
  // the host's own spelling (`Path` in a stock env block), so a comparison keyed on the
@@ -58,7 +66,7 @@ function safeMkdtemp() {
58
66
  }
59
67
  }
60
68
  export async function runGjsBundle(bundlePath, extraArgs = [], options) {
61
- const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath);
69
+ const { env: nativeEnv, envPrefix } = computeNativeEnvForBundle(bundlePath, process.cwd(), process.env, options.nativeRoots);
62
70
  // The breadcrumb `@gjsify/unit` writes what it is running into, and this process polls —
63
71
  // the only way a bundle that has stopped turning its main loop can still say which test it
64
72
  // died in. Anything that is not a unit run never writes the file, so the watchdog stays
@@ -149,7 +157,8 @@ export async function runGjsBundle(bundlePath, extraArgs = [], options) {
149
157
  if (hung)
150
158
  return failed(result.code ?? 1);
151
159
  if (result.code !== 0) {
152
- console.error(`gjs exited with ${describeExit(result)}`);
160
+ if (!options.quietExit)
161
+ console.error(`gjs exited with ${describeExit(result)}`);
153
162
  return failed(result.code ?? 1);
154
163
  }
155
164
  // See RunGjsBundleOptions.exitOnSuccess — terminal callers opt in,
@@ -26,6 +26,8 @@ export interface RunNodeBundleOptions {
26
26
  exitOnSuccess?: boolean;
27
27
  /** Suppress the `$ <env> node …` echo — see `RunGjsBundleOptions.quiet`. */
28
28
  quiet?: boolean;
29
+ /** Leave a failing child's output alone — see `RunGjsBundleOptions.quietExit`. */
30
+ quietExit?: boolean;
29
31
  }
30
32
  /**
31
33
  * Run a `--app node` bundle on Node.js with the native typelib env set (same detection as
@@ -148,7 +148,8 @@ export async function runRuntimeBundle(runtime, bundlePath, extraArgs = [], opti
148
148
  return failed(1);
149
149
  }
150
150
  if (result.code !== 0) {
151
- console.error(`${runtime} exited with ${describeExit(result)}`);
151
+ if (!options.quietExit)
152
+ console.error(`${runtime} exited with ${describeExit(result)}`);
152
153
  return failed(result.code ?? 1);
153
154
  }
154
155
  if (options.exitOnSuccess)
@@ -6,13 +6,14 @@
6
6
  // runtime→build-target mapping and `availableRuntimes()` live in ONE place instead
7
7
  // of a per-example copy.
8
8
  //
9
- // Intentionally PURE (only `node:child_process` and the side-effect-free
10
- // `@gjsify/rolldown-plugin-gjsify/runtime` host detector, no `gi://` or native
11
- // imports) so an external example can deep-import it from the installed CLI
12
- // (`@gjsify/cli/lib/utils/runtimes.js`) without pulling in the whole bundler — the
13
- // same deep-import precedent as `@gjsify/cli/lib/utils/run-gjs.js`.
14
- import { execFileSync } from 'node:child_process';
9
+ // Intentionally PURE (only `node:child_process`, `./spawn.js`'s Windows command
10
+ // rewrite and the side-effect-free `@gjsify/rolldown-plugin-gjsify/runtime` host
11
+ // detector, no `gi://` or native imports) so an external example can deep-import
12
+ // it from the installed CLI (`@gjsify/cli/lib/utils/runtimes.js`) without pulling
13
+ // in the whole bundler — the same deep-import precedent as `@gjsify/cli/lib/utils/run-gjs.js`.
14
+ import { spawnSync } from 'node:child_process';
15
15
  import { hostRuntime } from '@gjsify/rolldown-plugin-gjsify/runtime';
16
+ import { toSpawnable } from './spawn.js';
16
17
  /** All runtimes, in canonical order (gjs first — it is the reference). */
17
18
  export const EXAMPLE_RUNTIMES = ['gjs', 'node', 'bun', 'deno'];
18
19
  /**
@@ -57,13 +58,13 @@ export function requiresGjsSystemDeps(runtime) {
57
58
  }
58
59
  /** Whether `<probe> --version` succeeds (i.e. the runtime binary is on PATH). */
59
60
  export function isRuntimeOnPath(probe) {
60
- try {
61
- execFileSync(probe, ['--version'], { stdio: 'ignore', timeout: 15000 });
62
- return true;
63
- }
64
- catch {
65
- return false;
66
- }
61
+ const inv = toSpawnable(probe, ['--version']);
62
+ const r = spawnSync(inv.cmd, inv.args, {
63
+ stdio: 'ignore',
64
+ timeout: 15000,
65
+ windowsVerbatimArguments: inv.windowsVerbatimArguments,
66
+ });
67
+ return !r.error && r.status === 0;
67
68
  }
68
69
  /**
69
70
  * Whether a specific runtime is runnable here. The HOST runtime needs no PATH probe
@@ -26,7 +26,7 @@
26
26
  // asked for only where a reader demands it (see below) and not blanket-applied
27
27
  // to the payload tarballs.
28
28
  //
29
- // Ledgered in `status/open-todos.md`; closing it means pinning ONE deflate
29
+ // Ledgered in `status/open-todos/README.md`; closing it means pinning ONE deflate
30
30
  // implementation, which is a larger decision than this file.
31
31
  import { gzip } from '@gjsify/tar';
32
32
  /**
@@ -157,6 +157,12 @@ export interface RasterizeInput {
157
157
  * alone passes just as well when nobody wires it in.
158
158
  */
159
159
  spawn?: typeof spawnToCompletion;
160
+ /**
161
+ * The command a refusal names (default `gjsify ship`). `gjsify webext` renders
162
+ * its toolbar icons through the same child, and a message blaming `ship`
163
+ * would send its reader to the wrong command's documentation.
164
+ */
165
+ command?: string;
160
166
  }
161
167
  /**
162
168
  * Render an SVG at every size through librsvg, in a child GJS.
@@ -267,6 +267,7 @@ export function rasterizerEnv(opts = {}) {
267
267
  export async function rasterizeSvg(input) {
268
268
  if (input.sizes.length === 0)
269
269
  return new Map();
270
+ const command = input.command ?? 'gjsify ship';
270
271
  // THE CLI'S OWN SPAWN WRAPPER, not `promisify(execFile)`. Measured under the
271
272
  // GJS-built CLI (`dist/cli.gjs.mjs`, gjs 1.88): `node:util`'s `promisify` over
272
273
  // `@gjsify/child_process`'s `execFile` resolves with the bare stdout string —
@@ -285,7 +286,7 @@ export async function rasterizeSvg(input) {
285
286
  // A 1024 px PNG of a detailed icon is a few hundred kilobytes; base64 adds
286
287
  // a third. Node's default 1 MiB is within reach of one large element.
287
288
  maxBuffer: 64 * 1024 * 1024,
288
- notFound: () => new Error(`gjsify ship: \`${RASTERIZER}\` is not on PATH, and it is what renders ${input.svg} into the ` +
289
+ notFound: () => new Error(`${command}: \`${RASTERIZER}\` is not on PATH, and it is what renders ${input.svg} into the ` +
289
290
  `sizes an icon needs on this OS. ${RASTERIZER_HINT}.`),
290
291
  });
291
292
  const stdout = result.stdout ?? '';
@@ -296,7 +297,7 @@ export async function rasterizeSvg(input) {
296
297
  const hint = /Rsvg|Typelib|cairo/i.test(stderr)
297
298
  ? ` The Rsvg typelib or GJS's cairo binding is missing. ${RASTERIZER_HINT}.`
298
299
  : '';
299
- throw new Error(`gjsify ship: rendering ${input.svg} through ${RASTERIZER} failed (exit ${result.code ?? result.signal}).` +
300
+ throw new Error(`${command}: rendering ${input.svg} through ${RASTERIZER} failed (exit ${result.code ?? result.signal}).` +
300
301
  `${hint}${stderr === '' ? '' : `\n ${stderr.split('\n').join('\n ')}`}`);
301
302
  }
302
303
  let parsed;
@@ -304,19 +305,19 @@ export async function rasterizeSvg(input) {
304
305
  parsed = JSON.parse(stdout);
305
306
  }
306
307
  catch {
307
- throw new Error(`gjsify ship: ${RASTERIZER} printed something other than the rendered icon while rendering ` +
308
+ throw new Error(`${command}: ${RASTERIZER} printed something other than the rendered icon while rendering ` +
308
309
  `${input.svg}: ${stdout.slice(0, 200)}`);
309
310
  }
310
311
  const out = new Map();
311
312
  for (const size of input.sizes) {
312
313
  const encoded = parsed[String(size)];
313
314
  if (encoded === undefined) {
314
- throw new Error(`gjsify ship: ${RASTERIZER} rendered no ${size} px image of ${input.svg}.`);
315
+ throw new Error(`${command}: ${RASTERIZER} rendered no ${size} px image of ${input.svg}.`);
315
316
  }
316
317
  const png = new Uint8Array(Buffer.from(encoded, 'base64'));
317
318
  const { width, height } = readPngSize(png, `the ${size} px render of ${input.svg}`);
318
319
  if (width !== size || height !== size) {
319
- throw new Error(`gjsify ship: asked ${RASTERIZER} for a ${size} px render of ${input.svg} and got ${width}×${height}.`);
320
+ throw new Error(`${command}: asked ${RASTERIZER} for a ${size} px render of ${input.svg} and got ${width}×${height}.`);
320
321
  }
321
322
  out.set(size, png);
322
323
  }
@@ -1,4 +1,5 @@
1
1
  import type { ChildProcess } from 'node:child_process';
2
+ import { type Win32Invocation } from './win32-command.js';
2
3
  /**
3
4
  * What the calling command does once this spawn settles.
4
5
  *
@@ -82,6 +83,18 @@ export interface SpawnCompletion {
82
83
  /** The child's stderr — only under `stdio: 'capture'`, `''` when it wrote none. */
83
84
  stderr?: string;
84
85
  }
86
+ /**
87
+ * Rewrite a bare command for Windows (see `utils/win32-command.ts`). A no-op on
88
+ * every other platform, and on win32 for anything already carrying a path or
89
+ * extension. `env` is the CHILD's, because its `PATH` is what must be searched.
90
+ *
91
+ * Exported for the synchronous PROBES that cannot go through
92
+ * {@link spawnToCompletion} (`<tool> --version` in `check-system-deps.ts`, the
93
+ * runtime probe in `runtimes.ts`): a raw `execFileSync('deno', …)` is ENOENT on
94
+ * Windows when deno came from `npm i -g deno`, which installs a `.cmd` shim, so
95
+ * `--runtime deno` was refused as "not installed" on a host where `deno` runs.
96
+ */
97
+ export declare function toSpawnable(cmd: string, args: readonly string[], env?: NodeJS.ProcessEnv): Win32Invocation;
85
98
  /**
86
99
  * Run `cmd args…` to completion and resolve with its exit code and signal.
87
100
  *
@@ -60,7 +60,7 @@
60
60
  //
61
61
  // ## What is spawned still matters — the Windows rewrite
62
62
  //
63
- // This helper also carries the Windows command rewrite (`forWin32`), which is a
63
+ // This helper also carries the Windows command rewrite (`toSpawnable`), which is a
64
64
  // second reason to come through it, and the one that applies OUTSIDE the CLI too:
65
65
  //
66
66
  // • a real executable (`git`, `gh`, `node`, `gjs`, `flatpak`, `process.execPath`)
@@ -93,10 +93,15 @@ function resolveEnv(opts) {
93
93
  /**
94
94
  * Rewrite a bare command for Windows (see `utils/win32-command.ts`). A no-op on
95
95
  * every other platform, and on win32 for anything already carrying a path or
96
- * extension. Applied to BOTH paths below so the blocking and streaming branches
97
- * agree; `env` is the CHILD's, because its `PATH` is what must be searched.
96
+ * extension. `env` is the CHILD's, because its `PATH` is what must be searched.
97
+ *
98
+ * Exported for the synchronous PROBES that cannot go through
99
+ * {@link spawnToCompletion} (`<tool> --version` in `check-system-deps.ts`, the
100
+ * runtime probe in `runtimes.ts`): a raw `execFileSync('deno', …)` is ENOENT on
101
+ * Windows when deno came from `npm i -g deno`, which installs a `.cmd` shim, so
102
+ * `--runtime deno` was refused as "not installed" on a host where `deno` runs.
98
103
  */
99
- function forWin32(cmd, args, env) {
104
+ export function toSpawnable(cmd, args, env) {
100
105
  const rewritten = resolveWin32Command(cmd, args, {
101
106
  platform: process.platform,
102
107
  env: env ?? process.env,
@@ -132,7 +137,7 @@ export function spawnToCompletion(cmd, args, opts) {
132
137
  // must not touch it — see `SpawnToCompletionOptions.shell`.
133
138
  const invocation = opts.shell
134
139
  ? { cmd, args: [...args], windowsVerbatimArguments: false }
135
- : forWin32(cmd, args, env);
140
+ : toSpawnable(cmd, args, env);
136
141
  if (mustBlock(opts)) {
137
142
  // `'inherit'` is NOT forwarded through `@gjsify/child_process`'s GJS
138
143
  // spawnSync path (it drives a private main context via
@@ -23,9 +23,23 @@ export declare function spawnSupervised(cmd: string, cmdArgs: string[], env?: No
23
23
  * "this project").
24
24
  */
25
25
  export declare function spawnBundleSupervised(runtime: ExampleRuntime, bundlePath: string, cwd: string, subject: string): Promise<ChildProcess>;
26
+ /** One path the loop watches — a directory recursively, or the directory's own entries only. */
27
+ export interface WatchTarget {
28
+ path: string;
29
+ recursive: boolean;
30
+ }
26
31
  export interface WatchLoopOptions {
27
- /** Directory watched recursively. */
32
+ /** Directory watched recursively — the label and the default when `watch` is not given. */
28
33
  dir: string;
34
+ /**
35
+ * Several paths instead of `dir` alone. `gjsify webext dev` needs it: its
36
+ * sources sit in sibling top-level directories next to `node_modules`, and a
37
+ * recursive watch on the project root costs one `Gio.FileMonitor` per
38
+ * directory under GJS, `node_modules` included.
39
+ */
40
+ watch?: readonly WatchTarget[];
41
+ /** An absolute changed path the loop must not rebuild for (build output, caches). */
42
+ ignore?: (changed: string) => boolean;
29
43
  /** How `dir` is spelled in the loop's own log lines. Defaults to `dir`. */
30
44
  dirLabel?: string;
31
45
  /** Prefix for the loop's log lines: `[<label>] …`. */
@@ -125,7 +125,7 @@ export function isSelfWrite(dir, output, filename) {
125
125
  * fix, and a dev loop that dies on a syntax error is a dev loop nobody uses.
126
126
  */
127
127
  export async function runWatchLoop(opts) {
128
- const { dir, label, prepare, build, spawnChild, output, debounceMs = 200 } = opts;
128
+ const { dir, label, prepare, build, spawnChild, output, ignore, debounceMs = 200 } = opts;
129
129
  // Say so BEFORE the first build: `gjsify run`'s in-process dispatch acts on
130
130
  // this the moment the handler resolves, and a first build that throws still
131
131
  // leaves the loop watching.
@@ -164,13 +164,16 @@ export async function runWatchLoop(opts) {
164
164
  };
165
165
  await rebuild();
166
166
  let debounce = null;
167
- const watcher = watch(dir, { recursive: true }, (_event, filename) => {
168
- if (isSelfWrite(dir, output, typeof filename === 'string' ? filename : null))
167
+ const watchers = (opts.watch ?? [{ path: dir, recursive: true }]).map((target) => watch(target.path, { recursive: target.recursive }, (_event, filename) => {
168
+ const name = typeof filename === 'string' ? filename : null;
169
+ if (isSelfWrite(target.path, output, name))
170
+ return;
171
+ if (ignore && name !== null && ignore(resolve(target.path, name)))
169
172
  return;
170
173
  if (debounce)
171
174
  clearTimeout(debounce);
172
175
  debounce = setTimeout(() => void rebuild(), debounceMs);
173
- });
176
+ }));
174
177
  // Under GJS nothing else keeps us here. An `fs.watch` handle is a Gio file
175
178
  // monitor, not a Node handle count, and the supervised spawn's own
176
179
  // `ensureMainLoop()` declines to arm anything: it is reached from a
@@ -198,7 +201,8 @@ export async function runWatchLoop(opts) {
198
201
  if (debounce)
199
202
  clearTimeout(debounce);
200
203
  child?.kill();
201
- watcher.close();
204
+ for (const watcher of watchers)
205
+ watcher.close();
202
206
  process.exit(0);
203
207
  };
204
208
  process.on('SIGINT', shutdown);
@@ -0,0 +1,42 @@
1
+ import { type ZipEntry } from '../ship/zip.js';
2
+ import type { RasterizeInput } from '../ship/icons.js';
3
+ import type { ResolvedWebext } from './config.js';
4
+ import type { WebextTarget } from './targets.js';
5
+ export interface BundleRequest {
6
+ entry: string;
7
+ outfile: string;
8
+ format: 'esm' | 'iife';
9
+ define: Record<string, string>;
10
+ minify: boolean;
11
+ }
12
+ export interface WebextBuildOptions {
13
+ config: ResolvedWebext;
14
+ targets: readonly WebextTarget[];
15
+ /** Absolute; the target folders are `<outDir>/<target>`. */
16
+ outDir: string;
17
+ mode: 'production' | 'development';
18
+ /** Effective defines: the config's, then the command line's. */
19
+ define: Record<string, string>;
20
+ /**
21
+ * Overwrite files instead of starting from an empty folder. `dev` needs it:
22
+ * deleting the folder `web-ext run` watches makes Firefox unload the
23
+ * extension mid-rebuild (measured in beifahrer).
24
+ */
25
+ inPlace: boolean;
26
+ zip: boolean;
27
+ /** Bundle one entry. Injectable so the assembly is testable without a bundler. */
28
+ bundle: (request: BundleRequest) => Promise<void>;
29
+ rasterize?: (input: RasterizeInput) => Promise<Map<number, Uint8Array>>;
30
+ log?: (line: string) => void;
31
+ }
32
+ export interface WebextBuildResult {
33
+ /** target id → absolute folder. */
34
+ folders: Map<string, string>;
35
+ /** target id → absolute zip path (only with `zip`). */
36
+ zips: Map<string, string>;
37
+ }
38
+ /** One `SOURCE_DATE_EPOCH`-aware timestamp for every entry, so a rebuild zips to the same bytes. */
39
+ export declare function zipTimestamp(env?: NodeJS.ProcessEnv): number;
40
+ /** Every file under `dir`, as archive entries relative to it. */
41
+ export declare function zipEntriesOf(dir: string): ZipEntry[];
42
+ export declare function buildWebext(opts: WebextBuildOptions): Promise<WebextBuildResult>;
@@ -0,0 +1,120 @@
1
+ // `gjsify webext build|zip|dev` — assemble one folder per target (ADR 0077 § 4).
2
+ //
3
+ // Every bundle is built ONCE into a staging directory and copied into each
4
+ // target; only the manifest and the icon files differ between targets. That is
5
+ // beifahrer's measured arrangement, and it is also what makes a target cheap:
6
+ // adding `edge-mv3` to a project costs a copy, not a rebuild.
7
+ import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { join, relative } from 'node:path';
9
+ import { buildZip } from '../ship/zip.js';
10
+ import { renderWebextIcons, webextIconPaths, writeWebextIcons } from './icons.js';
11
+ import { checkManifest, composeManifest, loadManifestTemplate } from './manifest.js';
12
+ import { planPage } from './pages.js';
13
+ /** The fixed ZIP timestamp when SOURCE_DATE_EPOCH is unset: 1980-01-01, the format's floor. */
14
+ const ZIP_EPOCH_FLOOR = 315532800;
15
+ /** One `SOURCE_DATE_EPOCH`-aware timestamp for every entry, so a rebuild zips to the same bytes. */
16
+ export function zipTimestamp(env = process.env) {
17
+ const epoch = Number(env.SOURCE_DATE_EPOCH);
18
+ return Number.isFinite(epoch) && epoch >= ZIP_EPOCH_FLOOR ? epoch : ZIP_EPOCH_FLOOR;
19
+ }
20
+ /** Every file under `dir`, as archive entries relative to it. */
21
+ export function zipEntriesOf(dir) {
22
+ const out = [];
23
+ const walk = (current) => {
24
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
25
+ const full = join(current, entry.name);
26
+ if (entry.isDirectory())
27
+ walk(full);
28
+ else
29
+ out.push({ path: relative(dir, full).split(/[\\/]/).join('/'), mode: 0o644, data: readFileSync(full) });
30
+ }
31
+ };
32
+ walk(dir);
33
+ return out;
34
+ }
35
+ export async function buildWebext(opts) {
36
+ const { config, targets, outDir, define } = opts;
37
+ const log = opts.log ?? ((line) => console.log(line));
38
+ const minify = opts.mode === 'production' && config.minify;
39
+ const stage = join(outDir, '.stage');
40
+ rmSync(stage, { recursive: true, force: true });
41
+ mkdirSync(stage, { recursive: true });
42
+ try {
43
+ for (const [name, entry] of config.scripts) {
44
+ await opts.bundle({ entry, outfile: join(stage, `${name}.js`), format: 'iife', define, minify });
45
+ }
46
+ const written = new Map();
47
+ for (const [name, htmlPath] of config.pages) {
48
+ const plan = planPage(name, htmlPath, readFileSync(htmlPath, 'utf8'));
49
+ for (const script of plan.scripts) {
50
+ await opts.bundle({
51
+ entry: script.entry,
52
+ outfile: join(stage, script.output),
53
+ format: script.format,
54
+ define,
55
+ minify,
56
+ });
57
+ }
58
+ for (const sheet of plan.stylesheets) {
59
+ const previous = written.get(sheet.output);
60
+ if (previous !== undefined && previous !== sheet.source) {
61
+ throw new Error(`gjsify webext: pages link ${previous} and ${sheet.source}, which would both be written as ` +
62
+ `${sheet.output}. Rename one.`);
63
+ }
64
+ written.set(sheet.output, sheet.source);
65
+ cpSync(sheet.source, join(stage, sheet.output));
66
+ }
67
+ writeFileSync(join(stage, `${name}.html`), plan.html);
68
+ }
69
+ const icons = config.icons;
70
+ const rendered = icons ? await renderWebextIcons(icons, targets, opts.rasterize) : null;
71
+ const template = await loadManifestTemplate(config.manifest);
72
+ const result = { folders: new Map(), zips: new Map() };
73
+ for (const target of targets) {
74
+ const dir = join(outDir, target.id);
75
+ if (!opts.inPlace)
76
+ rmSync(dir, { recursive: true, force: true });
77
+ mkdirSync(dir, { recursive: true });
78
+ // Public files first, so a built file of the same name wins.
79
+ if (config.publicDir)
80
+ cpSync(config.publicDir, dir, { recursive: true });
81
+ if (config.localesDir)
82
+ cpSync(config.localesDir, join(dir, '_locales'), { recursive: true });
83
+ cpSync(stage, dir, { recursive: true });
84
+ if (icons && rendered)
85
+ writeWebextIcons(rendered, icons, target, dir);
86
+ const ctx = {
87
+ target: target.id,
88
+ browser: target.browser,
89
+ manifestVersion: target.manifestVersion,
90
+ mode: opts.mode,
91
+ version: config.version,
92
+ define,
93
+ icons: (name) => {
94
+ if (!icons) {
95
+ throw new Error(`gjsify webext: the manifest asks for icon "${name}" and \`gjsify.webext.icons\` is not declared.`);
96
+ }
97
+ return webextIconPaths(icons, name, target);
98
+ },
99
+ };
100
+ const manifest = await composeManifest(template, ctx);
101
+ writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
102
+ const problems = checkManifest(manifest, target, dir);
103
+ if (problems.length > 0) {
104
+ throw new Error(`gjsify webext: ${target.id} would not load:\n - ${problems.join('\n - ')}`);
105
+ }
106
+ result.folders.set(target.id, dir);
107
+ log(`built ${relative(process.cwd(), dir) || dir}`);
108
+ if (opts.zip) {
109
+ const file = join(outDir, `${config.name}-${config.version}-${target.id}.zip`);
110
+ writeFileSync(file, buildZip(zipEntriesOf(dir), zipTimestamp()));
111
+ result.zips.set(target.id, file);
112
+ log(`zipped ${relative(process.cwd(), file) || file} (${Math.round(statSync(file).size / 1024)} KiB)`);
113
+ }
114
+ }
115
+ return result;
116
+ }
117
+ finally {
118
+ rmSync(stage, { recursive: true, force: true });
119
+ }
120
+ }
@@ -0,0 +1,72 @@
1
+ import { type WebextTarget } from './targets.js';
2
+ /** A source SVG per icon name: one file, or one per size ceiling (`{ "default": …, "32": … }`). */
3
+ export type WebextIconSource = string | Record<string, string>;
4
+ export interface WebextIconsConfig {
5
+ sizes?: number[];
6
+ sources: Record<string, WebextIconSource>;
7
+ /** Targets that receive the SVG files themselves instead of rendered PNGs. */
8
+ svg?: string[];
9
+ }
10
+ /** The block as a project writes it. */
11
+ export interface WebextConfig {
12
+ name?: string;
13
+ targets?: string[];
14
+ manifest?: string;
15
+ scripts?: Record<string, string>;
16
+ pages?: Record<string, string>;
17
+ icons?: WebextIconsConfig;
18
+ public?: string;
19
+ locales?: string;
20
+ outDir?: string;
21
+ define?: Record<string, string>;
22
+ watch?: string[];
23
+ minify?: boolean;
24
+ }
25
+ export interface ResolvedWebextIcons {
26
+ sizes: number[];
27
+ /** name → absolute SVG path per size. */
28
+ sources: Map<string, Map<number, string>>;
29
+ svgTargets: Set<string>;
30
+ }
31
+ /** The block after validation, with every path absolute. */
32
+ export interface ResolvedWebext {
33
+ root: string;
34
+ name: string;
35
+ version: string;
36
+ targets: WebextTarget[];
37
+ manifest: string;
38
+ scripts: Map<string, string>;
39
+ pages: Map<string, string>;
40
+ icons: ResolvedWebextIcons | null;
41
+ publicDir: string | null;
42
+ localesDir: string | null;
43
+ outDir: string;
44
+ define: Record<string, string>;
45
+ watch: string[];
46
+ minify: boolean;
47
+ }
48
+ export declare const DEFAULT_ICON_SIZES: number[];
49
+ interface PackageShape {
50
+ name?: string;
51
+ version?: string;
52
+ gjsify?: {
53
+ webext?: unknown;
54
+ };
55
+ }
56
+ /** The zip base name a package gets when it declares none: its npm name without the scope. */
57
+ export declare function defaultArtifactName(packageName: string | undefined): string;
58
+ /**
59
+ * Pick the SVG an icon uses at `size`: the smallest numeric ceiling that is
60
+ * `>= size`, else `default`. Returns undefined when neither covers the size.
61
+ */
62
+ export declare function iconSourceForSize(source: WebextIconSource, size: number): string | undefined;
63
+ /**
64
+ * Validate `gjsify.webext` and resolve it against the package root.
65
+ *
66
+ * `exists` is injectable so the specs can describe a project without writing
67
+ * one to disk.
68
+ */
69
+ export declare function resolveWebextConfig(pkg: PackageShape, root: string, exists?: (path: string, kind: 'file' | 'dir') => boolean): ResolvedWebext;
70
+ /** Narrow the configured targets to the ones a command was asked for. */
71
+ export declare function selectTargets(config: ResolvedWebext, requested: readonly string[] | undefined): WebextTarget[];
72
+ export {};