@gjsify/rolldown-plugin-gjsify 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/app/browser.d.ts +2 -0
  2. package/lib/app/browser.js +4 -2
  3. package/lib/app/gjs.d.ts +2 -0
  4. package/lib/app/gjs.js +32 -2
  5. package/lib/app/nativescript.d.ts +2 -0
  6. package/lib/app/nativescript.js +6 -4
  7. package/lib/app/node.d.ts +7 -2
  8. package/lib/app/node.js +122 -4
  9. package/lib/index.d.ts +9 -1
  10. package/lib/index.js +5 -1
  11. package/lib/library/lib.d.ts +2 -0
  12. package/lib/library/lib.js +12 -1
  13. package/lib/plugin.d.ts +15 -0
  14. package/lib/plugins/intl-segmenter-banner.d.ts +1 -0
  15. package/lib/plugins/intl-segmenter-banner.js +42 -0
  16. package/lib/plugins/platform-resolve.d.ts +133 -8
  17. package/lib/plugins/platform-resolve.js +214 -30
  18. package/lib/plugins/process-stub.js +4 -0
  19. package/lib/plugins/react-native-alias.d.ts +63 -0
  20. package/lib/plugins/react-native-alias.js +128 -0
  21. package/lib/plugins/react-native-gate.d.ts +108 -0
  22. package/lib/plugins/react-native-gate.js +235 -0
  23. package/lib/plugins/rn-route-manifest.d.ts +67 -0
  24. package/lib/plugins/rn-route-manifest.js +166 -0
  25. package/lib/types/index.d.ts +1 -0
  26. package/lib/types/index.js +1 -0
  27. package/lib/types/plugin-options.d.ts +20 -0
  28. package/lib/types/source-dialect.d.ts +42 -0
  29. package/lib/types/source-dialect.js +21 -0
  30. package/lib/utils/detect-free-globals.js +29 -7
  31. package/lib/utils/index.d.ts +2 -0
  32. package/lib/utils/index.js +1 -0
  33. package/lib/utils/jsx-survival.d.ts +31 -0
  34. package/lib/utils/jsx-survival.js +102 -0
  35. package/lib/utils/scan-named-imports.d.ts +47 -0
  36. package/lib/utils/scan-named-imports.js +239 -0
  37. package/package.json +17 -10
@@ -1,19 +1,144 @@
1
1
  import type { Plugin } from 'rolldown';
2
2
  export type NativescriptPlatform = 'android' | 'ios' | 'visionos';
