@gjsify/rolldown-plugin-gjsify 0.3.14

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 (89) hide show
  1. package/lib/app/browser.d.ts +17 -0
  2. package/lib/app/browser.js +77 -0
  3. package/lib/app/gjs.d.ts +27 -0
  4. package/lib/app/gjs.js +211 -0
  5. package/lib/app/index.d.ts +6 -0
  6. package/lib/app/index.js +3 -0
  7. package/lib/app/node.d.ts +17 -0
  8. package/lib/app/node.js +102 -0
  9. package/lib/globals.d.ts +4 -0
  10. package/lib/globals.js +9 -0
  11. package/lib/index.d.ts +17 -0
  12. package/lib/index.js +15 -0
  13. package/lib/library/index.d.ts +2 -0
  14. package/lib/library/index.js +1 -0
  15. package/lib/library/lib.d.ts +16 -0
  16. package/lib/library/lib.js +118 -0
  17. package/lib/plugin.d.ts +25 -0
  18. package/lib/plugin.js +67 -0
  19. package/lib/plugins/alias.d.ts +5 -0
  20. package/lib/plugins/alias.js +45 -0
  21. package/lib/plugins/css-as-string.d.ts +2 -0
  22. package/lib/plugins/css-as-string.js +34 -0
  23. package/lib/plugins/gjs-imports-empty.d.ts +2 -0
  24. package/lib/plugins/gjs-imports-empty.js +26 -0
  25. package/lib/plugins/process-stub.d.ts +28 -0
  26. package/lib/plugins/process-stub.js +60 -0
  27. package/lib/plugins/rewrite-node-modules-paths.d.ts +38 -0
  28. package/lib/plugins/rewrite-node-modules-paths.js +132 -0
  29. package/lib/plugins/shebang.d.ts +8 -0
  30. package/lib/plugins/shebang.js +26 -0
  31. package/lib/shims/console-gjs.d.ts +24 -0
  32. package/lib/shims/console-gjs.js +24 -0
  33. package/lib/types/app.d.ts +1 -0
  34. package/lib/types/app.js +1 -0
  35. package/lib/types/index.d.ts +3 -0
  36. package/lib/types/index.js +3 -0
  37. package/lib/types/plugin-options.d.ts +46 -0
  38. package/lib/types/plugin-options.js +1 -0
  39. package/lib/types/resolve-alias-options.d.ts +2 -0
  40. package/lib/types/resolve-alias-options.js +1 -0
  41. package/lib/utils/alias.d.ts +12 -0
  42. package/lib/utils/alias.js +29 -0
  43. package/lib/utils/auto-globals.d.ts +72 -0
  44. package/lib/utils/auto-globals.js +193 -0
  45. package/lib/utils/detect-free-globals.d.ts +18 -0
  46. package/lib/utils/detect-free-globals.js +268 -0
  47. package/lib/utils/entry-points.d.ts +2 -0
  48. package/lib/utils/entry-points.js +38 -0
  49. package/lib/utils/extension.d.ts +1 -0
  50. package/lib/utils/extension.js +7 -0
  51. package/lib/utils/index.d.ts +7 -0
  52. package/lib/utils/index.js +7 -0
  53. package/lib/utils/inline-static-reads.d.ts +11 -0
  54. package/lib/utils/inline-static-reads.js +549 -0
  55. package/lib/utils/merge.d.ts +2 -0
  56. package/lib/utils/merge.js +23 -0
  57. package/lib/utils/scan-globals.d.ts +32 -0
  58. package/lib/utils/scan-globals.js +85 -0
  59. package/package.json +68 -0
  60. package/src/app/browser.ts +102 -0
  61. package/src/app/gjs.ts +260 -0
  62. package/src/app/index.ts +6 -0
  63. package/src/app/node.ts +128 -0
  64. package/src/globals.ts +11 -0
  65. package/src/index.ts +32 -0
  66. package/src/library/index.ts +2 -0
  67. package/src/library/lib.ts +142 -0
  68. package/src/plugin.ts +91 -0
  69. package/src/plugins/alias.ts +53 -0
  70. package/src/plugins/css-as-string.ts +37 -0
  71. package/src/plugins/gjs-imports-empty.ts +29 -0
  72. package/src/plugins/process-stub.ts +91 -0
  73. package/src/plugins/rewrite-node-modules-paths.ts +169 -0
  74. package/src/plugins/shebang.ts +33 -0
  75. package/src/shims/console-gjs.ts +25 -0
  76. package/src/types/app.ts +1 -0
  77. package/src/types/index.ts +3 -0
  78. package/src/types/plugin-options.ts +48 -0
  79. package/src/types/resolve-alias-options.ts +1 -0
  80. package/src/utils/alias.ts +46 -0
  81. package/src/utils/auto-globals.ts +283 -0
  82. package/src/utils/detect-free-globals.ts +278 -0
  83. package/src/utils/entry-points.ts +48 -0
  84. package/src/utils/extension.ts +7 -0
  85. package/src/utils/index.ts +7 -0
  86. package/src/utils/inline-static-reads.ts +541 -0
  87. package/src/utils/merge.ts +22 -0
  88. package/src/utils/scan-globals.ts +91 -0
  89. package/tsconfig.json +16 -0
