@gjsify/rolldown-plugin-gjsify 0.41.0 → 0.43.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.
@@ -18,6 +18,12 @@
18
18
  //
19
19
  // Hosted as a `transform(code, id)` hook with `order: 'post'`: after the
20
20
  // deepkit/blueprint/css pre-transforms, still during module loading, before chunking.
21
+ //
22
+ // TWO SCOPES, one transform. The four cases above are node_modules-only, because
23
+ // they are about a file whose recorded location stopped being true. The static-read
24
+ // INLINER that runs alongside them is not: it folds build-time-resolvable reads in
25
+ // ANY source, first-party included, and it was trapped inside the node_modules gate
26
+ // until 2026-08-22 — see `shouldInline` for the incident that measured the cost.
21
27
  import { createRequire } from 'node:module';
22
28
  import { dirname, join, relative, resolve } from 'node:path';
23
29
  import { inlineStaticReads } from '../utils/inline-static-reads.js';
@@ -39,6 +45,40 @@ export function shouldRewrite(path) {
39
45
  return false;
40
46
  return true;
41
47
  }
48
+ /**
49
+ * True when the static-read INLINER wants to look at this path — any supported
50
+ * source, first-party included.
51
+ *
52
+ * Deliberately wider than {@link shouldRewrite}, because the two answer different
53
+ * questions. The inliner asks *is this read resolvable at build time*, which is a
54
+ * property of the expression and can be true in any file. The rewriter asks *does
55
+ * this file's `import.meta.url` still mean anything once bundled*, which is only
56
+ * worth asking of a file whose original location was `node_modules` — the whole
57
+ * subject of this plugin.
58
+ *
59
+ * They were ONE gate until 2026-08-22, and the cost was silent. Each of
60
+ * `@gjsify/cli`'s own six `readFileSync(new URL(<literal>, import.meta.url))`
61
+ * template loaders shipped into `dist/cli.gjs.mjs` as a LIVE read pointing at a
62
+ * `templates/` directory that exists neither in the repo (the sources are in
63
+ * `src/templates/`; the build copies them to `lib/templates/`) nor in the
64
+ * published tarball (`files` ships `lib`, not a root `templates`). Measured on a
65
+ * fresh build: `gjs -m dist/cli.gjs.mjs ship --stage` died with
66
+ * `ENOENT … packages/infra/cli/templates/app/desktop.tmpl` while the identical
67
+ * command through `lib/index.js` staged the desktop entry. Two of those six files
68
+ * carry a comment asserting the inliner handles them, so reading the code
69
+ * confirmed the belief rather than the behaviour.
70
+ *
71
+ * Consequence for a CONSUMER's build, stated because it is a behaviour change: a
72
+ * statically resolvable read in their own source is baked into their bundle too.
73
+ * That is the promise the inliner exists to make — a bundle that survives leaving
74
+ * its build site — and a read meant to stay dynamic keeps working by not being
75
+ * statically resolvable, since anything the evaluator cannot fold is left alone.
76
+ */
77
+ export function shouldInline(path) {
78
+ if (!REWRITE_FILTER.test(path))
79
+ return false;
80
+ return !GJSIFY_SHIM_RE.test(path);
81
+ }
42
82
  /** The directory the bundle's outfile lives in, from `output.file` or `output.dir`. */
