@gjsify/rolldown-plugin-gjsify 0.51.1 → 0.52.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.
@@ -1,5 +1,9 @@
1
1
  // `--app browser` Rolldown configuration factory.
2
2
  //
3
+ // Platform-file forks resolve through `plugins/platform-resolve.ts` on the
4
+ // BROWSER chain (ADR 0032 § 9): `./foo.web.<ext>` ahead of `./foo.<ext>`, one
5
+ // rung, with `.gtk` / `.desktop` / `.native` refused and warned about.
6
+ //
3
7
  // Browser builds redirect `@girs/*` and `gi://*` to an empty virtual module
4
8
  // (they appear transitively via `@gjsify/unit` and similar packages with
5
9
  // GJS-specific code paths) — unless `--gi-renderer` composes the ADR 0034
@@ -22,6 +26,7 @@ import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
22
26
  import { ALIASES_NODE_FOR_BROWSER, GI_RENDERERS, getDerivedAliasesSync } from '@gjsify/resolve-npm';
23
27
  import { globToEntryPoints } from '../utils/entry-points.js';
24
28
  import { gjsImportsEmptyPlugin } from '../plugins/gjs-imports-empty.js';
29
+ import { platformResolvePlugin, browserSuffixChain, BROWSER_REFUSED_SUFFIXES } from '../plugins/platform-resolve.js';
25
30
  import { giRendererPlugin } from '../plugins/gi-renderer.js';
26
31
  import { cssAsStringPlugin } from '../plugins/css-as-string.js';
27
32
  import { unresolvedWorkspaceImportPlugin } from '../plugins/unresolved-workspace-import.js';