@@ -0,0 +1,541 @@
1
+ // Build-time inlining of statically-resolvable filesystem reads.
2
+ //
3
+ // Many node_modules packages locate their own resources (own package.json,
4
+ // locales, themes, ...) via `import.meta.url`-relative reads:
5
+ //
6
+ // const pkg = JSON.parse(readFileSync(
7
+ // new URL("../package.json", import.meta.url),
8
+ // "utf8",
9
+ // ));
10
+ //
11
+ // In a bundled GJS executable, `import.meta.url` no longer points at the
12
+ // original `node_modules/<pkg>/<file>` location, so the read fails with
13
+ // ENOENT once the bundle leaves the build site (gjsify dlx, manual move,
14
+ // CI artifact download, …).
15
+ //
16
+ // The clean fix is to evaluate the static expressions at build time and
17
+ // replace the entire `readFileSync(...)` (or `readdirSync(...)`, or the
18
+ // `JSON.parse(readFileSync(...))` composition) with a literal containing
19
+ // the file contents. The bundle is then a single self-contained file that
20
+ // behaves exactly like the original — same return value, same errors on
21
+ // missing files — but with no runtime dependency on the build-site layout.
22
+ //
23
+ // Patterns handled:
24
+ //
25
+ // readFileSync(<URL-derived-path>, "utf8" | "utf-8" | { encoding: "utf8" })
26
+ // → string literal
27
+ // readFileSync(<URL-derived-path>) → Uint8Array literal
28
+ // readdirSync(<URL-derived-path>) → array literal of names
29
+ // JSON.parse(readFileSync(...)) → object literal
30
+ // existsSync(<URL-derived-path>) → boolean literal
31
+ //
32
+ // Path expressions are evaluated against `import.meta.url` of the source
33
+ // file at build time, supporting compositions of:
34
+ //
35
+ // new URL(<lit>, import.meta.url) base resolution
36
+ // <expr>.href, <expr>.pathname property access
37
+ // fileURLToPath(<URL-expr>) url → fs path
38
+ // path.{join,dirname,resolve,basename,relative}(...) path arithmetic
39
+ // string-literal + string-literal concatenation
40
+ //
41
+ // Anything not statically resolvable is left untouched — the legacy
42
+ // `import.meta.url` rewriter still applies as a fallback.
43
+
44
+ import * as acorn from 'acorn';
45
+ import * as walk from 'acorn-walk';
46
+ import { dirname, join, resolve, basename, relative, extname } from 'node:path';
47
+ import { fileURLToPath, pathToFileURL } from 'node:url';
48
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
49
+
50
+ /**
51
+ * One in-place edit on the source string. Stored as half-open `[start, end)`
52
+ * byte offsets so we can apply replacements right-to-left without invalidating
53
+ * earlier offsets.
54
+ */
55
+ interface Edit {
56
+ start: number;
57
+ end: number;
58
+ replacement: string;
59
+ }
60
+
61
+ interface InlineContext {
62
+ /** `import.meta.url` of the source file being inlined (file:// URL). */
63
+ sourceUrl: string;
64
+ }
65
+
66
+ /**
67
+ * Run the inliner on a source string. Returns the rewritten source (or the
68
+ * original string when no inlining applied) and the count of edits applied.
69
+ *
70
+ * Safe to call on any JS source. Files that don't reference `readFileSync` /
71
+ * `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
72
+ */
73
+ export function inlineStaticReads(
74
+ src: string,
75
+ sourceFilePath: string,
76
+ ): { contents: string; inlined: number } {
77
+ if (
78
+ !src.includes('readFileSync') &&
79
+ !src.includes('readdirSync') &&
80
+ !src.includes('existsSync')
81
+ ) {
82
+ return { contents: src, inlined: 0 };
83
+ }
84
+
85
+ let ast: acorn.Program;
86
+ try {
87
+ ast = acorn.parse(src, {
88
+ ecmaVersion: 'latest',
89
+ sourceType: 'module',
90
+ allowAwaitOutsideFunction: true,
91
+ allowReturnOutsideFunction: true,
92
+ allowImportExportEverywhere: true,
93
+ });
94
+ } catch {
95
+ // Source isn't valid JS (CJS source with shebangs, mixed module
96
+ // syntax, ...). Skip; the rest of the rewriter still runs.
97
+ return { contents: src, inlined: 0 };
98
+ }
99
+
100
+ const ctx: InlineContext = {
101
+ sourceUrl: pathToFileURL(sourceFilePath).href,
102
+ };
103
+ const edits: Edit[] = [];
104
+
105
+ walk.simple(ast, {
106
+ CallExpression(node: acorn.CallExpression) {
107
+ const edit = tryInlineCall(node, ctx, src);
108
+ if (edit) edits.push(edit);
109
+ },
110
+ });
111
+
112
+ if (edits.length === 0) return { contents: src, inlined: 0 };
113
+
114
+ // The walker visits both outer and inner CallExpressions, so a successful
115
+ // match on `JSON.parse(readFileSync(...))` produces an edit AT the same
116
+ // time that the inner `readFileSync(...)` also produces one. Applying both
117
+ // would corrupt the output. Keep only edits that are not contained in any
118
+ // other edit (= outermost wins).
119
+ const outermost: Edit[] = [];
120
+ edits.sort((a, b) => a.start - b.start || b.end - a.end);
121
+ for (const e of edits) {
122
+ const last = outermost[outermost.length - 1];
123
+ if (last && e.start >= last.start && e.end <= last.end) continue; // nested
124
+ outermost.push(e);
125
+ }
126
+
127
+ // Apply right-to-left so earlier offsets remain valid.
128
+ outermost.sort((a, b) => b.start - a.start);
129
+ let out = src;
130
+ for (const e of outermost) {
131
+ out = out.slice(0, e.start) + e.replacement + out.slice(e.end);
132
+ }
133
+ return { contents: out, inlined: outermost.length };
134
+ }
135
+
136
+ /**
137
+ * Try to inline a single `CallExpression`. Returns an edit on success, or
138
+ * `undefined` if the call doesn't match an inlinable pattern or the path
139
+ * couldn't be resolved or the file doesn't exist.
140
+ */
141
+ function tryInlineCall(
142
+ node: acorn.CallExpression,
143
+ ctx: InlineContext,
144
+ src: string,
145
+ ): Edit | undefined {
146
+ const callee = node.callee;
147
+
148
+ // `JSON.parse(readFileSync(<path>, "utf8"))` — collapse the whole
149
+ // composition. Recognising it specifically lets us emit a parsed-JSON
150
+ // object literal instead of a `JSON.parse('…')` string-then-parse pair,
151
+ // which esbuild can dead-code-eliminate against.
152
+ if (
153
+ callee.type === 'MemberExpression' &&
154
+ !callee.computed &&
155
+ callee.object.type === 'Identifier' && callee.object.name === 'JSON' &&
156
+ callee.property.type === 'Identifier' && callee.property.name === 'parse' &&
157
+ node.arguments.length >= 1 &&
158
+ node.arguments[0].type === 'CallExpression'
159
+ ) {
160
+ const inner = node.arguments[0] as acorn.CallExpression;
161
+ const innerEdit = tryInlineReadFile(inner, ctx, /*forceTextEncoding*/ true);
162
+ if (innerEdit !== undefined) {
163
+ // `innerEdit` is the literal source for the read result (a JSON
164
+ // string). Parse and re-emit as a JS-literal expression so the
165
+ // surrounding code sees an object directly.
166
+ try {
167
+ const parsed = JSON.parse(JSON.parse(innerEdit));
168
+ return {
169
+ start: node.start,
170
+ end: node.end,
171
+ replacement: jsLiteral(parsed),
172
+ };
173
+ } catch {
174
+ // Fall through — leave the original call alone.
175
+ }
176
+ }
177
+ }
178
+
179
+ const calleeName = identifierName(callee);
180
+
181
+ if (calleeName === 'readFileSync') {
182
+ const replacement = tryInlineReadFile(node, ctx, /*forceTextEncoding*/ false);
183
+ if (replacement !== undefined) {
184
+ return { start: node.start, end: node.end, replacement };
185
+ }
186
+ }
187
+
188
+ if (calleeName === 'readdirSync') {
189
+ const path = evalPathExpr(node.arguments[0], ctx);
190
+ if (path && existsSyncSafe(path) && isDirectorySafe(path)) {
191
+ try {
192
+ const names = readdirSync(path);
193
+ return {
194
+ start: node.start,
195
+ end: node.end,
196
+ replacement: jsLiteral(names),
197
+ };
198
+ } catch {
199
+ /* skip */
200
+ }
201
+ }
202
+ }
203
+
204
+ if (calleeName === 'existsSync') {
205
+ const path = evalPathExpr(node.arguments[0], ctx);
206
+ if (path !== undefined) {
207
+ return {
208
+ start: node.start,
209
+ end: node.end,
210
+ replacement: existsSyncSafe(path) ? 'true' : 'false',
211
+ };
212
+ }
213
+ }
214
+
215
+
216
+ // `createRequire(<URL>)` from `node:module` returns a CJS-style require.
217
+ // In a bundled GJS executable, the deps that the runtime require would
218
+ // resolve are already inlined by esbuild, so the require() function is
219
+ // typically dead code. The createRequire CALL itself runs at module init,
220
+ // and Node's implementation rejects the rewritten URLs we produce when
221
+ // they don't point at an existing file (yargs-parser's `createRequire(
222
+ // import.meta.url)` blows up because the rewritten URL refers to a Yarn
223
+ // PnP zip path that doesn't exist outside the PnP runtime).
224
+ //
225
+ // Replace the call with a stub function: assignment succeeds, the bundle
226
+ // boots, and any actual `require()` invocation produces a clear error
227
+ // instead of an obscure URL-validation crash. Only fires when the URL
228
+ // argument can be statically resolved AND points at a non-existent file
229
+ // — the common case is exactly the broken one.
230
+ if (calleeName === 'createRequire') {
231
+ const path = evalPathExpr(node.arguments[0], ctx);
232
+ // Stub the call when:
233
+ // - the resolved path doesn't exist on disk (build site), OR
234
+ // - the path contains a `.zip/` segment (Yarn PnP virtual zip,
235
+ // where Node's PnP hooks make `existsSync` return true at build
236
+ // time but the path doesn't exist under GJS at runtime).
237
+ const isZip = path !== undefined && path.includes('.zip/');
238
+ if (path !== undefined && (isZip || !existsSyncSafe(path))) {
239
+ return {
240
+ start: node.start,
241
+ end: node.end,
242
+ replacement:
243
+ `(() => { ` +
244
+ `const _r = (id) => { throw new Error("[gjsify] createRequire stub: '" + id + "' was not bundled (anchor path: " + ${jsStringLiteral(path)} + ")"); }; ` +
245
+ `_r.resolve = _r; _r.cache = {}; _r.extensions = {}; _r.main = void 0; ` +
246
+ `return _r; ` +
247
+ `})()`,
248
+ };
249
+ }
250
+ }
251
+
252
+ return undefined;
253
+ }
254
+
255
+ /**
256
+ * Inline a `readFileSync(<path>, <enc>?)` call to a string or byte literal.
257
+ * Returns the source replacement, or `undefined` to leave the call alone.
258
+ *
259
+ * `forceTextEncoding`: caller (JSON.parse wrapper) demands an utf-8 read
260
+ * regardless of whether the syntactic argument provides an encoding.
261
+ */
262
+ function tryInlineReadFile(
263
+ node: acorn.CallExpression,
264
+ ctx: InlineContext,
265
+ forceTextEncoding: boolean,
266
+ ): string | undefined {
267
+ if (node.arguments.length < 1) return undefined;
268
+ const path = evalPathExpr(node.arguments[0], ctx);
269
+ if (!path) return undefined;
270
+ if (!existsSyncSafe(path) || isDirectorySafe(path)) return undefined;
271
+
272
+ let encoding: string | undefined;
273
+ if (forceTextEncoding) {
274
+ encoding = 'utf8';
275
+ } else if (node.arguments.length >= 2) {
276
+ encoding = evalEncodingExpr(node.arguments[1]);
277
+ if (encoding === undefined) return undefined; // unknown → bail
278
+ }
279
+
280
+ try {
281
+ if (encoding) {
282
+ const text = readFileSync(path, encoding as BufferEncoding);
283
+ return jsStringLiteral(text);
284
+ } else {
285
+ // Binary read → emit a Uint8Array constructor over a number array.
286
+ // Buffer-vs-Uint8Array semantic difference is mostly irrelevant in
287
+ // bundled GJS code (Buffer is polyfilled on top of Uint8Array).
288
+ const bytes = readFileSync(path);
289
+ return `new Uint8Array([${Array.from(bytes).join(',')}])`;
290
+ }
291
+ } catch {
292
+ return undefined;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Statically evaluate a node we expect to produce a filesystem path string.
298
+ * Returns the absolute path or `undefined` if any step is non-static.
299
+ *
300
+ * Recursively understands compositions of:
301
+ * - string literals, template literals (no expressions), `+` concatenation
302
+ * - `new URL(<lit>, <base-url-expr>)`
303
+ * - `<URL-expr>.href`, `<URL-expr>.pathname`
304
+ * - `fileURLToPath(<URL-expr>)` / `pathToFileURL(<path>).href`
305
+ * - `(path.)?{join,dirname,resolve,basename,relative,extname}(...)` over static args
306
+ * - `import.meta.url` (resolved against ctx.sourceUrl)
307
+ * - bare identifier `__dirname` / `__filename` (resolved against ctx.sourceUrl)
308
+ *
309
+ * Returns a path string OR a URL string, depending on context — callers
310
+ * that need a path use `evalPathExpr`, callers that need a URL use
311
+ * `evalUrlExpr`. They both come from the same recursive evaluator.
312
+ */
313
+ function evalPathExpr(node: acorn.AnyNode | undefined, ctx: InlineContext): string | undefined {
314
+ const v = evalExpr(node, ctx);
315
+ if (v instanceof URL) {
316
+ if (v.protocol === 'file:') return fileURLToPath(v);
317
+ return undefined;
318
+ }
319
+ if (typeof v !== 'string') return undefined;
320
+ if (v.startsWith('file://')) return fileURLToPath(v);
321
+ if (v.startsWith('/')) return v;
322
+ return undefined;
323
+ }
324
+
325
+ type EvalValue = string | URL | undefined;
326
+
327
+ function evalExpr(node: acorn.AnyNode | undefined, ctx: InlineContext): EvalValue {
328
+ if (!node) return undefined;
329
+
330
+ switch (node.type) {
331
+ case 'Literal':
332
+ if (typeof (node as acorn.Literal).value === 'string') {
333
+ return (node as acorn.Literal).value as string;
334
+ }
335
+ return undefined;
336
+
337
+ case 'TemplateLiteral': {
338
+ const tl = node as acorn.TemplateLiteral;
339
+ if (tl.expressions.length > 0) return undefined;
340
+ return tl.quasis.map((q) => q.value.cooked ?? '').join('');
341
+ }
342
+
343
+ case 'BinaryExpression': {
344
+ const be = node as acorn.BinaryExpression;
345
+ if (be.operator !== '+') return undefined;
346
+ const l = evalExpr(be.left, ctx);
347
+ const r = evalExpr(be.right, ctx);
348
+ if (typeof l !== 'string' || typeof r !== 'string') return undefined;
349
+ return l + r;
350
+ }
351
+
352
+ case 'Identifier': {
353
+ const id = node as acorn.Identifier;
354
+ if (id.name === '__dirname') return fileURLToPath(new URL('.', ctx.sourceUrl));
355
+ if (id.name === '__filename') return fileURLToPath(ctx.sourceUrl);
356
+ return undefined;
357
+ }
358
+
359
+ case 'MemberExpression': {
360
+ const me = node as acorn.MemberExpression;
361
+ // import.meta.url
362
+ if (
363
+ me.object.type === 'MetaProperty' &&
364
+ (me.object as acorn.MetaProperty).meta.name === 'import' &&
365
+ (me.object as acorn.MetaProperty).property.name === 'meta' &&
366
+ me.property.type === 'Identifier' &&
367
+ (me.property as acorn.Identifier).name === 'url'
368
+ ) {
369
+ return ctx.sourceUrl;
370
+ }
371
+ // <expr>.href / .pathname
372
+ if (!me.computed && me.property.type === 'Identifier') {
373
+ const obj = evalExpr(me.object, ctx);
374
+ const prop = (me.property as acorn.Identifier).name;
375
+ if (obj instanceof URL) {
376
+ if (prop === 'href') return obj.href;
377
+ if (prop === 'pathname') return obj.pathname;
378
+ }
379
+ if (typeof obj === 'string') {
380
+ if (prop === 'href') return obj; // already a URL string
381
+ if (prop === 'pathname') {
382
+ try { return new URL(obj).pathname; } catch { return undefined; }
383
+ }
384
+ }
385
+ }
386
+ return undefined;
387
+ }
388
+
389
+ case 'NewExpression': {
390
+ const ne = node as acorn.NewExpression;
391
+ const calleeName = identifierName(ne.callee);
392
+ if (calleeName === 'URL') {
393
+ if (ne.arguments.length === 0) return undefined;
394
+ const first = evalExpr(ne.arguments[0], ctx);
395
+ if (typeof first !== 'string') return undefined;
396
+ if (ne.arguments.length === 1) {
397
+ try { return new URL(first); } catch { return undefined; }
398
+ }
399
+ const base = evalExpr(ne.arguments[1], ctx);
400
+ const baseStr = base instanceof URL ? base.href : (typeof base === 'string' ? base : undefined);
401
+ if (!baseStr) return undefined;
402
+ try { return new URL(first, baseStr); } catch { return undefined; }
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ case 'CallExpression': {
408
+ const ce = node as acorn.CallExpression;
409
+ const name = identifierName(ce.callee);
410
+
411
+ if (name === 'fileURLToPath') {
412
+ const arg = evalExpr(ce.arguments[0], ctx);
413
+ const url = arg instanceof URL ? arg.href : (typeof arg === 'string' ? arg : undefined);
414
+ if (!url) return undefined;
415
+ try { return fileURLToPath(url); } catch { return undefined; }
416
+ }
417
+
418
+ if (name === 'pathToFileURL') {
419
+ const arg = evalExpr(ce.arguments[0], ctx);
420
+ if (typeof arg !== 'string') return undefined;
421
+ try { return pathToFileURL(arg); } catch { return undefined; }
422
+ }
423
+
424
+ if (name === 'join' || name === 'resolve') {
425
+ const args: string[] = [];
426
+ for (const a of ce.arguments) {
427
+ const v = evalExpr(a, ctx);
428
+ if (typeof v !== 'string') return undefined;
429
+ args.push(v);
430
+ }
431
+ return name === 'join' ? join(...args) : resolve(...args);
432
+ }
433
+
434
+ if (name === 'dirname' || name === 'basename' || name === 'extname') {
435
+ const v = evalExpr(ce.arguments[0], ctx);
436
+ if (typeof v !== 'string') return undefined;
437
+ if (name === 'dirname') return dirname(v);
438
+ if (name === 'basename') {
439
+ const ext = ce.arguments.length >= 2 ? evalExpr(ce.arguments[1], ctx) : undefined;
440
+ return basename(v, typeof ext === 'string' ? ext : undefined);
441
+ }
442
+ if (name === 'extname') return extname(v);
443
+ }
444
+
445
+ if (name === 'relative') {
446
+ const a = evalExpr(ce.arguments[0], ctx);
447
+ const b = evalExpr(ce.arguments[1], ctx);
448
+ if (typeof a !== 'string' || typeof b !== 'string') return undefined;
449
+ return relative(a, b);
450
+ }
451
+
452
+ return undefined;
453
+ }
454
+ }
455
+ return undefined;
456
+ }
457
+
458
+ /**
459
+ * Evaluate an encoding argument to its canonical string form.
460
+ * "utf8" / "utf-8" → "utf8"
461
+ * { encoding: "utf8" } → "utf8"
462
+ * anything else → undefined (caller leaves the call alone)
463
+ */
464
+ function evalEncodingExpr(node: acorn.AnyNode | undefined): string | undefined {
465
+ if (!node) return undefined;
466
+ if (node.type === 'Literal') {
467
+ const v = (node as acorn.Literal).value;
468
+ if (typeof v === 'string') return canonicalEncoding(v);
469
+ return undefined;
470
+ }
471
+ if (node.type === 'ObjectExpression') {
472
+ for (const p of (node as acorn.ObjectExpression).properties) {
473
+ if (p.type !== 'Property' || p.computed) continue;
474
+ const key = p.key.type === 'Identifier'
475
+ ? (p.key as acorn.Identifier).name
476
+ : p.key.type === 'Literal' ? String((p.key as acorn.Literal).value) : undefined;
477
+ if (key !== 'encoding') continue;
478
+ if (p.value.type === 'Literal' && typeof (p.value as acorn.Literal).value === 'string') {
479
+ return canonicalEncoding((p.value as acorn.Literal).value as string);
480
+ }
481
+ return undefined;
482
+ }
483
+ }
484
+ return undefined;
485
+ }
486
+
487
+ function canonicalEncoding(v: string): string | undefined {
488
+ const lc = v.toLowerCase();
489
+ if (lc === 'utf8' || lc === 'utf-8') return 'utf8';
490
+ if (lc === 'ascii') return 'ascii';
491
+ if (lc === 'latin1' || lc === 'binary') return 'latin1';
492
+ return undefined;
493
+ }
494
+
495
+ /**
496
+ * Get the leaf identifier name of a callee. Recognises:
497
+ * `foo` → "foo"
498
+ * `path.foo` → "foo"
499
+ * `node:path.foo` → "foo" (rare)
500
+ * `fs.foo` / `fs.promises.foo` → "foo"
501
+ * Returns `undefined` for computed/dynamic callees.
502
+ */
503
+ function identifierName(node: acorn.AnyNode | undefined): string | undefined {
504
+ if (!node) return undefined;
505
+ if (node.type === 'Identifier') return (node as acorn.Identifier).name;
506
+ if (node.type === 'MemberExpression' && !(node as acorn.MemberExpression).computed) {
507
+ const me = node as acorn.MemberExpression;
508
+ if (me.property.type === 'Identifier') return (me.property as acorn.Identifier).name;
509
+ }
510
+ return undefined;
511
+ }
512
+
513
+ /** Produce a JS source-fragment for a value the inliner produced. */
514
+ function jsLiteral(v: unknown): string {
515
+ if (typeof v === 'string') return jsStringLiteral(v);
516
+ if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
517
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
518
+ if (v === null) return 'null';
519
+ if (Array.isArray(v)) return '[' + v.map(jsLiteral).join(',') + ']';
520
+ if (typeof v === 'object') {
521
+ const parts: string[] = [];
522
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
523
+ parts.push(`${jsStringLiteral(k)}:${jsLiteral(val)}`);
524
+ }
525
+ return '{' + parts.join(',') + '}';
526
+ }
527
+ return 'undefined';
528
+ }
529
+
530
+ /** JSON.stringify is the safest way to escape arbitrary strings into JS. */
531
+ function jsStringLiteral(s: string): string {
532
+ return JSON.stringify(s);
533
+ }
534
+
535
+ function existsSyncSafe(path: string): boolean {
536
+ try { return existsSync(path); } catch { return false; }
537
+ }
538
+
539
+ function isDirectorySafe(path: string): boolean {
540
+ try { return statSync(path).isDirectory(); } catch { return false; }
541
+ }
@@ -0,0 +1,22 @@
1
+ /** Deep merge objects (replaces lodash.merge) */
2
+ export function merge<T extends Record<string, any>>(target: T, ...sources: Record<string, any>[]): T {
3
+ for (const source of sources) {
4
+ if (!source) continue;
5
+ for (const key of Object.keys(source)) {
6
+ const targetVal = (target as any)[key];
7
+ const sourceVal = source[key];
8
+ if (sourceVal !== undefined) {
9
+ if (isPlainObject(targetVal) && isPlainObject(sourceVal)) {
10
+ merge(targetVal, sourceVal);
11
+ } else {
12
+ (target as any)[key] = sourceVal;
13
+ }
14
+ }
15
+ }
16
+ }
17
+ return target;
18
+ }
19
+
20
+ function isPlainObject(val: unknown): val is Record<string, any> {
21
+ return typeof val === 'object' && val !== null && !Array.isArray(val) && Object.getPrototypeOf(val) === Object.prototype;
22
+ }
@@ -0,0 +1,91 @@
1
+ // Explicit `--globals` CLI flag support.
2
+ //
3
+ // This module resolves a user-provided comma-separated list of global
4
+ // identifiers (e.g. `fetch,Buffer,process,URL,crypto`) into the
5
+ // corresponding set of `@gjsify/<pkg>/register` subpaths and writes an
6
+ // ESM stub file that the esbuild plugin injects via its
7
+ // `autoGlobalsInject` option.
8
+ //
9
+ // gjsify does NOT scan user code to guess which globals are needed —
10
+ // the user declares them explicitly via `gjsify build --globals <list>`
11
+ // (or via the default script scaffolded by `@gjsify/create-app`). See
12
+ // the "Tree-shakeable Globals" section in AGENTS.md for the rationale.
13
+
14
+ import { writeFile, mkdir } from 'node:fs/promises';
15
+ import { createHash } from 'node:crypto';
16
+ import { join } from 'node:path';
17
+
18
+ import { GJS_GLOBALS_MAP, GJS_GLOBALS_GROUPS } from '@gjsify/resolve-npm/globals-map';
19
+
20
+ const GLOBALS_MAP: Record<string, string> = GJS_GLOBALS_MAP;
21
+ const GLOBALS_GROUPS: Record<string, string[]> = GJS_GLOBALS_GROUPS;
22
+
23
+ /**
24
+ * Resolve a `--globals` CLI argument into the set of `/register` subpaths
25
+ * that must be injected into the build.
26
+ *
27
+ * The argument is a comma-separated list of identifiers or group names.
28
+ * Group names (`node`, `web`, `dom`) expand to all identifiers in that group.
29
+ * Unknown tokens are silently ignored. Empty or whitespace-only input returns
30
+ * an empty set.
31
+ *
32
+ * Examples:
33
+ * resolveGlobalsList('fetch,Buffer,process')
34
+ * → Set { 'fetch/register', '@gjsify/buffer/register', '@gjsify/node-globals/register' }
35
+ *
36
+ * resolveGlobalsList('node,web')
37
+ * → Set { '@gjsify/buffer/register', '@gjsify/node-globals/register', 'fetch/register', … }
38
+ *
39
+ * resolveGlobalsList('')
40
+ * → Set { }
41
+ */
42
+ export function resolveGlobalsList(globalsArg: string): Set<string> {
43
+ const result = new Set<string>();
44
+ const trimmed = globalsArg.trim();
45
+ if (!trimmed) return result;
46
+
47
+ for (const rawToken of trimmed.split(',')) {
48
+ const token = rawToken.trim();
49
+ if (!token) continue;
50
+ const group = GLOBALS_GROUPS[token];
51
+ if (group) {
52
+ for (const id of group) {
53
+ const path = GLOBALS_MAP[id];
54
+ if (path) result.add(path);
55
+ }
56
+ } else {
57
+ const path = GLOBALS_MAP[token];
58
+ if (path) result.add(path);
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Write a stub ESM file with `import` statements for the given register
66
+ * paths and return its absolute path, suitable for passing to esbuild's
67
+ * `inject` option via the plugin's `autoGlobalsInject` field.
68
+ *
69
+ * The file lives inside `<cwd>/node_modules/.cache/gjsify/` so esbuild's
70
+ * module resolver can follow the bare specifiers in the generated imports.
71
+ *
72
+ * The file name is hashed by content so repeated builds with the same
73
+ * set reuse the same file (no churn, idempotent on disk).
74
+ */
75
+ export async function writeRegisterInjectFile(
76
+ registerPaths: Set<string>,
77
+ cwd: string = process.cwd(),
78
+ ): Promise<string | null> {
79
+ if (registerPaths.size === 0) return null;
80
+
81
+ const sorted = [...registerPaths].sort();
82
+ const content = sorted.map((p) => `import '${p}';`).join('\n') + '\n';
83
+ const hash = createHash('sha1').update(content).digest('hex').slice(0, 10);
84
+
85
+ const cacheDir = join(cwd, 'node_modules', '.cache', 'gjsify');
86
+ await mkdir(cacheDir, { recursive: true });
87
+
88
+ const path = join(cacheDir, `auto-globals-${hash}.mjs`);
89
+ await writeFile(path, content, 'utf-8');
90
+ return path;
91
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "rootDir": "src",
4
+ "outDir": "lib",
5
+ "declaration": true,
6
+ "target": "ESNext",
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "lib": ["ESNext", "DOM"],
10
+ "strict": false,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "types": ["node"]
14
+ },
15
+ "include": ["src/**/*"]
16
+ }