43
83
  export function getBundleDirFromOutput(opts) {
44
84
  const outFile = opts.file ?? join(opts.dir ?? '.', 'bundle.mjs');
@@ -265,6 +305,11 @@ export function rewriteContents(args, srcInput, bundleDir, runtimeResolve) {
265
305
  /**
266
306
  * Build a Rolldown plugin that runs the path rewriter as a `transform(code, id)`
267
307
  * hook with `order: 'post'`.
308
+ *
309
+ * Two scopes in one transform, not two plugins: a second transform would parse
310
+ * every module a second time, and the inliner's own fast path (skip unless the
311
+ * source mentions `readFileSync`/`readdirSync`/`existsSync`) already makes the
312
+ * wider scope nearly free. See {@link shouldInline} for why the scopes differ.
268
313
  */
269
314
  export function nodeModulesPathRewritePlugin(options) {
270
315
  const runtimeResolve = options.runtimeResolve ?? false;
@@ -274,12 +319,19 @@ export function nodeModulesPathRewritePlugin(options) {
274
319
  order: 'post',
275
320
  filter: { id: REWRITE_FILTER },
276
321
  handler(code, id) {
277
- if (!id.includes('node_modules'))
278
- return null;
279
- const result = rewriteContents({ path: id }, code, options.bundleDir, runtimeResolve);
280
- if (!result)
322
+ if (id.includes('node_modules')) {
323
+ const result = rewriteContents({ path: id }, code, options.bundleDir, runtimeResolve);
324
+ if (!result)
325
+ return null;
326
+ return { code: result.code, map: null };
327
+ }
328
+ // First-party source: inline statically resolvable reads, and
329
+ // nothing else. `import.meta.url` here is not rewritten — that
330
+ // is the node_modules question this plugin was written for.
331
+ if (!shouldInline(id))
281
332
  return null;
282
- return { code: result.code, map: null };
333
+ const inlined = inlineStaticReads(code, id);
334
+ return inlined.inlined > 0 ? { code: inlined.contents, map: null } : null;
283
335
  },
284
336
  },
285
337
  };
@@ -16,6 +16,43 @@ export interface WorkspaceImportGuardOptions {
16
16
  * (plus exact-name membership) so the two cannot drift.
17
17
  */
18
18
  isExternal?: (id: string) => boolean;
19
+ /**
20
+ * Last-resort anchor for a `@gjsify/*` the PROJECT cannot resolve — set only
21
+ * when the thing being bundled is TOOLCHAIN rather than user code, and unset
22
+ * for every ordinary `gjsify build`.
23
+ *
24
+ * A repo build script (`node scripts/x.mjs`, re-entered as `gjsify run
25
+ * --node-script` on a Node-less host) does not need the WORKSPACE's copy of
26
+ * `@gjsify/fs` — it needs a working one. Anchored at the script, resolution
27
+ * finds the workspace symlink, whose `lib/esm` a cold clone has not built
28
+ * yet; anchored additionally at the running CLI, it finds the copy installed
29
+ * beside it, which is built by construction.
30
+ *
31
+ * Measured on postmarketOS/aarch64 (gjs 1.88.1, no node): without this the
32
+ * ADR 0002 bootstrap fails at its third step, because `--globals auto` pulls
33
+ * the whole `@gjsify/<pkg>/register` closure into a one-line script (the
34
+ * count and the incident are in this package's AGENTS.md § toolchainAnchor).
35
+ * The alternative is building most of the polyfill tree before the first
36
+ * script runs, on every host including the ones that never bundle one.
37
+ *
38
+ * The value is a FILE PATH inside the running CLI's own install, used as the
39
+ * IMPORTER of the fallback resolve — not a second resolver. That matters:
40
+ * `--app gjs` deliberately omits both `import` and `require` from
41
+ * `conditionNames` (`app/gjs.ts`), and a `createRequire().resolve()` here
42
+ * would resolve under `require` and hand a `cjs-compat.cjs` shim to a GJS
43
+ * bundle for the ~3 `@gjsify/*` packages that declare a `require` branch.
44
+ * Going back through `this.resolve` uses the target's own condition set by
45
+ * construction, so the rescue can only pick the entry the build would have
46
+ * picked. The precedent is `commands/tsc.ts`, which resolves
47
+ * `@gjsify/tsc/bundle` from the project first and the running CLI second for
48
+ * exactly this reason; `tests/e2e/node-free-bootstrap` states that invariant.
49
+ *
50
+ * PROJECT FIRST, ALWAYS: consulted only after normal resolution returns
51
+ * null, so a workspace that HAS the package keeps using it and nothing about
52
+ * a warm tree changes. A project resolve that ERRORED is not a null and is
53
+ * never rescued — see the handler.
54
+ */
55
+ toolchainAnchor?: string;
19
56
  }
20
57
  /**
21
58
  * Is `id` a bare `@gjsify/*` package specifier (root or subpath)? Excludes disk
@@ -62,11 +99,16 @@ export interface UnresolvedWorkspaceImportDetails {
62
99
  */
63
100
  aliasedFrom?: readonly string[];
64
101
  importer: string;
102
+ /**
103
+ * Set when `this.resolve` THREW instead of answering — the message then names
104
+ * it rather than listing three causes that cannot apply.
105
+ */
106
+ resolverFailure?: string;
65
107
  }
66
108
  /** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
67
109
  export declare class UnresolvedWorkspaceImportError extends Error {
68
110
  readonly details: UnresolvedWorkspaceImportDetails;
69
- constructor(details: UnresolvedWorkspaceImportDetails);
111
+ constructor(details: UnresolvedWorkspaceImportDetails, cause?: unknown);
70
112
  }
71
113
  /**
72
114
  * The actionable message: names the specifier, the importer and the likely cause —
@@ -82,6 +124,10 @@ export declare function buildReverseAliasIndex(aliases: Record<string, string> |
82
124
  * imports-empty/externals at normal) gets first refusal — only ids NOTHING claimed
83
125
  * reach this hook. It then re-runs the resolution Rolldown would have run next
84
126
  * (`this.resolve(…, { skipSelf })`) and returns that, so a healthy build resolves
85
- * each specifier exactly once and behaves identically. Only a genuine `null` throws.
127
+ * each specifier exactly once and behaves identically.
128
+ *
129
+ * A genuine `null` throws — unless `toolchainAnchor` is set and the CLI's own install
130
+ * has it. A THROW is not a `null`: it is rethrown with this plugin's context and the
131
+ * original as `cause`, never rescued and never reported as a missing dependency.
86
132
  */
87
133
  export declare function unresolvedWorkspaceImportPlugin(options: WorkspaceImportGuardOptions): Plugin;
@@ -72,8 +72,8 @@ export function classifyImport(input) {
72
72
  /** A `@gjsify/*` edge the build promised to substitute could not be resolved. */
73
73
  export class UnresolvedWorkspaceImportError extends Error {
74
74
  details;
75
- constructor(details) {
76
- super(formatUnresolvedWorkspaceImport(details));
75
+ constructor(details, cause) {
76
+ super(formatUnresolvedWorkspaceImport(details), cause === undefined ? undefined : { cause });
77
77
  this.name = 'UnresolvedWorkspaceImportError';
78
78
  this.details = details;
79
79
  }
@@ -83,7 +83,7 @@ export class UnresolvedWorkspaceImportError extends Error {
83
83
  * the three things `UNRESOLVED_IMPORT` and the post-hoc `node:` guard cannot.
84
84
  */
85
85
  export function formatUnresolvedWorkspaceImport(details) {
86
- const { target, source, candidate, aliasTarget, aliasedFrom, importer } = details;
86
+ const { target, source, candidate, aliasTarget, aliasedFrom, importer, resolverFailure } = details;
87
87
  // The specifiers `candidate` stood in for: `source` when IT carried the table
88
88
  // entry, else the reverse lookup — but only when its fan-in is SMALL. A shared
89
89
  // sink inverts to a wall of text (`@gjsify/empty` is the browser target for 49
@@ -114,11 +114,18 @@ export function formatUnresolvedWorkspaceImport(details) {
114
114
  `\`--app ${target}\` runtime cannot resolve.`
115
115
  : `Leaving it unresolved would silently externalise the ORIGINAL ${substitutedFor.length === 1 ? 'specifier' : 'specifiers'} ${quoted} into the ` +
116
116
  `bundle, defeating the substitution the \`--app ${target}\` target exists to perform.`) + symptom;
117
+ // A resolver that FAILED is not a resolver that found nothing, and the three
118
+ // likely causes below are all wrong for it: an EACCES on `node_modules`, a
119
+ // corrupt `package.json`, or a deliberate throw from another `resolveId` hook
120
+ // (`napi-node-addon`'s `resolveAddonPath`) would otherwise be reported as the
121
+ // user's missing dependency, with the real error gone from the process.
122
+ const failure = resolverFailure === undefined ? [] : ['', `The project resolver failed with: ${resolverFailure}`];
117
123
  return [
118
124
  headline,
119
125
  ` imported by: ${importer}`,
120
126
  '',
121
127
  consequence,
128
+ ...failure,
122
129
  '',
123
130
  'Likely causes:',
124
131
  ' - `node_modules` is missing or incomplete — run `gjsify install` (`--immutable` in CI).',
@@ -143,6 +150,10 @@ export function buildReverseAliasIndex(aliases) {
143
150
  list.sort();
144
151
  return index;
145
152
  }
153
+ /** One line for the message; the untouched error rides along as `cause`. */
154
+ function describeResolverFailure(err) {
155
+ return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
156
+ }
146
157
  /**
147
158
  * Refuse a build whose `@gjsify/*` substitution could not be resolved.
148
159
  *
@@ -150,10 +161,14 @@ export function buildReverseAliasIndex(aliases) {
150
161
  * imports-empty/externals at normal) gets first refusal — only ids NOTHING claimed
151
162
  * reach this hook. It then re-runs the resolution Rolldown would have run next
152
163
  * (`this.resolve(…, { skipSelf })`) and returns that, so a healthy build resolves
153
- * each specifier exactly once and behaves identically. Only a genuine `null` throws.
164
+ * each specifier exactly once and behaves identically.
165
+ *
166
+ * A genuine `null` throws — unless `toolchainAnchor` is set and the CLI's own install
167
+ * has it. A THROW is not a `null`: it is rethrown with this plugin's context and the
168
+ * original as `cause`, never rescued and never reported as a missing dependency.
154
169
  */
155
170
  export function unresolvedWorkspaceImportPlugin(options) {
156
- const { target, aliases, isExternal } = options;
171
+ const { target, aliases, isExternal, toolchainAnchor } = options;
157
172
  // Re-entrancy belt: `skipSelf: true` should keep our own `this.resolve` out of
158
173
  // this hook, but a hang is a far worse failure than a missed check. Keyed by
159
174
  // `candidate\0importer` — the same package legitimately resolves from many.
@@ -185,6 +200,12 @@ export function unresolvedWorkspaceImportPlugin(options) {
185
200
  const importer = typeof rawImporter === 'string' ? rawImporter : undefined;
186
201
  if (extraOptions?.isEntry)
187
202
  return null;
203
+ // The toolchain fallback below re-enters this hook with the anchor as
204
+ // the importer. Declining there keeps the probe from rescuing itself:
205
+ // the anchor cannot answer for the anchor, and the recursion has no
206
+ // bound of its own.
207
+ if (toolchainAnchor !== undefined && importer === toolchainAnchor)
208
+ return null;
188
209
  const verdict = classifyImport({ source, importer, aliases, isExternal });
189
210
  if (verdict.verdict === 'ignore')
190
211
  return null;
@@ -198,13 +219,29 @@ export function unresolvedWorkspaceImportPlugin(options) {
198
219
  if (inFlight.has(key))
199
220
  return null;
200
221
  inFlight.add(key);
201
- let resolved;
222
+ let resolved = null;
223
+ // KEPT DELIBERATELY, and NOT as a miss. `this.resolve` re-runs the whole
224
+ // `pre`-order chain, so it can fail two very different ways. "Nothing is
225
+ // there" is `null` on both engines — npm `rolldown` always answered that,
226
+ // and `@gjsify/rolldown-native` now does too (`isResolveMiss`, which is
227
+ // where the engine mismatch that hid this plugin's diagnostic under GJS
228
+ // is fixed). What reaches this catch is the OTHER kind: a hook in that
229
+ // chain throwing on purpose (`napi-node-addon`'s `resolveAddonPath`), an
230
+ // EACCES, a corrupt `package.json`, a bridge fault. Letting it escape
231
+ // surfaces it as a bare "plugin `gjsify-alias` threw an error" naming
232
+ // neither specifier nor importer; swallowing it would report a real fault
233
+ // as the user's missing dependency. So it is caught, kept, and thrown
234
+ // BELOW with this plugin's context around it and the original as `cause`.
235
+ let resolverFailure;
202
236
  try {
203
237
  resolved = await this.resolve(verdict.candidate, importer, {
204
238
  skipSelf: true,
205
239
  kind: extraOptions?.kind,
206
240
  });
207
241
  }
242
+ catch (err) {
243
+ resolverFailure = err;
244
+ }
208
245
  finally {
209
246
  inFlight.delete(key);
210
247
  }
@@ -212,6 +249,30 @@ export function unresolvedWorkspaceImportPlugin(options) {
212
249
  resolvedCache.set(key, resolved);
213
250
  return resolved;
214
251
  }
252
+ // Toolchain fallback — see `toolchainAnchor`. Only after a real `null`:
253
+ // a resolver that ERRORED has not established that the project lacks the
254
+ // package, and rescuing there would turn "project first, always" into
255
+ // "project first unless the project errors".
256
+ if (resolverFailure === undefined && toolchainAnchor !== undefined) {
257
+ const fromToolchain = await this.resolve(verdict.candidate, toolchainAnchor, {
258
+ skipSelf: true,
259
+ kind: extraOptions?.kind,
260
+ });
261
+ if (fromToolchain) {
262
+ // The one visible trace that this artifact is MIXED — some of its
263
+ // `@gjsify/*` from the project, this one from the CLI's own
264
+ // install, possibly a different major. Without it a rescued build
265
+ // and a healthy one have byte-identical logs, and a wrong answer
266
+ // that leaves no trace is the expensive kind. Deduplicated by
267
+ // `resolvedCache`: one line per (candidate, importer dir, kind).
268
+ this.warn(`gjsify: \`${verdict.candidate}\` did not resolve from the project ` +
269
+ `(imported by ${importer ?? '<entry>'}) — using the copy installed beside the ` +
270
+ `running CLI (${toolchainAnchor}). Build the workspace package to use the ` +
271
+ `project's own copy.`);
272
+ resolvedCache.set(key, fromToolchain);
273
+ return fromToolchain;
274
+ }
275
+ }
215
276
  throw new UnresolvedWorkspaceImportError({
216
277
  target,
217
278
  source,
@@ -219,7 +280,10 @@ export function unresolvedWorkspaceImportPlugin(options) {
219
280
  aliasTarget: verdict.aliasTarget,
220
281
  aliasedFrom: reverseAliases.get(verdict.candidate)?.filter((s) => s !== source),
221
282
  importer: importer ?? '<entry>',
222
- });
283
+ ...(resolverFailure === undefined
284
+ ? {}
285
+ : { resolverFailure: describeResolverFailure(resolverFailure) }),
286
+ }, resolverFailure);
223
287
  },
224
288
  },
225
289
  };
@@ -67,4 +67,16 @@ export interface PluginOptions {
67
67
  * the wrapper. Defaults to `false`.
68
68
  */
69
69
  preserveDefaultExport?: boolean;
70
+ /**
71
+ * Where to look for a `@gjsify/*` the PROJECT cannot resolve, when the input is
72
+ * TOOLCHAIN rather than user code: a file path inside the running CLI's own
73
+ * install, used as the importer of a second resolve.
74
+ *
75
+ * OPT-IN PER CALL SITE, never inferred from the code path. `gjsify build` never
76
+ * sets it, and neither does a `node <file>` in an ordinary project's package
77
+ * script — a user's application must still fail loudly on a missing dependency
78
+ * rather than silently pick up whatever version the CLI ships. Rationale and the
79
+ * measured incident: `WorkspaceImportGuardOptions.toolchainAnchor`.
80
+ */
81
+ toolchainAnchor?: string;
70
82
  }
@@ -9,6 +9,7 @@
9
9
  import * as acorn from 'acorn';
10
10
  import * as walk from 'acorn-walk';
11
11
  import { GJS_GLOBALS_MAP } from '@gjsify/resolve-npm/globals-map';
12
+ import { classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
12
13
  const KNOWN_GLOBALS = new Set(Object.keys(GJS_GLOBALS_MAP));
13
14
  /**
14
15
  * `<host>.<method>` patterns that must inject a global even though the identifier
@@ -88,6 +89,33 @@ function extractBindingNames(node) {
88
89
  return [];
89
90
  }
90
91
  }
92
+ /**
93
+ * Parse one analysis chunk, and say what a JSX failure IS.
94
+ *
95
+ * This parse is the first thing a `--globals auto` build does with bundled output, so a
96
+ * `jsx: "preserve"` project with no framework compiler dies HERE — and acorn's own
97
+ * message is `Unexpected token (3:11)`, which names neither JSX, nor the file, nor a
98
+ * setting. Every other parse failure is re-thrown untouched: acorn trailing
99
+ * SpiderMonkey on new syntax is acorn's problem, not the project's, and claiming JSX
100
+ * for it would be a lie with a fix attached.
101
+ */
102
+ function parseAnalysisChunk(code) {
103
+ try {
104
+ return acorn.parse(code, {
105
+ ecmaVersion: 'latest',
106
+ sourceType: 'module',
107
+ // Acorn rejects shebangs by default, and any project bundling its own CLI gets
108
+ // one hoisted to byte 0.
109
+ allowHashBang: true,
110
+ });
111
+ }
112
+ catch (err) {
113
+ const jsx = classifyJsxParseFailure(code, err);
114
+ if (jsx === null)
115
+ throw err;
116
+ throw new Error(formatSurvivingJsx(jsx, 'the analysis bundle'), { cause: err });
117
+ }
118
+ }
91
119
  /**
92
120
  * Core scanner behind `detectFreeGlobals` (web/Node globals map) and
93
121
  * `detectGjsAmbientGlobals` (the `--app node` GJS-ambient set), parametrised so the node
@@ -108,13 +136,7 @@ function scanFreeGlobals(code, opts) {
108
136
  const knownGlobals = opts.knownGlobals;
109
137
  const methodMarkers = opts.methodMarkers ?? {};
110
138
  const wasmMarkers = opts.wasmMarkers ?? {};
111
- const ast = acorn.parse(code, {
112
- ecmaVersion: 'latest',
113
- sourceType: 'module',
114
- // Acorn rejects shebangs by default, and any project bundling its own CLI gets
115
- // one hoisted to byte 0.
116
- allowHashBang: true,
117
- });
139
+ const ast = parseAnalysisChunk(code);
118
140
  // Pass 1: every declared name in the module.
119
141
  const declaredNames = new Set();
120
142
  walk.simple(ast, {
@@ -7,3 +7,5 @@ export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
8
  export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
9
9
  export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
10
+ export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
11
+ export type { SurvivingJsx } from './jsx-survival.js';
@@ -7,3 +7,4 @@ export { detectFreeGlobals } from './detect-free-globals.js';
7
7
  export { resolveGlobalsList, writeRegisterInjectFile } from './scan-globals.js';
8
8
  export { inlineStaticReads, isAbsoluteFsPath } from './inline-static-reads.js';
9
9
  export { GJSIFY_VIRTUAL_PREFIX, isGjsifyVirtualModuleId } from './virtual-module-id.js';
10
+ export { locateSurvivingJsx, classifyJsxParseFailure, formatSurvivingJsx } from './jsx-survival.js';
@@ -1,10 +1,3 @@
1
- /**
2
- * Run the inliner on a source string. Returns the rewritten source (or the
3
- * original string when no inlining applied) and the count of edits applied.
4
- *
5
- * Safe to call on any JS source. Files that don't reference `readFileSync` /
6
- * `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
7
- */
8
1
  export declare function inlineStaticReads(src: string, sourceFilePath: string): {
9
2
  contents: string;
10
3
  inlined: number;
@@ -41,7 +41,7 @@
41
41
  // Anything not statically resolvable is left untouched — the legacy
42
42
  // `import.meta.url` rewriter still applies as a fallback.
43
43
  import * as acorn from 'acorn';
44
- import * as walk from 'acorn-walk';
44
+ import { tsPlugin } from 'acorn-typescript';
45
45
  import { dirname, join, resolve, basename, relative, extname, posix, win32 } from 'node:path';
46
46
  import { fileURLToPath, pathToFileURL } from 'node:url';
47
47
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
@@ -52,35 +52,98 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
52
52
  * Safe to call on any JS source. Files that don't reference `readFileSync` /
53
53
  * `readdirSync` / `existsSync` skip the AST parse entirely (cheap fast path).
54
54
  */
55
+ const TS_EXT_RE = /\.[cm]?tsx?$/i;
56
+ /**
57
+ * The TypeScript-capable parser, built once.
58
+ *
59
+ * `acorn-typescript` and not the bundler's own parser, and the reason is the
60
+ * runtime rather than a preference: under GJS the engine is
61
+ * `@gjsify/rolldown-native`, and npm `rolldown` is "a Rust napi crate that cannot
62
+ * run under GJS" (`bundler-pick.ts`), loaded only behind a dynamic import on Node.
63
+ * A top-level `import { parseAst } from 'rolldown/parseAst'` therefore links a
64
+ * Node-only N-API package into a module that has to load under GJS — measured:
65
+ * the CLI's own bundle then died at startup with `createRequire: Cannot require
66
+ * builtin module "fs" synchronously in GJS`. `acorn-typescript` is pure JS, emits
67
+ * the same ESTree shapes the evaluator below already speaks, and needs
68
+ * `locations: true` (it refuses to parse without them).
69
+ */
70
+ const TS_PARSER = acorn.Parser.extend(tsPlugin());
71
+ /**
72
+ * Parse a source with the parser its extension calls for.
73
+ *
74
+ * The TypeScript half is why first-party sources were invisible to this inliner
75
+ * for as long as it was scoped to `node_modules`: an installed package ships JS,
76
+ * so plain acorn could always parse it, and nothing ever asked what happens to a
77
+ * `.ts`. The answer was that `acorn.parse` threw and the `catch` returned
78
+ * "nothing to inline" — a result indistinguishable from a file that genuinely has
79
+ * no static reads. Measured on `packages/infra/cli/src/utils/app-metadata.ts`:
80
+ * `inlined: 0`, while the identical expression in a `.js` file returned
81
+ * `inlined: 1`.
82
+ */
83
+ function parseSource(src, sourceFilePath) {
84
+ const shared = {
85
+ ecmaVersion: 'latest',
86
+ sourceType: 'module',
87
+ allowAwaitOutsideFunction: true,
88
+ allowReturnOutsideFunction: true,
89
+ allowImportExportEverywhere: true,
90
+ };
91
+ if (!TS_EXT_RE.test(sourceFilePath))
92
+ return acorn.parse(src, shared);
93
+ return TS_PARSER.parse(src, { ...shared, locations: true });
94
+ }
95
+ /**
96
+ * Visit every `CallExpression` in the tree.
97
+ *
98
+ * A generic descent rather than `acorn-walk`, and that is not a preference:
99
+ * `walk.simple` looks its visitor up in a table keyed by node type and throws
100
+ * `No walker function defined for node type TSInterfaceDeclaration` on the first
101
+ * interface in a first-party source. A walker with no node table cannot fall
102
+ * behind the parser — which matters precisely because the parser is now allowed
103
+ * to emit node types this file has never heard of.
104
+ */
105
+ function forEachCallExpression(root, visit) {
106
+ const stack = [root];
107
+ while (stack.length > 0) {
108
+ const node = stack.pop();
109
+ if (node === null || typeof node !== 'object')
110
+ continue;
111
+ if (Array.isArray(node)) {
112
+ for (const child of node)
113
+ stack.push(child);
114
+ continue;
115
+ }
116
+ if (node.type === 'CallExpression')
117
+ visit(node);
118
+ for (const value of Object.values(node)) {
119
+ if (value !== null && typeof value === 'object')
120
+ stack.push(value);
121
+ }
122
+ }
123
+ }
55
124
  export function inlineStaticReads(src, sourceFilePath) {
56
125
  if (!src.includes('readFileSync') && !src.includes('readdirSync') && !src.includes('existsSync')) {
57
126
  return { contents: src, inlined: 0 };
58
127
  }
59
128
  let ast;
60
129
  try {
61
- ast = acorn.parse(src, {
62
- ecmaVersion: 'latest',
63
- sourceType: 'module',
64
- allowAwaitOutsideFunction: true,
65
- allowReturnOutsideFunction: true,
66
- allowImportExportEverywhere: true,
67
- });
130
+ ast = parseSource(src, sourceFilePath);
68
131
  }
69
132
  catch {
70
- // Source isn't valid JS (CJS source with shebangs, mixed module
71
- // syntax, ...). Skip; the rest of the rewriter still runs.
133
+ // Not parseable as the language its extension claims (a CJS source with
134
+ // a shebang, mixed module syntax, ...). Skip; the rest of the rewriter
135
+ // still runs. This catch used to swallow every TypeScript file in the
136
+ // tree as well — see `parseSource`.
72
137
  return { contents: src, inlined: 0 };
73
138
  }
74
139
  const ctx = {
75
140
  sourceUrl: pathToFileURL(sourceFilePath).href,
76
141
  };
77
142
  const edits = [];
78
- walk.simple(ast, {
79
- CallExpression(node) {
80
- const edit = tryInlineCall(node, ctx, src);
81
- if (edit)
82
- edits.push(edit);
83
- },
143
+ forEachCallExpression(ast, (node) => {
144
+ const edit = tryInlineCall(node, ctx, src);
145
+ if (edit)
146
+ edits.push(edit);
84
147
  });
85
148
  if (edits.length === 0)
86
149
  return { contents: src, inlined: 0 };
@@ -0,0 +1,31 @@
1
+ /** Where the parser stopped, and on what. */
2
+ export interface SurvivingJsx {
3
+ /** 1-based line, as acorn reports it. */
4
+ line: number;
5
+ /** 0-based column, as acorn reports it. */
6
+ column: number;
7
+ /** The text around the failure, trimmed so a minified bundle stays readable. */
8
+ excerpt: string;
9
+ }
10
+ /**
11
+ * Does `err` — a failure from parsing `code` — say that raw JSX survived? Returns the
12
+ * location when the position attributes it to JSX, `null` when it does not.
13
+ *
14
+ * Split from {@link locateSurvivingJsx} so a caller that ALREADY parsed (and threw) can
15
+ * attribute its own failure without parsing a second time.
16
+ */
17
+ export declare function classifyJsxParseFailure(code: string, err: unknown): SurvivingJsx | null;
18
+ /**
19
+ * Parse `code` as an ES module and report surviving JSX. `null` means either "parses
20
+ * fine" or "fails for a reason that is not JSX" — the two cases this must not conflate,
21
+ * because only the first is a healthy bundle and only the second is acorn's problem
22
+ * rather than the build's.
23
+ */
24
+ export declare function locateSurvivingJsx(code: string): SurvivingJsx | null;
25
+ /**
26
+ * The actionable message: what the artifact does, what the runtime will do with it, and
27
+ * the two settings that fix it. Shared by the post-bundle artifact gate and the
28
+ * `--globals auto` analysis pass, which hits the same source with a bare
29
+ * "Unexpected token (3:11)" naming neither JSX nor a setting.
30
+ */
31
+ export declare function formatSurvivingJsx(found: SurvivingJsx, label: string): string;