3
+ /**
4
+ * The desktop suffix for one host OS, keyed by `process.platform`.
5
+ *
6
+ * TWO VOCABULARIES MEET HERE and neither is negotiable. The KEYS are
7
+ * `process.platform`'s (`linux`/`darwin`/`win32`) — ADR 0018's target set, the
8
+ * spelling `gjsify.os` and `gjsify.platforms` use, and what a running process
9
+ * computes about itself. The VALUES are React Native's `Platform.OS` spelling
10
+ * (`linux`/`macos`/`windows`), which is what ADR 0032 § 9 writes and what
11
+ * `@gjsify/react-native`'s own `Platform.OS` reports — so a `foo.macos.tsx`
12
+ * agrees with the `Platform.OS === 'macos'` branch beside it. Spelling the file
13
+ * `foo.darwin.tsx` would make those two disagree in the one place a reader looks
14
+ * to check them against each other.
15
+ *
16
+ * Exported so `platform-resolve.spec.ts` can iterate the real values instead of
17
+ * a copy: nothing in the type system stops someone adding a `web` row here, and
18
+ * that row is exactly what § 9 forbids.
19
+ */
20
+ export declare const DESKTOP_OS_SUFFIXES: Readonly<Record<string, string>>;
21
+ /**
22
+ * Suffixes that must NEVER be a rung of the DESKTOP chain, with § 9's reason.
23
+ *
24
+ * `.native` looks like an oversight and is the opposite: a `.native.tsx` is by
25
+ * definition written for a React Native runtime, so reaching for it would
26
+ * silently feed the GTK build code that expects `NativeModules` — a bridge that
27
+ * exists only in the phone host. No import error, no type error, no failing
28
+ * test; the divergence surfaces as a window that does not work. Falling through
29
+ * to the BASE file is the honest outcome, because the base file at least has to
30
+ * compile against what this target really has.
31
+ *
32
+ * `.web` is worse. It reads as the obvious pick for a desktop target — a desktop
33
+ * is not a phone, so surely the web variant? — and it carries exactly the DOM
34
+ * assumptions ADR 0032 rules out, the same two-lossy-mappings-stacked shape that
35
+ * rules out react-native-web over a DOM (§ 3, Rejected).
36
+ *
37
+ * Not silence, though: when the desktop chain falls through to base and one of
38
+ * these siblings exists, {@link platformResolvePlugin} warns naming the file.
39
+ * Without that, an author's fork is dead code with nothing anywhere saying so.
40
+ */
41
+ export declare const DESKTOP_REFUSED_SUFFIXES: readonly string[];
42
+ /**
43
+ * The NativeScript chain: platform-specific first, then the platform-agnostic
44
+ * `native` suffix. Without a known platform only `native` applies.
45
+ */
46
+ export declare function nativescriptSuffixChain(platform?: NativescriptPlatform): readonly string[];
47
+ /**
48
+ * The GTK/desktop chain (ADR 0032 § 9): `.gtk` → `.<os>` → `.desktop` → base.
49
+ *
50
+ * `.gtk` outranks the OS because a GTK fork is a statement about the TOOLKIT,
51
+ * which is the more specific claim: `foo.gtk.tsx` says "this is how the widget
52
+ * tree does it", `foo.linux.tsx` says "this is how this kernel does it", and a
53
+ * project holding both means the first on a GTK build.
54
+ *
55
+ * `os` is a suffix from {@link DESKTOP_OS_SUFFIXES}, never a `process.platform`
56
+ * token — see {@link desktopOsSuffix}. Omitting it drops the OS rung rather than
57
+ * guessing one.
58
+ */
59
+ export declare function desktopSuffixChain(os?: string): readonly string[];
60
+ /**
61
+ * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
62
+ * ADR 0018's target set.
63
+ *
64
+ * The host, not a cross-target: there is no cross-OS desktop build in this chain
65
+ * (ADR 0032's Consequences put macOS and Windows on Node + `@gjsify/node-gi`,
66
+ * whose installable artifact is ADR 0024 stages 4 and 5 and does not exist yet).
67
+ * A future cross-build states its own suffix by passing one to
68
+ * {@link desktopSuffixChain}, which is why the decision function takes the value
69
+ * and only this one function reads the ambient.
70
+ *
71
+ * The ambient read is `process.platform` behind a defaulted parameter — the
72
+ * shape `extractPackageSpec` and `isAbsoluteFsPath` already use in this package.
73
+ * The canonical repo-wide spelling is `@gjsify/utils/core`'s `hostOs()`, and
74
+ * `os-axis.mjs` sanctions infra keeping its own raw read for the purity reason
75
+ * `cli/src/utils/platform-check.ts` documents. Here there is a second, harder
76
+ * reason: `@gjsify/utils` is BUILT BY the CLI this package is part of, so
77
+ * depending on it would make `build:infra` need its own output.
78
+ * `package.json#gjsify.os` on this package already carries the ADR 0018 claim.
79
+ */
80
+ export declare function desktopOsSuffix(platform?: string): string | undefined;
3
81
  export interface PlatformResolvePluginOptions {
4
82
  /**
5
- * Target platform. When omitted, only `.native.*` variants are tried
6
- * (the platform-specific `.android.*` / `.ios.*` lookups are skipped).
83
+ * 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.
86
+ */
87
+ suffixes: readonly string[];
88
+ /**
89
+ * Suffixes deliberately NOT resolved, but whose presence on disk is worth
90
+ * 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.
7
94
  */
8
- platform?: NativescriptPlatform;
95
+ refusedSuffixes?: readonly string[];
96
+ /**
97
+ * Consult the importer's directory listing before entering the resolver.
98
+ *
99
+ * MEASURED, and the reason this option exists at all. The NativeScript chain
100
+ * runs on one build target; the desktop chain runs on every `--app gjs` and
101
+ * `--app node` build in the repository, and a three-rung chain costs up to
102
+ * six failed `this.resolve` calls per relative import. On this repository's
103
+ * largest bundle (`dist/cli.gjs.mjs`, ~1400 modules) that was 21.9–22.2 s
104
+ * without the plugin against 25.0–26.3 s with it — about +14%, and Rolldown
105
+ * reported the plugin as 49% of all plugin time. Skipping `node_modules`
106
+ * importers recovered under a second of it: the cost is FIRST-PARTY imports,
107
+ * which is exactly the population the feature is for.
108
+ *
109
+ * So the filter is a directory listing, cached per directory: one
110
+ * `readdirSync` answers every suffix for every import in that directory, and
111
+ * the resolver is entered only for a candidate that has a matching sibling.
112
+ * Same bundle, 22.0–22.8 s and 25% of plugin time — the surcharge is gone and
113
+ * the plugin now sits alongside its four siblings instead of ahead of them.
114
+ * The listing is a FILTER and never an answer — a directory it cannot read
115
+ * (a virtual importer id, a `\0`-prefixed module) falls straight through to
116
+ * the resolver, and names are compared lowercased so a case-insensitive
117
+ * filesystem can only make the filter more permissive, never less.
118
+ *
119
+ * OFF for NativeScript, deliberately: `@nativescript/core` ships
120
+ * `.android`/`.ios` siblings and imports them relatively, and the mandate on
121
+ * that chain is byte-identical behaviour — a filter that another plugin's
122
+ * virtual `resolveId` could see past is not something to switch on there
123
+ * without a measurement of that tree.
124
+ */
125
+ siblingIndex?: boolean;
126
+ }
127
+ /** Thrown when a variant is on disk but the resolver hands it back as external. */
128
+ export declare class PlatformVariantExternalError extends Error {
129
+ readonly candidate: string;
130
+ readonly importer: string;
131
+ readonly name = "PlatformVariantExternalError";
132
+ constructor(candidate: string, importer: string);
9
133
  }
