@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.
- package/lib/app/gjs.js +28 -0
- package/lib/app/nativescript.js +2 -2
- package/lib/app/node.d.ts +5 -2
- package/lib/app/node.js +118 -2
- package/lib/index.d.ts +9 -1
- package/lib/index.js +5 -1
- package/lib/plugins/platform-resolve.d.ts +133 -8
- package/lib/plugins/platform-resolve.js +214 -30
- package/lib/plugins/react-native-alias.d.ts +63 -0
- package/lib/plugins/react-native-alias.js +128 -0
- package/lib/plugins/react-native-gate.d.ts +108 -0
- package/lib/plugins/react-native-gate.js +235 -0
- package/lib/plugins/rn-route-manifest.d.ts +67 -0
- package/lib/plugins/rn-route-manifest.js +166 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.js +1 -0
- package/lib/types/plugin-options.d.ts +20 -0
- package/lib/types/source-dialect.d.ts +42 -0
- package/lib/types/source-dialect.js +21 -0
- package/lib/utils/scan-named-imports.d.ts +47 -0
- package/lib/utils/scan-named-imports.js +239 -0
- package/package.json +15 -9
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Platform file resolution — ONE plugin, ONE resolution order, two chains.
|
|
2
2
|
//
|
|
3
|
-
// Lets a shared codebase fork a single module per target
|
|
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
|
|
5
|
+
// variant on disk, in the priority order the caller's chain declares:
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
// ./foo.
|
|
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
|
+
//
|
|
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 →
|
|
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;
|