@gjsify/rolldown-plugin-gjsify 0.43.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.
@@ -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
  },
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ import type { Plugin } from 'rolldown';
2
+ import { ImportScanParseError, type NamedImport, type OpaqueReference } from '../utils/scan-named-imports.js';
3
+ /**
4
+ * The two functions the gate needs off the support table.
5
+ *
6
+ * Structural, so a spec can pass a three-row fixture and so the plugin never
7
+ * types itself against a tier-3 package it must not import.
8
+ */
9
+ export interface SupportTableReader {
10
+ /** True for `supported` and `partial` — the statuses a build may import. */
11
+ isImportable(name: string): boolean;
12
+ /** The one sentence the build error and the runtime throw both print. */
13
+ explainUnsupported(name: string): string;
14
+ }
15
+ /** The subpath that carries the table in an installed `@gjsify/react-native`. */
16
+ export declare const SUPPORT_TABLE_SUBPATH = "@gjsify/react-native/support-table";
17
+ /**
18
+ * Both spellings of the layer are watched.
19
+ *
20
+ * A ported application writes `react-native` (the alias rewrites it, and the
21
+ * rewrite happens in `resolveId` — the SOURCE text still says `react-native`
22
+ * when this hook reads it). A gjsify-native application, this repository's own
23
+ * showcase included, writes `@gjsify/react-native`. The gate is about the
24
+ * SURFACE, not about which name reached it.
25
+ */
26
+ export declare const WATCHED_SPECIFIERS: readonly string[];
27
+ /** One refused import, with everything the message needs. */
28
+ export interface SupportViolation {
29
+ readonly name: string;
30
+ readonly specifier: string;
31
+ readonly line: number;
32
+ readonly column: number;
33
+ /** The table's own sentence for this name. */
34
+ readonly reason: string;
35
+ }
36
+ /** Thrown for every refused import in one module, in one message. */
37
+ export declare class ReactNativeUnsupportedImportError extends Error {
38
+ readonly id: string;
39
+ readonly violations: readonly SupportViolation[];
40
+ readonly name = "ReactNativeUnsupportedImportError";
41
+ constructor(id: string, violations: readonly SupportViolation[]);
42
+ }
43
+ /** Thrown when the gate is on but the table it must read is not reachable. */
44
+ export declare class SupportTableUnreadableError extends Error {
45
+ readonly name = "SupportTableUnreadableError";
46
+ constructor(detail: string, cause?: unknown);
47
+ }
48
+ /**
49
+ * All violations for one module as one message.
50
+ *
51
+ * One message and not one per name: an application that imports `FlatList`,
52
+ * `Animated` and `Image` from the same file has one porting decision to make,
53
+ * and three separate build failures would show it one third of that decision per
54
+ * run.
55
+ */
56
+ export declare function formatSupportViolations(id: string, violations: readonly SupportViolation[]): string;
57
+ /** The refused subset of a scan, in the table's words. */
58
+ export declare function findSupportViolations(named: readonly NamedImport[], table: SupportTableReader): readonly SupportViolation[];
59
+ /** The warning for a reference whose names the scan cannot see. */
60
+ export declare function formatOpaqueReference(id: string, ref: OpaqueReference): string;
61
+ /**
62
+ * The warning for a module the parser could not read.
63
+ *
64
+ * A WARNING and not a build error, which is the one place this gate deliberately
65
+ * does not fail. `acorn-typescript` 1.4.13 cannot parse a `satisfies` expression
66
+ * or a `const` type parameter (measured; see `scan-named-imports.ts`), both of
67
+ * which are ordinary TypeScript in a tree pinned to `typescript: "^6.0.3"`.
68
+ * Erroring there would refuse a program that is correct, and this repository has
69
+ * already written down what that costs: "a false violation is how a checker
70
+ * teaches people to ignore it" (`check-rn-surface.mjs`). The shape used instead
71
+ * is the one that file also uses — degrade to the weaker half and SAY SO, out
72
+ * loud, per file, rather than degrade quietly.
73
+ */
74
+ export declare function formatUnreadableModule(error: ImportScanParseError): string;
75
+ /** The resolver slice this plugin needs, so `loadSupportTable` is testable. */
76
+ export interface SupportTableResolver {
77
+ resolve(specifier: string, importer: string): Promise<{
78
+ id: string;
79
+ external?: boolean | string;
80
+ } | null>;
81
+ }
82
+ /**
83
+ * Resolve and import the support table.
84
+ *
85
+ * `importer` is a module from the project being built, so the resolve lands in
86
+ * the PROJECT's dependency tree rather than the CLI's — the gate must describe
87
+ * the layer this bundle will actually contain.
88
+ */
89
+ export declare function loadSupportTable(resolver: SupportTableResolver, importer: string, load?: (href: string) => Promise<unknown>): Promise<SupportTableReader>;
90
+ export interface ReactNativeSupportGateOptions {
91
+ /**
92
+ * The table, when the caller already has it. Left unset in a real build,
93
+ * where {@link loadSupportTable} reads the project's own copy on the first
94
+ * module that mentions the layer.
95
+ */
96
+ table?: SupportTableReader;
97
+ }
98
+ /**
99
+ * Fail the build on an import of a React Native name whose status is not
100
+ * `supported` or `partial`.
101
+ *
102
+ * A `transform` hook at `order: 'pre'`, which is what makes `import type` visible
103
+ * — by the time a normal-order hook runs, the annotations may already have been
104
+ * stripped, and the gate would then be unable to tell the five type-only imports
105
+ * ADR 0032 measured from five value imports. Same reason `deepkitPlugin` is
106
+ * composed as a prePlugin.
107
+ */
108
+ export declare function reactNativeSupportGatePlugin(options?: ReactNativeSupportGateOptions): Plugin;