10
134
  /**
11
- * Resolve platform-specific source-file variants (`*.android` / `*.ios` /
12
- * `*.native`) ahead of the base file. Relative imports only — bare/package
13
- * specifiers are left to the normal resolver chain (package platform-`main`
14
- * fields are a separate, rarer concern handled by the alias layer).
135
+ * Resolve platform-specific source-file variants ahead of the base file.
136
+ *
137
+ * Relative imports only — bare/package specifiers are left to the normal
138
+ * resolver chain (package platform-`main` fields are a separate, rarer concern
139
+ * handled by the alias layer).
15
140
  */
16
- export declare function platformResolvePlugin(options?: PlatformResolvePluginOptions): Plugin;
141
+ export declare function platformResolvePlugin(options: PlatformResolvePluginOptions): Plugin;
17
142
  /**
18
143
  * Best-effort detection of the NativeScript target platform from the
19
144
  * environment NS' CLI sets when it spawns a bundler (`NATIVESCRIPT_BUNDLER_ENV`
@@ -1,14 +1,20 @@
1
- // NativeScript / React-Native-style platform file resolution.
1
+ // Platform file resolution — ONE plugin, ONE resolution order, two chains.
2
2
  //
3
- // Lets a shared codebase fork a single module per target platform by file
4
- // name — `foo.android.ts` / `foo.ios.ts` (platform-specific) or `foo.native.ts`
5
- // (any native platform, as opposed to the browser's `foo.ts`). An
3
+ // Lets a shared codebase fork a single module per target by file name. An
6
4
  // `import './foo'` (or `import './foo.js'`) resolves to the most specific
7
- // variant that exists, in priority order:
5
+ // variant on disk, in the priority order the caller's chain declares:
8
6
  //
9
- // ./foo.<platform>.<ext> (e.g. ./foo.android.ts) — when a platform is known
10
- // ./foo.native.<ext> — native-but-not-browser
11
- // ./foo.<ext> — the base file (default resolver fallthrough)
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
+ //
13
+ // TWO CHAINS, NOT TWO PLUGINS (ADR 0032 § 9). A second plugin would give the
14
+ // tree two resolution orders that no single file states, and the order IS the
15
+ // contract — `.gtk` before `.<os>` before `.desktop` is a decision, not an
16
+ // implementation detail. So the chain is a PARAMETER and the builders below are
17
+ // the only way to spell one.
12
18
  //
13
19
  // WHY THIS LIVES IN GJSIFY (not just a dependency on @nativescript/vite):
14
20
  // `@nativescript/vite` implements the same lookup as a Vite `resolve.alias`
@@ -17,13 +23,15 @@
17
23
  // 'ViteAlias' … function replacement into rust type String`), which breaks the
18
24
  // NS production build under Vite 8. Implemented here as a proper `resolveId`
19
25
  // plugin HOOK (not a `resolve.alias`), it works identically under Rolldown
20
- // (the `gjsify build --app nativescript` CLI) AND under Vite 7/8 (the
21
- // `gjsifyNativescript()` preset) — the function-alias limitation only affects
22
- // the `resolve.alias` config shorthand, not plugin `resolveId` hooks.
26
+ // (the `gjsify build` CLI) AND under Vite 7/8 (the `gjsifyNativescript()`
27
+ // preset) — the function-alias limitation only affects the `resolve.alias`
28
+ // config shorthand, not plugin `resolveId` hooks.
23
29
  //
24
30
  // Reference: @nativescript/vite `helpers/package-platform-aliases.js`. Original
25
31
  // Copyright (c) NativeScript contributors, Apache-2.0. Reimplemented for the
26
32
  // gjsify Rolldown/Vite plugin pipeline.
33
+ import { readdirSync } from 'node:fs';
34
+ import { basename, dirname, resolve as resolvePath } from 'node:path';
27
35
  const PLATFORMS = ['android', 'ios', 'visionos'];
28
36
  // JS/TS extensions a source specifier may carry. Stripped so the platform
29
37
  // suffix lands BEFORE the extension (`./foo.js` → `./foo.android`).
@@ -32,18 +40,162 @@ function isPlatform(value) {
32
40
  return PLATFORMS.includes(value);
33
41
  }
34
42
  /**
35
- * Resolve platform-specific source-file variants (`*.android` / `*.ios` /
36
- * `*.native`) ahead of the base file. Relative imports only — bare/package
37
- * specifiers are left to the normal resolver chain (package platform-`main`
38
- * fields are a separate, rarer concern handled by the alias layer).
43
+ * The desktop suffix for one host OS, keyed by `process.platform`.
44
+ *
45
+ * TWO VOCABULARIES MEET HERE and neither is negotiable. The KEYS are
46
+ * `process.platform`'s (`linux`/`darwin`/`win32`) — ADR 0018's target set, the
47
+ * spelling `gjsify.os` and `gjsify.platforms` use, and what a running process
48
+ * computes about itself. The VALUES are React Native's `Platform.OS` spelling
49
+ * (`linux`/`macos`/`windows`), which is what ADR 0032 § 9 writes and what
50
+ * `@gjsify/react-native`'s own `Platform.OS` reports — so a `foo.macos.tsx`
51
+ * agrees with the `Platform.OS === 'macos'` branch beside it. Spelling the file
52
+ * `foo.darwin.tsx` would make those two disagree in the one place a reader looks
53
+ * to check them against each other.
54
+ *
55
+ * Exported so `platform-resolve.spec.ts` can iterate the real values instead of
56
+ * a copy: nothing in the type system stops someone adding a `web` row here, and
57
+ * that row is exactly what § 9 forbids.
58
+ */
59
+ export const DESKTOP_OS_SUFFIXES = {
60
+ linux: 'linux',
61
+ darwin: 'macos',
62
+ win32: 'windows',
63
+ };
64
+ /**
65
+ * Suffixes that must NEVER be a rung of the DESKTOP chain, with § 9's reason.
66
+ *
67
+ * `.native` looks like an oversight and is the opposite: a `.native.tsx` is by
68
+ * definition written for a React Native runtime, so reaching for it would
69
+ * silently feed the GTK build code that expects `NativeModules` — a bridge that
70
+ * exists only in the phone host. No import error, no type error, no failing
71
+ * test; the divergence surfaces as a window that does not work. Falling through
72
+ * to the BASE file is the honest outcome, because the base file at least has to
73
+ * compile against what this target really has.
74
+ *
75
+ * `.web` is worse. It reads as the obvious pick for a desktop target — a desktop
76
+ * is not a phone, so surely the web variant? — and it carries exactly the DOM
77
+ * assumptions ADR 0032 rules out, the same two-lossy-mappings-stacked shape that
78
+ * rules out react-native-web over a DOM (§ 3, Rejected).
79
+ *
80
+ * Not silence, though: when the desktop chain falls through to base and one of
81
+ * these siblings exists, {@link platformResolvePlugin} warns naming the file.
82
+ * Without that, an author's fork is dead code with nothing anywhere saying so.
83
+ */
84
+ export const DESKTOP_REFUSED_SUFFIXES = ['native', 'web'];
85
+ /**
86
+ * The NativeScript chain: platform-specific first, then the platform-agnostic
87
+ * `native` suffix. Without a known platform only `native` applies.
88
+ */
89
+ export function nativescriptSuffixChain(platform) {
90
+ return platform ? [platform, 'native'] : ['native'];
91
+ }
92
+ /**
93
+ * The GTK/desktop chain (ADR 0032 § 9): `.gtk` → `.<os>` → `.desktop` → base.
94
+ *
95
+ * `.gtk` outranks the OS because a GTK fork is a statement about the TOOLKIT,
96
+ * which is the more specific claim: `foo.gtk.tsx` says "this is how the widget
97
+ * tree does it", `foo.linux.tsx` says "this is how this kernel does it", and a
98
+ * project holding both means the first on a GTK build.
99
+ *
100
+ * `os` is a suffix from {@link DESKTOP_OS_SUFFIXES}, never a `process.platform`
101
+ * token — see {@link desktopOsSuffix}. Omitting it drops the OS rung rather than
102
+ * guessing one.
103
+ */
104
+ export function desktopSuffixChain(os) {
105
+ return os ? ['gtk', os, 'desktop'] : ['gtk', 'desktop'];
106
+ }
107
+ /**
108
+ * The desktop suffix for the BUILD HOST, or `undefined` for a host outside
109
+ * ADR 0018's target set.
110
+ *
111
+ * The host, not a cross-target: there is no cross-OS desktop build in this chain
112
+ * (ADR 0032's Consequences put macOS and Windows on Node + `@gjsify/node-gi`,
113
+ * whose installable artifact is ADR 0024 stages 4 and 5 and does not exist yet).
114
+ * A future cross-build states its own suffix by passing one to
115
+ * {@link desktopSuffixChain}, which is why the decision function takes the value
116
+ * and only this one function reads the ambient.
117
+ *
118
+ * The ambient read is `process.platform` behind a defaulted parameter — the
119
+ * shape `extractPackageSpec` and `isAbsoluteFsPath` already use in this package.
120
+ * The canonical repo-wide spelling is `@gjsify/utils/core`'s `hostOs()`, and
121
+ * `os-axis.mjs` sanctions infra keeping its own raw read for the purity reason
122
+ * `cli/src/utils/platform-check.ts` documents. Here there is a second, harder
123
+ * reason: `@gjsify/utils` is BUILT BY the CLI this package is part of, so
124
+ * depending on it would make `build:infra` need its own output.
125
+ * `package.json#gjsify.os` on this package already carries the ADR 0018 claim.
39
126
  */