@@ -98,6 +103,22 @@ export const setupForBrowser = async (input) => {
98
103
  // transform can strip the annotations out from under it (see `GjsifyConfig`).
99
104
  const prePlugins = [deepkitPlugin({ reflection: input.pluginOptions.reflection })];
100
105
  const plugins = [
106
+ // Platform-file forks for the browser, ADR 0032 § 9: `.web` → base. One
107
+ // rung, and `browserSuffixChain`'s doc comment carries why each candidate
108
+ // second rung is absent. FIRST in the array for the reason `app/gjs.ts`
109
+ // states: the plugin claims RELATIVE imports only, and a platform fork of
110
+ // a module that also has a Node-builtin substitution must win over the
111
+ // substitution, because the fork is the more specific statement. `.gtk`,
112
+ // `.desktop` and `.native` are deliberately not rungs here and are warned
113
+ // about when present; `BROWSER_REFUSED_SUFFIXES` carries the reason.
114
+ // `siblingIndex: true` as on the desktop chain — same population (first-
115
+ // party relative imports), same directory-listing filter, and without it
116
+ // a one-rung chain plus three refusals is four failed resolves per import.
117
+ platformResolvePlugin({
118
+ suffixes: browserSuffixChain(),
119
+ refusedSuffixes: BROWSER_REFUSED_SUFFIXES,
120
+ siblingIndex: true,
121
+ }),
101
122
  // ADR 0034 stage 9 — the `gi://` arm, ahead of the empty redirect so it
102
123
  // claims the specifier first, exactly as `gjsGiNodePlugin` does on the node
103
124
  // target. `emptyGirs` follows it: with the arm on, `@girs/<ns>-<ver>` must
@@ -106,6 +127,12 @@ export const setupForBrowser = async (input) => {
106
127
  ...(giRenderer ? [giRendererPlugin({ app: 'browser', ...giRenderer })] : []),
107
128
  gjsImportsEmptyPlugin({ emptyGirs: !giRenderer }),
108
129
  aliasPlugin({ entries: aliasEntries }),
130
+ // Blueprint has been registered here since it was registered anywhere, and until the
131
+ // `?shared-tree` exit existed the XML string it emitted had no reader on this target:
132
+ // a browser has no `Gtk.Builder`, so `import Template from './x.blp'` compiled, shipped
133
+ // its bytes and was never parsed by anything. It stays — a `--app browser` build of a
134
+ // GJS app's sources must not start failing on an import that used to resolve — and the
135
+ // exit a browser can actually use is now beside it.
109
136
  blueprintPlugin(),
110
137
  cssAsStringPlugin(),
111
138
  // `order: 'post'` — see app/gjs.ts. The browser target's whole job is to
@@ -33,8 +33,12 @@
33
33
  // PACKAGE NAME still routes per its declared slot in a single hop.
34
34
  //
35
35
  // No `cssAsStringPlugin` (NativeScript ships its own CSS pipeline as part
36
- // of `@nativescript/core`) and no `blueprintPlugin` (Blueprint is GTK-only).
36
+ // of `@nativescript/core`). `blueprintPlugin` IS here: a bare `.blp` import
37
+ // still compiles to GtkBuilder XML that nothing on this target reads, but
38
+ // `./x.blp?shared-tree` projects the same file into the node shape
39
+ // `@gjsify/adwaita-nativescript`'s `build` consumes.
37
40
  import { aliasPlugin } from '../plugins/alias.js';
41
+ import blueprintPlugin from '@gjsify/vite-plugin-blueprint';
38
42
  import { deepkitPlugin } from '@gjsify/rolldown-plugin-deepkit';
39
43
  import { ALIASES_NODE_FOR_NATIVESCRIPT, GI_RENDERERS, getDerivedAliasesSync } from '@gjsify/resolve-npm';
40
44
  import { globToEntryPoints } from '../utils/entry-points.js';
@@ -146,7 +150,15 @@ export const setupForNativescript = async (input) => {
146
150
  // alias routing so a platform fork of a portable module is honored.
147
151
  platformResolvePlugin({ suffixes: nativescriptSuffixChain(platform) }),
148
152
  aliasPlugin({ entries: aliasEntries }),
149
- // NO blueprintPlugin — Blueprint is a GTK-specific UI DSL
153
+ // Blueprint: `./x.blp?shared-tree` is how ONE authored template reaches this
154
+ // target. The comment this replaced read "NO blueprintPlugin — Blueprint is a
155
+ // GTK-specific UI DSL", and that was true of the only exit the plugin had: a
156
+ // GtkBuilder-XML string, which no NativeScript runtime can load. It is the
157
+ // NOTATION that was never GTK-specific — ADR 0053 clause 1 made `.blp` a second
158
+ // READER of the shared node shape, and the projection is what this target
159
+ // consumes. A `.blp` whose projection loses anything is refused at build time
160
+ // rather than rendered partially; see `SHARED_TREE_QUERY` in the plugin.
161
+ blueprintPlugin(),
150
162
  // NO cssAsStringPlugin — NS ships its own CSS pipeline via
151
163
  // @nativescript/core; .css imports are handled by the consuming
152
164
  // @nativescript/webpack or @nativescript/vite build
package/lib/index.d.ts CHANGED
@@ -22,7 +22,7 @@ export { unresolvedWorkspaceImportPlugin, classifyImport, isWorkspaceSpecifier,
22
22
  export type { WorkspaceImportGuardOptions, WorkspaceImportGuardTarget, ImportVerdict, ClassifyImportInput, UnresolvedWorkspaceImportDetails, } from './plugins/unresolved-workspace-import.js';
23
23
  export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, isGjsifyNativeBridge, detectNapiRsEntry, hostNapiRsTriple, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
24
24
  export type { NapiNodeAddonPluginOptions, AddonPackageJson } from './plugins/napi-node-addon.js';
25
- export { platformResolvePlugin, nativescriptSuffixChain, desktopSuffixChain, desktopOsSuffix, DESKTOP_OS_SUFFIXES, DESKTOP_REFUSED_SUFFIXES, PlatformVariantExternalError, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
25
+ export { platformResolvePlugin, nativescriptSuffixChain, desktopSuffixChain, browserSuffixChain, desktopOsSuffix, DESKTOP_OS_SUFFIXES, DESKTOP_REFUSED_SUFFIXES, BROWSER_REFUSED_SUFFIXES, PlatformVariantExternalError, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
26
26
  export type { PlatformResolvePluginOptions, NativescriptPlatform } from './plugins/platform-resolve.js';
27
27
  export { rnRouteManifestPlugin, renderRouteManifest, walkRoutes, RouteManifestError, RN_ROUTES_MODULE_ID, MAX_ROUTE_DEPTH, } from './plugins/rn-route-manifest.js';
28
28
  export type { RnRouteManifestOptions, FoundRoute } from './plugins/rn-route-manifest.js';
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@ export { gjsImportsEmptyPlugin } from './plugins/gjs-imports-empty.js';
14
14
  export { externalsPlugin } from './plugins/externals.js';
15
15
  export { unresolvedWorkspaceImportPlugin, classifyImport, isWorkspaceSpecifier, formatUnresolvedWorkspaceImport, buildReverseAliasIndex, UnresolvedWorkspaceImportError, } from './plugins/unresolved-workspace-import.js';
16
16
  export { napiNodeAddonPlugin, resolveAddonPath, nearestPackageRoot, classifySpecifier, directNodeShim, nodeGypBuildShim, bindingsShim, napiRsShim, ADDON_FILTER_RE, isNapiRsPackageJson, isNapiRsSibling, isGjsifyNativeBridge, detectNapiRsEntry, hostNapiRsTriple, AddonNotBuiltError, } from './plugins/napi-node-addon.js';
17
- export { platformResolvePlugin, nativescriptSuffixChain, desktopSuffixChain, desktopOsSuffix, DESKTOP_OS_SUFFIXES, DESKTOP_REFUSED_SUFFIXES, PlatformVariantExternalError, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
17
+ export { platformResolvePlugin, nativescriptSuffixChain, desktopSuffixChain, browserSuffixChain, desktopOsSuffix, DESKTOP_OS_SUFFIXES, DESKTOP_REFUSED_SUFFIXES, BROWSER_REFUSED_SUFFIXES, PlatformVariantExternalError, detectNativescriptPlatform, nativescriptPlatformDefines, } from './plugins/platform-resolve.js';
18
18
  export { rnRouteManifestPlugin, renderRouteManifest, walkRoutes, RouteManifestError, RN_ROUTES_MODULE_ID, MAX_ROUTE_DEPTH, } from './plugins/rn-route-manifest.js';
19
19
  export { reactNativeAliasPlugin, classifyReactNativeSpecifier, couldBeSurfaceSpecifier, loadLayer, FALLBACK_SURFACES, REACT_NATIVE_ALIAS_TARGET, REACT_NATIVE_SPECIFIER, ReactNativeDeepImportError, ReactNativeAliasTargetMissingError, SURFACE_MENTION, SURFACE_NAME_PREFIXES, } from './plugins/react-native-alias.js';
20
20
  export { reactNativeSupportGatePlugin, loadSupportTable, findSupportViolations, formatSupportViolations, formatOpaqueReference, formatUnreadableModule, SUPPORT_TABLE_SUBPATH, WATCHED_SPECIFIERS, watchedSpecifiers, ReactNativeUnsupportedImportError, SupportTableUnreadableError, } from './plugins/react-native-gate.js';
@@ -39,6 +39,48 @@ export declare const DESKTOP_OS_SUFFIXES: Readonly<Record<string, string>>;
39
39
  * Without that, an author's fork is dead code with nothing anywhere saying so.
40
40
  */
41
41
  export declare const DESKTOP_REFUSED_SUFFIXES: readonly string[];
42
+ /**
43
+ * Suffixes that must NEVER be a rung of the BROWSER chain — the mirror of
44
+ * {@link DESKTOP_REFUSED_SUFFIXES}, built by the same rule read from the other
45
+ * side: **refuse the other families' UMBRELLA rungs, not their per-target
46
+ * spellings.** The desktop list names `.web` (the whole browser chain) and
47
+ * `.native` (the phone family's umbrella) and deliberately leaves
48
+ * `.android`/`.ios`/`.visionos` out; from here the same rule names the GTK
49
+ * desktop family's two non-OS rungs plus that same phone umbrella.
50
+ *
51
+ * `.gtk` is the one a reader reaches for while chasing a missing widget, and it
52
+ * is the worst possible answer: `--app browser` redirects `gi://*` and `@girs/*`
53
+ * to an EMPTY module, so a `.gtk.tsx` reaching `Adw.ActionRow` does not fail to
54
+ * import — it gets `{}`, and `class X extends Adw.ActionRow` throws
55
+ * `Class extends value undefined` at load. That is measured, and the ADR 0034
56
+ * stage 9 e2e keeps exactly that row as its control.
57
+ *
58
+ * `.desktop` is named as well as `.gtk`, though only one of the two appears in
59
+ * this repository today: § 9 writes `.desktop` as the umbrella over `.gtk` and
60
+ * the three OS spellings, so refusing one and not the other is a gap, and a gap
61
+ * in a refusal list reads as a judgement that the omitted one is fine here.
62
+ *
63
+ * `.native` is the phone bridge — `NativeModules` exists in neither a browser
64
+ * nor a GTK host, which is why both lists carry it.
65
+ *
66
+ * NOT here, and each is a DECISION rather than an omission:
67
+ * - **the three OS spellings** (`linux`/`macos`/`windows`). They only ever
68
+ * appear as rungs of the desktop chain, whose umbrella is already named — the
69
+ * same reason `.android` is absent from the desktop list. Naming them would
70
+ * put a warning line on every OS-forked module of a normal dual-target tree,
71
+ * which is how a warning gets switched off.
72
+ * - **a refusal list for NativeScript at all.** That chain runs with
73
+ * `siblingIndex` OFF under a byte-identical mandate, and with no listing to
74
+ * filter them a refusal list costs two real `this.resolve` calls on every
75
+ * relative import that has no variant — the majority, inside
76
+ * `@nativescript/core` included. The desktop chain's own measurement (+14% on
77
+ * a ~1400-module bundle from six failed resolves) is what says failed resolves
78
+ * are not free. It is a cost with a measured-zero benefit on this tree: the
79
+ * 30 `.gtk.*`/`.native.*` pairs in `@gjsify/adwaita-react-native` all HIT the
80
+ * NS chain at `.native`, and the refusal probe only runs when the chain found
81
+ * nothing. Reopen it from a measurement of that tree with the index on.
82
+ */
83
+ export declare const BROWSER_REFUSED_SUFFIXES: readonly string[];
42
84
  /**
43
85
  * The NativeScript chain: platform-specific first, then the platform-agnostic
44
86
  * `native` suffix. Without a known platform only `native` applies.
@@ -57,6 +99,37 @@ export declare function nativescriptSuffixChain(platform?: NativescriptPlatform)
57
99
  * guessing one.
58
100
  */
59
101
  export declare function desktopSuffixChain(os?: string): readonly string[];
102
+ /**
103
+ * The BROWSER chain: `.web` → base. Exactly ONE rung, and the count is the
104
+ * decision.
105
+ *
106
+ * A desktop build knows three different things about itself and so gets three
107
+ * rungs: the toolkit (`.gtk`), the kernel (`.<os>`) and the target family
108
+ * (`.desktop`). A `--app browser` build knows ONE — it targets the DOM — and
109
+ * every candidate second rung was rejected for a stated reason rather than left
110
+ * out for tidiness:
111
+ *
112
+ * - **No engine rung** (`.firefox` / `.chromium`). The target compiles one
113
+ * `esnext` bundle with no per-engine branch anywhere in it; a rung nobody can
114
+ * fill is a resolution rule that can only ever surprise someone.
115
+ * - **No OS rung.** One browser bundle is served to every operating system, so
116
+ * an OS rung would resolve against the BUILD HOST and bake it into a
117
+ * platform-neutral artifact. That is the mistake `plugins/gi-runtime-paths.ts`
118
+ * already documents for the GI prologue, committed here at resolution time
119
+ * where the evidence is a file name and not a code path.
120
+ * - **No `.dom` synonym.** Two spellings for one concept is a priority order
121
+ * someone has to memorise, and `.web` is what § 9 writes and what every
122
+ * `.web.*` file in this repository already uses.
123
+ * - **No `.browser` umbrella.** `.desktop` earns its rung by sitting above four
124
+ * spellings; above `.web` there is nothing for an umbrella to cover.
125
+ *
126
+ * Takes no parameter, unlike {@link desktopSuffixChain}: nothing about this
127
+ * chain depends on the host, which is the previous point restated from the API
128
+ * side. It is a function rather than a constant so that
129
+ * {@link PlatformResolvePluginOptions.suffixes} keeps its rule — a hand-written
130
+ * array is a second resolution order the tree does not state.
131
+ */
132
+ export declare function browserSuffixChain(): readonly string[];
60
133
  /**
61
134
  * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
62
135
  * ADR 0018's target set.
@@ -81,16 +154,19 @@ export declare function desktopOsSuffix(platform?: string): string | undefined;
81
154
  export interface PlatformResolvePluginOptions {
82
155
  /**
83
156
  * The suffix chain, most specific first. Build it with
84
- * {@link nativescriptSuffixChain} or {@link desktopSuffixChain} — a
85
- * hand-written array is a second resolution order the tree does not state.
157
+ * {@link nativescriptSuffixChain}, {@link desktopSuffixChain} or
158
+ * {@link browserSuffixChain} — a hand-written array is a second resolution
159
+ * order the tree does not state.
86
160
  */
87
161
  suffixes: readonly string[];
88
162
  /**
89
163
  * Suffixes deliberately NOT resolved, but whose presence on disk is worth
90
164
  * saying out loud: {@link DESKTOP_REFUSED_SUFFIXES} for the desktop chain,
91
- * empty for NativeScript (where `.native` is a real rung). Probed only after
92
- * the chain found nothing, so an empty list costs zero extra resolves and
93
- * the NS path is byte-unchanged.
165
+ * {@link BROWSER_REFUSED_SUFFIXES} for the browser chain, empty for
166
+ * NativeScript — where `.native` is a real rung and the rest is a measured
167
+ * decision written out on `BROWSER_REFUSED_SUFFIXES`. Probed only after the
168
+ * chain found nothing, so an empty list costs zero extra resolves and the NS
169
+ * path is byte-unchanged.
94
170
  */
95
171
  refusedSuffixes?: readonly string[];
96
172
  /**
@@ -121,6 +197,20 @@ export interface PlatformResolvePluginOptions {
121
197
  * that chain is byte-identical behaviour — a filter that another plugin's
122
198
  * virtual `resolveId` could see past is not something to switch on there
123
199
  * without a measurement of that tree.
200
+ *
201
+ * ON WHEREVER THE CHAIN CARRIES A REFUSAL LIST, and that is not a preference.
202
+ * A refused suffix is probed on every miss, so a chain with refusals pays its
203
+ * probes on the MAJORITY of imports rather than the few that have a variant.
204
+ * Measured on `tests/e2e/vite-plugin-gjsify`'s fixture with the browser chain
205
+ * (one rung, three refusals) under `--max-old-space-size=4096`: index off is
206
+ * 1172 probes and `FATAL ERROR: Reached heap limit`; index on is 0 probes,
207
+ * 0.25 s and 191 MB. Under Vite each probe re-enters the whole plugin
208
+ * container, which is where the memory goes. Leaving the index off is
209
+ * therefore a choice only a chain with NO refusal list can afford.
210
+ *
211
+ * Kept fresh by {@link platformResolvePlugin}'s `watchChange` hook, so a
212
+ * long-lived host (a Vite dev server, `gjsify build --watch`) does not answer
213
+ * from a directory listing taken before the author added the variant.
124
214
  */
125
215
  siblingIndex?: boolean;
126
216
  }
@@ -1,16 +1,20 @@
1
- // Platform file resolution — ONE plugin, ONE resolution order, two chains.
1
+ // Platform file resolution — ONE plugin, ONE resolution order, three chains.
2
2
  //
3
3
  // Lets a shared codebase fork a single module per target by file name. An
4
4
  // `import './foo'` (or `import './foo.js'`) resolves to the most specific
5
5
  // variant on disk, in the priority order the caller's chain declares:
6
6
  //
7
- // NativeScript (`--app nativescript`) GTK / desktop (`--app gjs|node`)
8
- // ./foo.<android|ios|visionos>.<ext> ./foo.gtk.<ext>
9
- // ./foo.native.<ext> ./foo.<linux|macos|windows>.<ext>
10
- // ./foo.<ext> ./foo.desktop.<ext>
11
- // ./foo.<ext>
7
+ // NativeScript (`--app nativescript`) GTK / desktop (`--app gjs|node`)
8
+ // ./foo.<android|ios|visionos>.<ext> ./foo.gtk.<ext>
9
+ // ./foo.native.<ext> ./foo.<linux|macos|windows>.<ext>
10
+ // ./foo.<ext> ./foo.desktop.<ext>
11
+ // ./foo.<ext>
12
12
  //
13
- // TWO CHAINS, NOT TWO PLUGINS (ADR 0032 § 9). A second plugin would give the
13
+ // Browser (`--app browser`)
14
+ // ./foo.web.<ext>
15
+ // ./foo.<ext>
16
+ //
17
+ // THREE CHAINS, NOT THREE PLUGINS (ADR 0032 § 9). A second plugin would give the
14
18
  // tree two resolution orders that no single file states, and the order IS the
15
19
  // contract — `.gtk` before `.<os>` before `.desktop` is a decision, not an
16
20
  // implementation detail. So the chain is a PARAMETER and the builders below are
@@ -82,6 +86,48 @@ export const DESKTOP_OS_SUFFIXES = {
82
86
  * Without that, an author's fork is dead code with nothing anywhere saying so.
83
87
  */
84
88
  export const DESKTOP_REFUSED_SUFFIXES = ['native', 'web'];
89
+ /**
90
+ * Suffixes that must NEVER be a rung of the BROWSER chain — the mirror of
91
+ * {@link DESKTOP_REFUSED_SUFFIXES}, built by the same rule read from the other
92
+ * side: **refuse the other families' UMBRELLA rungs, not their per-target
93
+ * spellings.** The desktop list names `.web` (the whole browser chain) and
94
+ * `.native` (the phone family's umbrella) and deliberately leaves
95
+ * `.android`/`.ios`/`.visionos` out; from here the same rule names the GTK
96
+ * desktop family's two non-OS rungs plus that same phone umbrella.
97
+ *
98
+ * `.gtk` is the one a reader reaches for while chasing a missing widget, and it
99
+ * is the worst possible answer: `--app browser` redirects `gi://*` and `@girs/*`
100
+ * to an EMPTY module, so a `.gtk.tsx` reaching `Adw.ActionRow` does not fail to
101
+ * import — it gets `{}`, and `class X extends Adw.ActionRow` throws
102
+ * `Class extends value undefined` at load. That is measured, and the ADR 0034
103
+ * stage 9 e2e keeps exactly that row as its control.
104
+ *
105
+ * `.desktop` is named as well as `.gtk`, though only one of the two appears in
106
+ * this repository today: § 9 writes `.desktop` as the umbrella over `.gtk` and
107
+ * the three OS spellings, so refusing one and not the other is a gap, and a gap
108
+ * in a refusal list reads as a judgement that the omitted one is fine here.
109
+ *
110
+ * `.native` is the phone bridge — `NativeModules` exists in neither a browser
111
+ * nor a GTK host, which is why both lists carry it.
112
+ *
113
+ * NOT here, and each is a DECISION rather than an omission:
114
+ * - **the three OS spellings** (`linux`/`macos`/`windows`). They only ever
115
+ * appear as rungs of the desktop chain, whose umbrella is already named — the
116
+ * same reason `.android` is absent from the desktop list. Naming them would
117
+ * put a warning line on every OS-forked module of a normal dual-target tree,
118
+ * which is how a warning gets switched off.
119
+ * - **a refusal list for NativeScript at all.** That chain runs with
120
+ * `siblingIndex` OFF under a byte-identical mandate, and with no listing to
121
+ * filter them a refusal list costs two real `this.resolve` calls on every
122
+ * relative import that has no variant — the majority, inside
123
+ * `@nativescript/core` included. The desktop chain's own measurement (+14% on
124
+ * a ~1400-module bundle from six failed resolves) is what says failed resolves
125
+ * are not free. It is a cost with a measured-zero benefit on this tree: the
126
+ * 30 `.gtk.*`/`.native.*` pairs in `@gjsify/adwaita-react-native` all HIT the
127
+ * NS chain at `.native`, and the refusal probe only runs when the chain found
128
+ * nothing. Reopen it from a measurement of that tree with the index on.
129
+ */
130
+ export const BROWSER_REFUSED_SUFFIXES = ['gtk', 'desktop', 'native'];
85
131
  /**
86
132
  * The NativeScript chain: platform-specific first, then the platform-agnostic
87
133
  * `native` suffix. Without a known platform only `native` applies.
@@ -104,6 +150,39 @@ export function nativescriptSuffixChain(platform) {
104
150
  export function desktopSuffixChain(os) {
105
151
  return os ? ['gtk', os, 'desktop'] : ['gtk', 'desktop'];
106
152
  }
153
+ /**
154
+ * The BROWSER chain: `.web` → base. Exactly ONE rung, and the count is the
155
+ * decision.
156
+ *
157
+ * A desktop build knows three different things about itself and so gets three
158
+ * rungs: the toolkit (`.gtk`), the kernel (`.<os>`) and the target family
159
+ * (`.desktop`). A `--app browser` build knows ONE — it targets the DOM — and
160
+ * every candidate second rung was rejected for a stated reason rather than left
161
+ * out for tidiness:
162
+ *
163
+ * - **No engine rung** (`.firefox` / `.chromium`). The target compiles one
164
+ * `esnext` bundle with no per-engine branch anywhere in it; a rung nobody can
165
+ * fill is a resolution rule that can only ever surprise someone.
166
+ * - **No OS rung.** One browser bundle is served to every operating system, so
167
+ * an OS rung would resolve against the BUILD HOST and bake it into a
168
+ * platform-neutral artifact. That is the mistake `plugins/gi-runtime-paths.ts`
169
+ * already documents for the GI prologue, committed here at resolution time
170
+ * where the evidence is a file name and not a code path.
171
+ * - **No `.dom` synonym.** Two spellings for one concept is a priority order
172
+ * someone has to memorise, and `.web` is what § 9 writes and what every
173
+ * `.web.*` file in this repository already uses.
174
+ * - **No `.browser` umbrella.** `.desktop` earns its rung by sitting above four
175
+ * spellings; above `.web` there is nothing for an umbrella to cover.
176
+ *
177
+ * Takes no parameter, unlike {@link desktopSuffixChain}: nothing about this
178
+ * chain depends on the host, which is the previous point restated from the API
179
+ * side. It is a function rather than a constant so that
180
+ * {@link PlatformResolvePluginOptions.suffixes} keeps its rule — a hand-written
181
+ * array is a second resolution order the tree does not state.
182
+ */
183
+ export function browserSuffixChain() {
184
+ return ['web'];
185
+ }
107
186
  /**
108
187
  * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
109
188
  * ADR 0018's target set.
@@ -155,13 +234,21 @@ export function platformResolvePlugin(options) {
155
234
  // EMPTY chain is one that was computed and came out empty — the shape a
156
235
  // mis-wired orchestrator has — and it would send every import to base
157
236
  // while looking installed.
158
- throw new Error('gjsify platform resolve: the suffix chain is empty. Pass nativescriptSuffixChain(…) or ' +
159
- 'desktopSuffixChain(…), or omit the plugin.');
237
+ throw new Error('gjsify platform resolve: the suffix chain is empty. Pass nativescriptSuffixChain(…), ' +
238
+ 'desktopSuffixChain(…) or browserSuffixChain(), or omit the plugin.');
160
239
  }
161
240
  const refused = options.refusedSuffixes ?? [];
162
241
  const useIndex = options.siblingIndex ?? false;
163
242
  // Per-directory listing cache, lowercased. `null` marks a directory that
164
243
  // could not be read, which means "no opinion" — the resolver decides.
244
+ //
245
+ // CACHED FOR THE PLUGIN INSTANCE'S LIFE, so it needs the `watchChange` hook
246
+ // below: in a one-shot build the process ends before a listing can go stale,
247
+ // but `gjsify build --watch` and a Vite dev server both keep one plugin
248
+ // instance across rebuilds. Without invalidation an author who ADDS
249
+ // `foo.web.ts` beside `foo.ts` while the watcher runs keeps being served
250
+ // `foo.ts` until restart — the filter would be answering from a directory
251
+ // that no longer exists in that shape.
165
252
  const listings = new Map();
166
253
  const listingFor = (dir) => {
167
254
  const cached = listings.get(dir);
@@ -196,6 +283,19 @@ export function platformResolvePlugin(options) {
196
283
  const warned = new Set();
197
284
  return {
198
285
  name: 'gjsify-platform-resolve',
286
+ // The sibling index's invalidation. Both hosts call this for every path
287
+ // the watcher reports (`create` / `update` / `delete`), which is exactly
288
+ // the event that can make a cached listing wrong — a NEW `foo.web.ts` is
289
+ // a `create` in the directory the filter already answered for.
290
+ //
291
+ // Only the CHANGED path's directory is dropped, not the whole map: the
292
+ // map is refilled lazily, so a blanket clear would re-`readdirSync` every
293
+ // directory the next rebuild touches for one unrelated save. `dirname`
294
+ // of a virtual or `\0`-prefixed id is a directory that was never cached,
295
+ // and deleting an absent key is a no-op — no guard needed for that.
296
+ watchChange(id) {
297
+ listings.delete(dirname(id));
298
+ },
199
299
  resolveId: {
200
300
  order: 'pre',
201
301
  async handler(source, importer, extraOptions) {
@@ -204,6 +304,23 @@ export function platformResolvePlugin(options) {
204
304
  return null;
205
305
  if (!source.startsWith('./') && !source.startsWith('../'))
206
306
  return null;
307
+ // A QUERY NAMES A TRANSFORM OF ONE FILE, NOT A FILE TO FORK.
308
+ //
309
+ // `./x.blp?shared-tree`, `?raw`, `?url`: the plugin serving the query owns the
310
+ // resolution, and there is no second FILE for a platform chain to prefer —
311
+ // whatever the suffix is appended to, `./x.blp?shared-tree.native` is a
312
+ // specifier no convention gives a meaning to.
313
+ //
314
+ // Standing down is not merely tidier here, it is required. A probe that misses
315
+ // is normally free: `this.resolve('./x.native')` on a path that is not there
316
+ // returns null and the loop walks on. With a query in the specifier it is NOT
317
+ // — measured on `--app nativescript`, the missed probe surfaced as
318
+ // `UNLOADABLE_DEPENDENCY … No such file or directory (os error 2)` against the
319
+ // ORIGINAL import, and the chain never reached the plugin that would have
320
+ // resolved it. So the blind probe does not waste a lookup, it fails the build,
321
+ // and it does so pointing at a line that is correct.
322
+ if (source.includes('?'))
323
+ return null;
207
324
  const extMatch = KNOWN_EXT_RE.exec(source);
208
325
  const origExt = extMatch ? extMatch[0] : '';
209
326
  const base = origExt ? source.slice(0, -origExt.length) : source;
@@ -253,12 +370,16 @@ export function platformResolvePlugin(options) {
253
370
  if (!probe || warned.has(probe.id))
254
371
  continue;
255
372
  warned.add(probe.id);
373
+ // The remedy names THIS chain's own rungs. It used to end in
374
+ // a hardcoded `.desktop`, which was true of the only chain
375
+ // that had a refusal list and became advice to write a file
376
+ // the browser chain refuses the moment a second one did.
377
+ const ownVariants = suffixes.map((own) => `.${own}`).join(' / ');
256
378
  this.warn(`gjsify platform resolve: "${base}.${suffix}" exists but is NOT a rung of this ` +
257
379
  `target's chain (${suffixes.join(' → ')} → base), so ${importer} gets the base ` +
258
380
  `file. ADR 0032 § 9: a .${suffix} variant is written for a runtime this build ` +
259
381
  `is not, and reaching for it would hand this target code whose failure only ` +
260
- `shows up on screen. Move what applies here into a .${suffixes[0]} or ` +
261
- `.desktop variant.`);
382
+ `shows up on screen. Move what applies here into ${ownVariants} or the base file.`);
262
383
  }
263
384
  return null;
264
385
  },
@@ -189,6 +189,70 @@ function emitGiBackedDiagnostic(registerPaths, detected, cwd) {
189
189
  function isFullConfig(v) {
190
190
  return v !== null && typeof v === 'object' && !Array.isArray(v) && 'plugins' in v && 'options' in v;
191
191
  }
192
+ /**
193
+ * Merge the orchestrator's `transform` with the analysis side's, PER KEY — the
194
+ * analysis side wins for the keys it actually sets.
195
+ *
196
+ * `analysisOptions.transform ?? orchestratorOptions?.transform` was wrong for a
197
+ * MEASURED reason, not a stylistic one: the CLI's config merge does
198
+ * `bundler.transform ??= {}` (`@gjsify/cli` `src/config.ts`), so the analysis side is
199
+ * ALWAYS a non-nullish object and `??` therefore always took it — dropping the
200
+ * orchestrator's `target`, `define` and `inject` from every single analysis pass. The
201
+ * detector then reads a bundle transformed differently from the one that ships, which
202
+ * is exactly what the "analyse the bundled output AFTER tree-shaking" invariant exists
203
+ * to prevent.
204
+ *
205
+ * What that cost, measured on `--app gjs`, whose orchestrator defines
206
+ * `process.env.READABLE_STREAM` to `"disable"`. Entry:
207
+ *
208
+ * if (process.env.READABLE_STREAM !== 'disable') document.getElementById('app');
209
+ *
210
+ * The define makes that branch dead, so the emitted bundle carries no `document`
211
+ * reference at all — and the build injected `document` + `HTMLCanvasElement` + `Path2D`
212
+ * anyway: 24 globals and 276_726 bytes where the same entry now yields 21 and 176_487.
213
+ * 100 KB of DOM/canvas register code, one of it a GI-backed register that makes the
214
+ * artifact hard-require a GTK runtime at load. Silently, at exit 0.
215
+ *
216
+ * Note what does NOT discriminate here, so the next reproduction does not start with
217
+ * it: `process` is detected on every `--app gjs` build regardless, because the process
218
+ * stub the target prepends in `renderChunk` writes `globalThis.process` and the
219
+ * detector reads its own banner back.
220
+ *
221
+ * `define` and `inject` are string maps and merge per key; every other transform key is
222
+ * a scalar (`target`, `lang`, `sourceType`) or a coherent option bag (`typescript`,
223
+ * `decorator`, `assumptions`, `tsconfig`) that is replaced whole. This is deliberately
224
+ * the same shape as `mergeBundlerOptions` in `@gjsify/cli`, which merges the FINAL
225
+ * build's options: the two must agree, or the analysis is measuring a different bundle
226
+ * again one key further down.
227
+ */
228
+ function mergeTransformOptions(orchestrator, analysis) {
229
+ if (!orchestrator)
230
+ return analysis;
231
+ if (!analysis)
232
+ return orchestrator;
233
+ const merged = { ...orchestrator, ...analysis };
234
+ if (orchestrator.define || analysis.define) {
235
+ merged.define = { ...orchestrator.define, ...analysis.define };
236
+ }
237
+ if (orchestrator.inject || analysis.inject) {
238
+ merged.inject = { ...orchestrator.inject, ...analysis.inject };
239
+ }
240
+ return merged;
241
+ }
242
+ /**
243
+ * Same per-key rule for `resolve`. No caller sets `analysisOptions.resolve` today, so
244
+ * this is inert right now — it is here because `resolve` is the third plain-object
245
+ * field on the same code path and a whole-object `??` on it would silently drop the
246
+ * orchestrator's `conditionNames`/`mainFields`, which is the divergence the class
247
+ * comment above describes.
248
+ */
249
+ function mergeResolveOptions(orchestrator, analysis) {
250
+ if (!orchestrator)
251
+ return analysis;
252
+ if (!analysis)
253
+ return orchestrator;
254
+ return { ...orchestrator, ...analysis };
255
+ }
192
256
  /**
193
257
  * Run the iterative in-memory build with acorn-based global detection, each pass
194
258
  * seeded by the previous pass's globals, until the detected set is stable. Returns the
@@ -237,10 +301,16 @@ export async function detectAutoGlobals(analysisOptions, pluginOptions, gjsifyPl
237
301
  : analysisOptions.input;
238
302
  // Take resolve/external/transform from the orchestrator so the analysis bundle
239
303
  // goes through the same module resolution as the final build; explicit
240
- // analysis-side overrides win.
241
- const mergedResolve = analysisOptions.resolve ?? orchestratorOptions?.resolve;
304
+ // analysis-side overrides win PER KEY (see `mergeTransformOptions`).
305
+ //
306
+ // `external` stays a whole-value choice on purpose: it is not a plain object
307
+ // (string | RegExp | array | predicate), the orchestrator's array has already
308
+ // folded the caller's own entries in, and the shape rules an array cannot
309
+ // express live in `externalsPlugin` — which is part of `gjsifyPluginsArray`
310
+ // below, so it applies to the analysis bundle whichever array wins here.
311
+ const mergedResolve = mergeResolveOptions(orchestratorOptions?.resolve, analysisOptions.resolve);
242
312
  const mergedExternal = analysisOptions.external ?? orchestratorOptions?.external;
243
- const mergedTransform = analysisOptions.transform ?? orchestratorOptions?.transform;
313
+ const mergedTransform = mergeTransformOptions(orchestratorOptions?.transform, analysisOptions.transform);
244
314
  const orchTreeshake = orchestratorOptions?.treeshake;
245
315
  const gjsifyPluginsArray = Array.isArray(gjsifyInstance) ? gjsifyInstance : [gjsifyInstance];
246
316
  const chunkCodes = await bundler({
@@ -408,8 +478,12 @@ export async function detectNodeGiGlobals(analysisOptions, pluginOptions, gjsify
408
478
  ...baseOptions,
409
479
  input: analysisOptions.input,
410
480
  external: analysisOptions.external ?? baseOptions.external,
411
- resolve: analysisOptions.resolve ?? baseOptions.resolve,
412
- transform: analysisOptions.transform ?? baseOptions.transform,
481
+ // Per key, for the reason spelled out on `mergeTransformOptions`: the
482
+ // CLI always hands an object here, so a whole-object `??` dropped this
483
+ // target's `target: 'node24'` and `define: { global, window }` from
484
+ // every reverse-bridge analysis pass.
485
+ resolve: mergeResolveOptions(baseOptions.resolve, analysisOptions.resolve),
486
+ transform: mergeTransformOptions(baseOptions.transform, analysisOptions.transform),
413
487
  plugins: [...callerPlugins, ...gjsifyPluginsArray],
414
488
  logLevel: 'silent',
415
489
  },
@@ -11,7 +11,7 @@
11
11
  // Kept deliberately pure — no `gi://` / `@girs/*` imports, no side effects —
12
12
  // so it stays loadable on every host and so consumers can import it via the
13
13
  // `@gjsify/rolldown-plugin-gjsify/runtime` subpath without pulling the rest of
14
- // the plugin (which transitively loads blueprint-compiler, deepkit, etc.).
14
+ // the plugin (which transitively loads the Blueprint parser, deepkit, etc.).
15
15
  /**
16
16
  * `true` when running under Bun.
17
17
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-plugin-gjsify",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "description": "Rolldown / Rollup / Vite plugin orchestrator for GJS, Node, and Browser targets",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -60,11 +60,11 @@
60
60
  ],
61
61
  "license": "MIT",
62
62
  "dependencies": {
63
- "@gjsify/console": "^0.51.1",
64
- "@gjsify/resolve-npm": "^0.51.1",
65
- "@gjsify/rolldown-plugin-deepkit": "^0.51.1",
66
- "@gjsify/rolldown-plugin-pnp": "^0.51.1",
67
- "@gjsify/vite-plugin-blueprint": "^0.51.1",
63
+ "@gjsify/console": "^0.52.0",
64
+ "@gjsify/resolve-npm": "^0.52.0",
65
+ "@gjsify/rolldown-plugin-deepkit": "^0.52.0",
66
+ "@gjsify/rolldown-plugin-pnp": "^0.52.0",
67
+ "@gjsify/vite-plugin-blueprint": "^0.52.0",
68
68
  "@rollup/pluginutils": "^5.4.0",
69
69
  "acorn": "^8.17.0",
70
70
  "acorn-typescript": "^1.4.13",
@@ -74,7 +74,7 @@
74
74
  "sass": "^1.101.0"
75
75
  },
76
76
  "peerDependencies": {
77
- "@gjsify/lightningcss-native": "^0.51.1",
77
+ "@gjsify/lightningcss-native": "^0.52.0",
78
78
  "rolldown": "^1.1.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
@@ -86,8 +86,8 @@
86
86
  }
87
87
  },
88
88
  "devDependencies": {
89
- "@gjsify/cli": "^0.51.1",
90
- "@gjsify/unit": "^0.51.1",
89
+ "@gjsify/cli": "^0.52.0",
90
+ "@gjsify/unit": "^0.52.0",
91
91
  "@types/node": "^25.9.2",
92
92
  "rolldown": "^1.1.4",
93
93
  "typescript": "^6.0.3"