40
- export function platformResolvePlugin(options = {}) {
41
- const platform = options.platform;
42
- // Priority: platform-specific suffix first, then the platform-agnostic
43
- // `native` suffix. Without a known platform, only `native` applies.
44
- const suffixes = platform ? [platform, 'native'] : ['native'];
127
+ export function desktopOsSuffix(platform = process.platform) {
128
+ return DESKTOP_OS_SUFFIXES[platform];
129
+ }
130
+ /** Thrown when a variant is on disk but the resolver hands it back as external. */
131
+ export class PlatformVariantExternalError extends Error {
132
+ candidate;
133
+ importer;
134
+ name = 'PlatformVariantExternalError';
135
+ constructor(candidate, importer) {
136
+ super(`gjsify platform resolve: "${candidate}" (imported by ${importer}) resolved to an EXTERNAL id, ` +
137
+ `so the platform fork cannot be bundled. A platform variant is first-party source by ` +
138
+ `construction — it is a sibling file — so an externals pattern or a user --external is ` +
139
+ `claiming it. Narrow that pattern, or rename the variant.`);
140
+ this.candidate = candidate;
141
+ this.importer = importer;
142
+ }
143
+ }
144
+ /**
145
+ * Resolve platform-specific source-file variants ahead of the base file.
146
+ *
147
+ * Relative imports only — bare/package specifiers are left to the normal
148
+ * resolver chain (package platform-`main` fields are a separate, rarer concern
149
+ * handled by the alias layer).
150
+ */
151
+ export function platformResolvePlugin(options) {
152
+ const suffixes = options.suffixes;
153
+ if (suffixes.length === 0) {
154
+ // A caller meaning "no platform forks" composes no plugin at all. An
155
+ // EMPTY chain is one that was computed and came out empty — the shape a
156
+ // mis-wired orchestrator has — and it would send every import to base
157
+ // while looking installed.
158
+ throw new Error('gjsify platform resolve: the suffix chain is empty. Pass nativescriptSuffixChain(…) or ' +
159
+ 'desktopSuffixChain(…), or omit the plugin.');
160
+ }
161
+ const refused = options.refusedSuffixes ?? [];
162
+ const useIndex = options.siblingIndex ?? false;
163
+ // Per-directory listing cache, lowercased. `null` marks a directory that
164
+ // could not be read, which means "no opinion" — the resolver decides.
165
+ const listings = new Map();
166
+ const listingFor = (dir) => {
167
+ const cached = listings.get(dir);
168
+ if (cached !== undefined)
169
+ return cached;
170
+ let entries;
171
+ try {
172
+ entries = new Set(readdirSync(dir).map((name) => name.toLowerCase()));
173
+ }
174
+ catch {
175
+ // An unreadable directory is the normal case for a virtual importer
176
+ // id, not an error worth reporting: the resolver still gets asked.
177
+ entries = null;
178
+ }
179
+ listings.set(dir, entries);
180
+ return entries;
181
+ };
182
+ /** Could a `<stem>.<suffix>` sibling exist in `dir`? A file OR a directory. */
183
+ const hasSibling = (entries, stem, suffix) => {
184
+ const name = `${stem}.${suffix}`.toLowerCase();
185
+ if (entries.has(name))
186
+ return true;
187
+ for (const entry of entries) {
188
+ if (entry.startsWith(`${name}.`))
189
+ return true;
190
+ }
191
+ return false;
192
+ };
193
+ // One warning per resolved variant id, not per import site: a shared module
194
+ // reached from twenty files would print twenty identical lines and train the
195
+ // reader to skip them.
196
+ const warned = new Set();
45
197
  return {
46
- name: 'gjsify-nativescript-platform-resolve',
198
+ name: 'gjsify-platform-resolve',
47
199
  resolveId: {
48
200
  order: 'pre',
49
201
  async handler(source, importer, extraOptions) {
@@ -55,7 +207,23 @@ export function platformResolvePlugin(options = {}) {
55
207
  const extMatch = KNOWN_EXT_RE.exec(source);
56
208
  const origExt = extMatch ? extMatch[0] : '';
57
209
  const base = origExt ? source.slice(0, -origExt.length) : source;
210
+ const kind = extraOptions?.kind ? { kind: extraOptions.kind } : {};
211
+ // The filter, only when the caller asked for it — the NS path does
212
+ // not even resolve the sibling directory. `null` = no opinion.
213
+ let entries = null;
214
+ let stem = '';
215
+ if (useIndex) {
216
+ const target = resolvePath(dirname(importer), base);
217
+ entries = listingFor(dirname(target));
218
+ stem = basename(target);
219
+ }
220
+ const worthProbing = (suffix) => entries === null || hasSibling(entries, stem, suffix);
221
+ // `skipSelf: true` re-runs the resolver chain WITHOUT this
222
+ // plugin → no recursion on the nested resolve.
223
+ const tryResolve = (candidate) => this.resolve(candidate, importer, { skipSelf: true, ...kind });
58
224
  for (const suffix of suffixes) {
225
+ if (!worthProbing(suffix))
226
+ continue;
59
227
  // Try the bare form first (the resolver extension-probes,
60
228
  // so `./foo.android` finds `./foo.android.ts`), then the
61
229
  // extension-preserving form as a fallback.
@@ -65,17 +233,33 @@ export function platformResolvePlugin(options = {}) {
65
233
  for (const candidate of candidates) {
66
234
  if (candidate === source)
67
235
  continue;
68
- // `skipSelf: true` re-runs the resolver chain WITHOUT
69
- // this plugin → no recursion on the nested resolve.
70
- const resolved = await this.resolve(candidate, importer, {
71
- skipSelf: true,
72
- ...(extraOptions?.kind ? { kind: extraOptions.kind } : {}),
73
- });
74
- if (resolved && !resolved.external)
75
- return resolved;
236
+ const resolved = await tryResolve(candidate);
237
+ if (!resolved)
238
+ continue;
239
+ // An external variant is NOT a miss to walk past: the
240
+ // file is there and the author expects it in the bundle.
241
+ if (resolved.external)
242
+ throw new PlatformVariantExternalError(candidate, importer);
243
+ return resolved;
76
244
  }
77
245
  }
78
- // No variant on disk → let the default chain resolve the base.
246
+ // No variant on disk → the default chain resolves the base. Say
247
+ // so when a REFUSED sibling exists: § 9 wants the fall-through,
248
+ // not the silence around it.
249
+ for (const suffix of refused) {
250
+ if (!worthProbing(suffix))
251
+ continue;
252
+ const probe = await tryResolve(`${base}.${suffix}`);
253
+ if (!probe || warned.has(probe.id))
254
+ continue;
255
+ warned.add(probe.id);
256
+ this.warn(`gjsify platform resolve: "${base}.${suffix}" exists but is NOT a rung of this ` +
257
+ `target's chain (${suffixes.join(' → ')} → base), so ${importer} gets the base ` +
258
+ `file. ADR 0032 § 9: a .${suffix} variant is written for a runtime this build ` +
259
+ `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.`);
262
+ }
79
263
  return null;
80
264
  },
81
265
  },
@@ -1,4 +1,5 @@
1
1
  import { BUNDLE_URL_BANNER } from './bundle-url-banner.js';
2
+ import { GJS_INTL_SEGMENTER_STUB } from './intl-segmenter-banner.js';
2
3
  import { GJS_WELLKNOWN_SYMBOLS_STUB } from './wellknown-symbols-banner.js';
3
4
  import { giRuntimePathsStub } from './gi-runtime-paths.js';
4
5
  // Every GJS ambient global is reached through `globalThis.` — NEVER as a bare
@@ -84,6 +85,9 @@ export function processStubPlugin(options = {}) {
84
85
  // is single-line and idempotent.
85
86
  const stub = (options.captureBundleUrl ? BUNDLE_URL_BANNER : '') +
86
87
  GJS_WELLKNOWN_SYMBOLS_STUB +
88
+ // Before the process stub and before module init: `get-east-asian-width`
89
+ // constructs a Segmenter at module scope, so a later injection is too late.
90
+ GJS_INTL_SEGMENTER_STUB +
87
91
  GJS_PROCESS_STUB +
88
92
  giRuntimePathsStub(options.giRuntimePaths ?? [], options.giSystemProbes ?? []);
89
93
  const banner = composeBanner(stub, options.userBanner ?? '');
@@ -0,0 +1,63 @@
1
+ import type { Plugin } from 'rolldown';
2
+ /** The package whose export surface mirrors React Native's (ADR 0032 § 2). */
3
+ export declare const REACT_NATIVE_ALIAS_TARGET = "@gjsify/react-native";
4
+ /** The specifier an unmodified React Native application writes. */
5
+ export declare const REACT_NATIVE_SPECIFIER = "react-native";
6
+ /** What a specifier is, as far as this alias is concerned. */
7
+ export type ReactNativeSpecifierKind =
8
+ /** Exactly `react-native` — the package root, which is the mirrored surface. */
9
+ {
10
+ readonly kind: 'root';
11
+ }
12
+ /** `react-native/<subpath>` — internals, which are not mirrored. */
13
+ | {
14
+ readonly kind: 'subpath';
15
+ readonly subpath: string;
16
+ }
17
+ /** Anything else, including `@gjsify/react-native` itself. */
18
+ | {
19
+ readonly kind: 'other';
20
+ };
21
+ /**
22
+ * Classify an import specifier.
23
+ *
24
+ * EXACT match on the root, never a prefix: `react-native-web`,
25
+ * `react-native-reanimated` and `react-native-gesture-handler` all start with
26
+ * the same eleven characters and none of them is this package. ADR 0032 records
27
+ * the last two as `not-reachable` (they need a Babel worklet transform that is
28
+ * not in this chain) and the first as rejected by design; aliasing any of them
29
+ * onto the mirror would replace a package that legitimately does not work with
30
+ * one that silently does the wrong thing.
31
+ */
32
+ export declare function classifyReactNativeSpecifier(source: string): ReactNativeSpecifierKind;
33
+ /**
34
+ * Thrown for `react-native/<subpath>` — an import of React Native's internals.
35
+ *
36
+ * The alias rests on the export SURFACE being mirrored (§ 2), and a subpath is
37
+ * by definition not part of that surface: `react-native/Libraries/Components/…`
38
+ * reaches into a module layout that exists because of Metro and the native
39
+ * bridge. There is nothing to map it to and nothing honest to stub it with, so
40
+ * the build says so with the importer named rather than resolving to the real
41
+ * React Native source and failing later on `NativeModules`.
42
+ */
43
+ export declare class ReactNativeDeepImportError extends Error {
44
+ readonly specifier: string;
45
+ readonly importer: string;
46
+ readonly name = "ReactNativeDeepImportError";
47
+ constructor(specifier: string, importer: string);
48
+ }
49
+ /** Thrown when the alias target itself cannot be resolved. */
50
+ export declare class ReactNativeAliasTargetMissingError extends Error {
51
+ readonly importer: string;
52
+ readonly name = "ReactNativeAliasTargetMissingError";
53
+ constructor(importer: string);
54
+ }
55
+ /**
56
+ * Rewrite the bare `react-native` specifier onto `@gjsify/react-native`.
57
+ *
58
+ * `order: 'pre'` so the claim lands before the externals policy and the
59
+ * substitution-table alias layer get a look — the same position
60
+ * `platformResolvePlugin` holds, and for the same reason: a redirect that runs
61
+ * after `externalsPlugin` would find the specifier already externalised.
62
+ */
63
+ export declare function reactNativeAliasPlugin(): Plugin;
@@ -0,0 +1,128 @@
1
+ // `'react-native'` → `'@gjsify/react-native'`: the alias line ADR 0032 § 2 names.
2
+ //
3
+ // § 2 puts it this way: the package NAME `@gjsify/react-native` "is the
4
+ // documentation of the bundler alias line, and that alias only works if the
5
+ // export surface mirrors React Native's". Without the line the mirror has no
6
+ // consumer — an unmodified React Native application still cannot build, because
7
+ // every one of its files imports `react-native` and nothing rewrites it. This
8
+ // repository's own showcase had to import the gjsify name instead, which is the
9
+ // measurement: the port that was supposed to be free was a rename per file.
10
+ //
11
+ // OPT-IN, NEVER UNCONDITIONAL, and this is the part to not "simplify":
12
+ //
13
+ // 1. A build that is not a React Native port must not have `react-native`
14
+ // redirected under it. A monorepo with a phone leg and a desktop leg has
15
+ // the real `react-native` installed on purpose; rewriting the specifier for
16
+ // every `gjsify build` in that tree changes what the PHONE build resolves,
17
+ // and `--app nativescript` has its own vocabulary that this layer does not
18
+ // speak (ADR 0032, "What this does not decide").
19
+ // 2. `@gjsify/react-native` is tier 3 and optional. An unconditional alias
20
+ // makes every build in a tree without it fail on a package nobody asked
21
+ // for — and the failure names a gjsify package, so it reads as our bug.
22
+ //
23
+ // So the redirect happens only where the consumer asked for it: `gjsify build
24
+ // --dialect react-native`, or this plugin composed by hand.
25
+ //
26
+ // A `resolveId` hook and not an `aliasPlugin` entry, for two reasons the alias
27
+ // table cannot express: a deep import into React Native's internals is a NAMED
28
+ // REFUSAL rather than a rewrite (see {@link ReactNativeDeepImportError}), and an
29
+ // unresolvable target is a named error rather than a silent external — the
30
+ // substitution IS the promise, so its failure is the one thing that must not
31
+ // exit 0.
32
+ /** The package whose export surface mirrors React Native's (ADR 0032 § 2). */
33
+ export const REACT_NATIVE_ALIAS_TARGET = '@gjsify/react-native';
34
+ /** The specifier an unmodified React Native application writes. */
35
+ export const REACT_NATIVE_SPECIFIER = 'react-native';
36
+ /**
37
+ * Classify an import specifier.
38
+ *
39
+ * EXACT match on the root, never a prefix: `react-native-web`,
40
+ * `react-native-reanimated` and `react-native-gesture-handler` all start with
41
+ * the same eleven characters and none of them is this package. ADR 0032 records
42
+ * the last two as `not-reachable` (they need a Babel worklet transform that is
43
+ * not in this chain) and the first as rejected by design; aliasing any of them
44
+ * onto the mirror would replace a package that legitimately does not work with
45
+ * one that silently does the wrong thing.
46
+ */
47
+ export function classifyReactNativeSpecifier(source) {
48
+ if (source === REACT_NATIVE_SPECIFIER)
49
+ return { kind: 'root' };
50
+ if (source.startsWith(`${REACT_NATIVE_SPECIFIER}/`)) {
51
+ return { kind: 'subpath', subpath: source.slice(REACT_NATIVE_SPECIFIER.length + 1) };
52
+ }
53
+ return { kind: 'other' };
54
+ }
55
+ /**
56
+ * Thrown for `react-native/<subpath>` — an import of React Native's internals.
57
+ *
58
+ * The alias rests on the export SURFACE being mirrored (§ 2), and a subpath is
59
+ * by definition not part of that surface: `react-native/Libraries/Components/…`
60
+ * reaches into a module layout that exists because of Metro and the native
61
+ * bridge. There is nothing to map it to and nothing honest to stub it with, so
62
+ * the build says so with the importer named rather than resolving to the real
63
+ * React Native source and failing later on `NativeModules`.
64
+ */
65
+ export class ReactNativeDeepImportError extends Error {
66
+ specifier;
67
+ importer;
68
+ name = 'ReactNativeDeepImportError';
69
+ constructor(specifier, importer) {
70
+ super(`gjsify react-native alias: "${specifier}" (imported by ${importer}) reaches into React ` +
71
+ `Native's internals. The alias to ${REACT_NATIVE_ALIAS_TARGET} covers the PACKAGE ROOT, ` +
72
+ `because that is the export surface ADR 0032 § 2 mirrors; the subpath layout below it ` +
73
+ `belongs to Metro and the native bridge and has no counterpart here. Import the name from ` +
74
+ `"${REACT_NATIVE_SPECIFIER}" instead, or from "${REACT_NATIVE_ALIAS_TARGET}" directly if ` +
75
+ `it is a gjsify-only addition.`);
76
+ this.specifier = specifier;
77
+ this.importer = importer;
78
+ }
79
+ }
80
+ /** Thrown when the alias target itself cannot be resolved. */
81
+ export class ReactNativeAliasTargetMissingError extends Error {
82
+ importer;
83
+ name = 'ReactNativeAliasTargetMissingError';
84
+ constructor(importer) {
85
+ super(`gjsify react-native alias: "${REACT_NATIVE_SPECIFIER}" (imported by ${importer}) is aliased ` +
86
+ `to ${REACT_NATIVE_ALIAS_TARGET}, which does not resolve from this project. Install it, or ` +
87
+ `drop the react-native opt-in. NOT falling back to the real react-native on purpose: it is ` +
88
+ `written for a React Native runtime, so the bundle would build and then fail in the window ` +
89
+ `on a NativeModules bridge that does not exist here.`);
90
+ this.importer = importer;
91
+ }
92
+ }
93
+ /**
94
+ * Rewrite the bare `react-native` specifier onto `@gjsify/react-native`.
95
+ *
96
+ * `order: 'pre'` so the claim lands before the externals policy and the
97
+ * substitution-table alias layer get a look — the same position
98
+ * `platformResolvePlugin` holds, and for the same reason: a redirect that runs
99
+ * after `externalsPlugin` would find the specifier already externalised.
100
+ */
101
+ export function reactNativeAliasPlugin() {
102
+ return {
103
+ name: 'gjsify-react-native-alias',
104
+ resolveId: {
105
+ order: 'pre',
106
+ async handler(source, importer, extraOptions) {
107
+ const classified = classifyReactNativeSpecifier(source);
108
+ if (classified.kind === 'other')
109
+ return null;
110
+ // An ENTRY has no importer to name; report the specifier itself
111
+ // rather than the string "undefined".
112
+ const from = importer ?? source;
113
+ if (classified.kind === 'subpath')
114
+ throw new ReactNativeDeepImportError(source, from);
115
+ // `skipSelf: true` keeps the nested resolve out of this hook. The
116
+ // target is a plain package name, so the normal chain (workspace
117
+ // link, node_modules, the exports map) answers it.
118
+ const resolved = await this.resolve(REACT_NATIVE_ALIAS_TARGET, importer, {
119
+ skipSelf: true,
120
+ ...(extraOptions?.kind ? { kind: extraOptions.kind } : {}),
121
+ });
122
+ if (!resolved || resolved.external)
123
+ throw new ReactNativeAliasTargetMissingError(from);
124
+ return resolved;
125
+ },
126
+ },
127
+ };
128
+ }