@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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// The build-time half of ADR 0032 § 8's gate: fail the BUILD on an import of a
|
|
2
|
+
// React Native name this layer does not answer for.
|
|
3
|
+
//
|
|
4
|
+
// § 8 makes `packages/framework/react-native/src/support-table.ts` the one source
|
|
5
|
+
// three readers share — the bundler gate, the runtime refusals, and the generated
|
|
6
|
+
// README. Two of the three existed. This is the first, and the only thing it has
|
|
7
|
+
// over the runtime backstop is WHEN and WHERE: it names the importing file and
|
|
8
|
+
// the line before anything runs, instead of throwing the first time a code path
|
|
9
|
+
// is reached in a window. That is the whole point ADR 0032 states as the project
|
|
10
|
+
// goal — "porting is cheap and every divergence is knowable at build time rather
|
|
11
|
+
// than discoverable in a window".
|
|
12
|
+
//
|
|
13
|
+
// IT READS THE TABLE, NEVER A COPY. `@gjsify/react-native/support-table` is a
|
|
14
|
+
// published subpath (`./lib/esm/support-table.js`) exporting `isImportable` and
|
|
15
|
+
// `explainUnsupported`, and the plugin resolves it through the bundler's own
|
|
16
|
+
// resolver and imports it. Reading the SOURCE the way `scripts/check-rn-surface.mjs`
|
|
17
|
+
// does is not an option here: `files` on that package ships `lib` and not `src`,
|
|
18
|
+
// so in a consumer's `node_modules` there is no `support-table.ts` to parse. The
|
|
19
|
+
// built module is dependency-free — no imports at all, pure data plus two
|
|
20
|
+
// functions — so importing it costs nothing and cannot drag GTK into the bundler
|
|
21
|
+
// process.
|
|
22
|
+
//
|
|
23
|
+
// AND IT IS NOT A DEPENDENCY, deliberately. `@gjsify/rolldown-plugin-gjsify` is
|
|
24
|
+
// tier 1 and `@gjsify/react-native` is tier 3; a tier-1 package may not depend on
|
|
25
|
+
// a higher tier (`scripts/audit-runtimes.mjs`). Resolving the table from the
|
|
26
|
+
// PROJECT is also the more correct answer than bundling a snapshot would be: the
|
|
27
|
+
// gate then states exactly what the build's own copy of the layer supports, so a
|
|
28
|
+
// version skew between plugin and layer cannot make the gate lie in either
|
|
29
|
+
// direction.
|
|
30
|
+
import { pathToFileURL } from 'node:url';
|
|
31
|
+
import { REACT_NATIVE_ALIAS_TARGET, REACT_NATIVE_SPECIFIER } from './react-native-alias.js';
|
|
32
|
+
import { REWRITE_FILTER } from './rewrite-node-modules-paths.js';
|
|
33
|
+
import { ImportScanParseError, scanNamedImports, } from '../utils/scan-named-imports.js';
|
|
34
|
+
/** The subpath that carries the table in an installed `@gjsify/react-native`. */
|
|
35
|
+
export const SUPPORT_TABLE_SUBPATH = `${REACT_NATIVE_ALIAS_TARGET}/support-table`;
|
|
36
|
+
/**
|
|
37
|
+
* Both spellings of the layer are watched.
|
|
38
|
+
*
|
|
39
|
+
* A ported application writes `react-native` (the alias rewrites it, and the
|
|
40
|
+
* rewrite happens in `resolveId` — the SOURCE text still says `react-native`
|
|
41
|
+
* when this hook reads it). A gjsify-native application, this repository's own
|
|
42
|
+
* showcase included, writes `@gjsify/react-native`. The gate is about the
|
|
43
|
+
* SURFACE, not about which name reached it.
|
|
44
|
+
*/
|
|
45
|
+
export const WATCHED_SPECIFIERS = [REACT_NATIVE_SPECIFIER, REACT_NATIVE_ALIAS_TARGET];
|
|
46
|
+
/** Thrown for every refused import in one module, in one message. */
|
|
47
|
+
export class ReactNativeUnsupportedImportError extends Error {
|
|
48
|
+
id;
|
|
49
|
+
violations;
|
|
50
|
+
name = 'ReactNativeUnsupportedImportError';
|
|
51
|
+
constructor(id, violations) {
|
|
52
|
+
super(formatSupportViolations(id, violations));
|
|
53
|
+
this.id = id;
|
|
54
|
+
this.violations = violations;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Thrown when the gate is on but the table it must read is not reachable. */
|
|
58
|
+
export class SupportTableUnreadableError extends Error {
|
|
59
|
+
name = 'SupportTableUnreadableError';
|
|
60
|
+
constructor(detail, cause) {
|
|
61
|
+
super(`gjsify react-native gate: cannot read ${SUPPORT_TABLE_SUBPATH} — ${detail}. The gate has no ` +
|
|
62
|
+
`second source to fall back on, and ADR 0032 § 8 is explicit that a hand-maintained table ` +
|
|
63
|
+
`beside it would be the second truth this repository has already collected several times. ` +
|
|
64
|
+
`Install ${REACT_NATIVE_ALIAS_TARGET} (and build it, if it is a workspace link), or drop ` +
|
|
65
|
+
`the react-native opt-in.`, cause === undefined ? undefined : { cause });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* All violations for one module as one message.
|
|
70
|
+
*
|
|
71
|
+
* One message and not one per name: an application that imports `FlatList`,
|
|
72
|
+
* `Animated` and `Image` from the same file has one porting decision to make,
|
|
73
|
+
* and three separate build failures would show it one third of that decision per
|
|
74
|
+
* run.
|
|
75
|
+
*/
|
|
76
|
+
export function formatSupportViolations(id, violations) {
|
|
77
|
+
const lines = violations.map((v) => ` ${id}:${v.line}:${v.column} ${v.name}\n ${v.reason}`);
|
|
78
|
+
return (`gjsify react-native gate: ${violations.length} import(s) this layer does not answer for:\n` +
|
|
79
|
+
`${lines.join('\n')}\n` +
|
|
80
|
+
`Each sentence above is the support table's own (ADR 0032 § 8), so it is the same reason the ` +
|
|
81
|
+
`runtime would give. A "not implemented yet" entry is where to contribute; a refusal names what ` +
|
|
82
|
+
`to write instead.`);
|
|
83
|
+
}
|
|
84
|
+
/** The refused subset of a scan, in the table's words. */
|
|
85
|
+
export function findSupportViolations(named, table) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const entry of named) {
|
|
88
|
+
if (table.isImportable(entry.name))
|
|
89
|
+
continue;
|
|
90
|
+
out.push({
|
|
91
|
+
name: entry.name,
|
|
92
|
+
specifier: entry.specifier,
|
|
93
|
+
line: entry.line,
|
|
94
|
+
column: entry.column,
|
|
95
|
+
// The table's sentence, never a rephrasing of it: `explainUnsupported`
|
|
96
|
+
// exists so the build error and the runtime throw cannot describe the
|
|
97
|
+
// same gap differently.
|
|
98
|
+
reason: table.explainUnsupported(entry.name),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/** The warning for a reference whose names the scan cannot see. */
|
|
104
|
+
export function formatOpaqueReference(id, ref) {
|
|
105
|
+
const forms = {
|
|
106
|
+
namespace: 'a namespace import (`import * as …`)',
|
|
107
|
+
default: 'a default import',
|
|
108
|
+
'export-all': 'a star re-export (`export * from …`)',
|
|
109
|
+
'dynamic-import': 'a dynamic `import()`',
|
|
110
|
+
require: 'a `require()`',
|
|
111
|
+
};
|
|
112
|
+
return (`gjsify react-native gate: ${id}:${ref.line}:${ref.column} reaches "${ref.specifier}" through ` +
|
|
113
|
+
`${forms[ref.form]}, so the NAMES it uses are not knowable at build time and this gate cannot ` +
|
|
114
|
+
`check them. The runtime refusal still covers them — it reads the same table (ADR 0032 § 8) — but ` +
|
|
115
|
+
`it fires when the code path runs, not now. Import the names you use to move the answer back to ` +
|
|
116
|
+
`build time.`);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The warning for a module the parser could not read.
|
|
120
|
+
*
|
|
121
|
+
* A WARNING and not a build error, which is the one place this gate deliberately
|
|
122
|
+
* does not fail. `acorn-typescript` 1.4.13 cannot parse a `satisfies` expression
|
|
123
|
+
* or a `const` type parameter (measured; see `scan-named-imports.ts`), both of
|
|
124
|
+
* which are ordinary TypeScript in a tree pinned to `typescript: "^6.0.3"`.
|
|
125
|
+
* Erroring there would refuse a program that is correct, and this repository has
|
|
126
|
+
* already written down what that costs: "a false violation is how a checker
|
|
127
|
+
* teaches people to ignore it" (`check-rn-surface.mjs`). The shape used instead
|
|
128
|
+
* is the one that file also uses — degrade to the weaker half and SAY SO, out
|
|
129
|
+
* loud, per file, rather than degrade quietly.
|
|
130
|
+
*/
|
|
131
|
+
export function formatUnreadableModule(error) {
|
|
132
|
+
return (`${error.message} The build continues: the runtime refusal reads the same table (ADR 0032 § 8) and ` +
|
|
133
|
+
`still covers this module, but it fires when the code path runs rather than now.`);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve and import the support table.
|
|
137
|
+
*
|
|
138
|
+
* `importer` is a module from the project being built, so the resolve lands in
|
|
139
|
+
* the PROJECT's dependency tree rather than the CLI's — the gate must describe
|
|
140
|
+
* the layer this bundle will actually contain.
|
|
141
|
+
*/
|
|
142
|
+
export async function loadSupportTable(resolver, importer, load = (href) => import(/* @vite-ignore */ href)) {
|
|
143
|
+
let resolved;
|
|
144
|
+
try {
|
|
145
|
+
resolved = await resolver.resolve(SUPPORT_TABLE_SUBPATH, importer);
|
|
146
|
+
}
|
|
147
|
+
catch (cause) {
|
|
148
|
+
throw new SupportTableUnreadableError(`the resolver threw for ${importer}`, cause);
|
|
149
|
+
}
|
|
150
|
+
if (!resolved)
|
|
151
|
+
throw new SupportTableUnreadableError(`it does not resolve from ${importer}`);
|
|
152
|
+
if (resolved.external) {
|
|
153
|
+
throw new SupportTableUnreadableError(`it resolved as EXTERNAL from ${importer}, so there is no file to read`);
|
|
154
|
+
}
|
|
155
|
+
let mod;
|
|
156
|
+
try {
|
|
157
|
+
// A file path, not the bare specifier: GJS's ESM loader does not follow
|
|
158
|
+
// `package.json#exports`, so the bare form works on Node and fails under
|
|
159
|
+
// the GJS engine — the same limitation the CLI's by-name plugin loader
|
|
160
|
+
// documents. The resolver already did the exports-map hop.
|
|
161
|
+
mod = await load(pathToFileURL(resolved.id).href);
|
|
162
|
+
}
|
|
163
|
+
catch (cause) {
|
|
164
|
+
throw new SupportTableUnreadableError(`importing ${resolved.id} failed`, cause);
|
|
165
|
+
}
|
|
166
|
+
const candidate = mod;
|
|
167
|
+
if (typeof candidate.isImportable !== 'function' || typeof candidate.explainUnsupported !== 'function') {
|
|
168
|
+
throw new SupportTableUnreadableError(`${resolved.id} does not export isImportable + explainUnsupported. ` +
|
|
169
|
+
`That is a version skew between the bundler plugin and the layer, not a missing install`);
|
|
170
|
+
}
|
|
171
|
+
return { isImportable: candidate.isImportable, explainUnsupported: candidate.explainUnsupported };
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Fail the build on an import of a React Native name whose status is not
|
|
175
|
+
* `supported` or `partial`.
|
|
176
|
+
*
|
|
177
|
+
* A `transform` hook at `order: 'pre'`, which is what makes `import type` visible
|
|
178
|
+
* — by the time a normal-order hook runs, the annotations may already have been
|
|
179
|
+
* stripped, and the gate would then be unable to tell the five type-only imports
|
|
180
|
+
* ADR 0032 measured from five value imports. Same reason `deepkitPlugin` is
|
|
181
|
+
* composed as a prePlugin.
|
|
182
|
+
*/
|
|
183
|
+
export function reactNativeSupportGatePlugin(options = {}) {
|
|
184
|
+
let table = options.table;
|
|
185
|
+
return {
|
|
186
|
+
name: 'gjsify-react-native-gate',
|
|
187
|
+
transform: {
|
|
188
|
+
order: 'pre',
|
|
189
|
+
filter: { id: REWRITE_FILTER },
|
|
190
|
+
async handler(code, id) {
|
|
191
|
+
// Re-applied inside the handler, the way
|
|
192
|
+
// `nodeModulesPathRewritePlugin` does: the object-form `filter` is
|
|
193
|
+
// engine plumbing (under GJS `toNativePlugin` lifts `filter.id`
|
|
194
|
+
// into `@gjsify/rolldown-native`'s plugin-level `idFilter`), and a
|
|
195
|
+
// gate must not depend on plumbing to stay off `.css` / `.blp` /
|
|
196
|
+
// a data URL, all of which acorn would reject as invalid JS.
|
|
197
|
+
if (!REWRITE_FILTER.test(id))
|
|
198
|
+
return null;
|
|
199
|
+
// Text prefilter before the parse, the same cheap gate
|
|
200
|
+
// `shouldInline` uses: both watched specifiers contain
|
|
201
|
+
// `react-native`, so one `includes` clears every module that has
|
|
202
|
+
// nothing to do with this layer.
|
|
203
|
+
if (!code.includes(REACT_NATIVE_SPECIFIER))
|
|
204
|
+
return null;
|
|
205
|
+
// The ONE catch in this file, with a real throw path and a
|
|
206
|
+
// stated reason: `scanNamedImports` throws on syntax the pinned
|
|
207
|
+
// acorn-typescript cannot read, and the policy for that lives
|
|
208
|
+
// here rather than in the pure function — see
|
|
209
|
+
// {@link formatUnreadableModule}.
|
|
210
|
+
let scanned;
|
|
211
|
+
try {
|
|
212
|
+
scanned = scanNamedImports(code, id, WATCHED_SPECIFIERS);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (!(error instanceof ImportScanParseError))
|
|
216
|
+
throw error;
|
|
217
|
+
this.warn(formatUnreadableModule(error));
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const { named, opaque } = scanned;
|
|
221
|
+
if (named.length === 0 && opaque.length === 0)
|
|
222
|
+
return null;
|
|
223
|
+
table ??= await loadSupportTable(this, id);
|
|
224
|
+
const violations = findSupportViolations(named, table);
|
|
225
|
+
if (violations.length > 0)
|
|
226
|
+
throw new ReactNativeUnsupportedImportError(id, violations);
|
|
227
|
+
for (const ref of opaque)
|
|
228
|
+
this.warn(formatOpaqueReference(id, ref));
|
|
229
|
+
// A gate, not a transform: the source is returned unchanged and
|
|
230
|
+
// the module goes on through the chain.
|
|
231
|
+
return null;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Plugin } from 'rolldown';
|
|
2
|
+
/** The specifier an application imports. Overridable, because a name is a name. */
|
|
3
|
+
export declare const RN_ROUTES_MODULE_ID = "virtual:gjsify-rn-routes";
|
|
4
|
+
/**
|
|
5
|
+
* How deep the walk goes before it refuses.
|
|
6
|
+
*
|
|
7
|
+
* A LOUD limit rather than none: an unbounded walk turns a symlink cycle into a hang
|
|
8
|
+
* or an out-of-memory in the bundler, where the failure is not attributable to the
|
|
9
|
+
* directory that caused it. Ten levels is far past any real route tree — the measured
|
|
10
|
+
* application in ADR 0032 uses three — so hitting this is a finding, not a tuning
|
|
11
|
+
* problem, and the message says which path was at the bottom.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MAX_ROUTE_DEPTH = 10;
|
|
14
|
+
/** A file the walk found, relative to the routes directory. */
|
|
15
|
+
export interface FoundRoute {
|
|
16
|
+
/** Path relative to the routes directory, with `/` separators. */
|
|
17
|
+
readonly contextKey: string;
|
|
18
|
+
/** Absolute path, for the generated import. */
|
|
19
|
+
readonly file: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RnRouteManifestOptions {
|
|
22
|
+
/** The directory holding the route files — expo-router's `app/`. */
|
|
23
|
+
routesDir: string;
|
|
24
|
+
/** The specifier to answer. Defaults to `virtual:gjsify-rn-routes`. */
|
|
25
|
+
virtualId?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The error this plugin refuses a build with. */
|
|
28
|
+
export declare class RouteManifestError extends Error {
|
|
29
|
+
readonly name = "RouteManifestError";
|
|
30
|
+
constructor(message: string);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every file under `root`, deepest last, sorted — WALKED, never globbed.
|
|
34
|
+
*
|
|
35
|
+
* A glob is blind to the first file that lands in a subdirectory it did not think to
|
|
36
|
+
* match, and going blind is the failure this plugin exists to remove: a route file
|
|
37
|
+
* nobody imports is a screen that is simply not there. Sorted in CODE-UNIT order so the
|
|
38
|
+
* emitted module is byte-identical for the same tree on every machine, which is what
|
|
39
|
+
* makes a build cache honest — see the comparator for what a locale-aware sort cost.
|
|
40
|
+
*
|
|
41
|
+
* Directories are walked and every FILE is reported, whatever its extension. Filtering
|
|
42
|
+
* here would be a silent drop with a plausible excuse — the router refuses a file that
|
|
43
|
+
* matches no convention BY NAME, and that message is the one a reader can act on.
|
|
44
|
+
*/
|
|
45
|
+
export declare function walkRoutes(root: string, depth?: number, prefix?: string): readonly FoundRoute[];
|
|
46
|
+
/**
|
|
47
|
+
* The virtual module's source.
|
|
48
|
+
*
|
|
49
|
+
* Exported so a spec can assert the text rather than run a bundler for it, and so a
|
|
50
|
+
* consumer's own tooling can produce the same module for a different bundler.
|
|
51
|
+
*
|
|
52
|
+
* The generated imports use POSIX separators even on Windows, because a module
|
|
53
|
+
* specifier is not a path and a backslash in one is an escape.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renderRouteManifest(routes: readonly FoundRoute[], routesDir: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Serve the route manifest for `routesDir` as a virtual module.
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* gjsifyPlugin({ … }),
|
|
61
|
+
* rnRouteManifestPlugin({ routesDir: 'app' }),
|
|
62
|
+
* ```
|
|
63
|
+
* ```ts
|
|
64
|
+
* import { manifest } from 'virtual:gjsify-rn-routes';
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export declare function rnRouteManifestPlugin(options: RnRouteManifestOptions): Plugin;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The file-based route manifest, as a virtual module.
|
|
2
|
+
//
|
|
3
|
+
// `expo-router` discovers its routes with Metro's `require.context`, which does not
|
|
4
|
+
// exist in this chain and is not going to. What replaces it is this: walk a declared
|
|
5
|
+
// directory, emit one module that STATICALLY imports every file in it, and let
|
|
6
|
+
// `@gjsify/react-native/router` turn that list into a tree.
|
|
7
|
+
//
|
|
8
|
+
// WHERE THE SPLIT IS, AND WHY IT IS HERE. This plugin knows about a FILESYSTEM: which
|
|
9
|
+
// directory, which files, whether the directory exists. It knows nothing about what a
|
|
10
|
+
// name MEANS — `(group)`, `[param]`, `_layout` and `+not-found` are the router's
|
|
11
|
+
// semantics, and `@gjsify/react-native/router`'s `buildRouteTree` owns them. That is
|
|
12
|
+
// not tidiness: ADR 0032 § 12 says the build chain belongs to the consumer, so a
|
|
13
|
+
// consumer on another bundler, or one writing the nine-line manifest by hand, has to
|
|
14
|
+
// get the same refusals — and they only can if the conventions live in the package
|
|
15
|
+
// they import rather than in the plugin they replaced. It also means there is ONE
|
|
16
|
+
// parser; a copy here would be the second truth this repository keeps paying for.
|
|
17
|
+
//
|
|
18
|
+
// So the refusals below are exactly the ones a filesystem can produce, and each names
|
|
19
|
+
// the resolved absolute path, because "routes directory not found" without the path it
|
|
20
|
+
// looked for is the least useful build error there is.
|
|
21
|
+
//
|
|
22
|
+
// STATIC IMPORTS, NOT `() => import(…)`. A lazy route needs a Suspense boundary, and
|
|
23
|
+
// `@gjsify/gtk-host/react`'s `render()` is synchronous by construction — a boundary
|
|
24
|
+
// that suspends on the first commit leaves the container EMPTY and returns cleanly,
|
|
25
|
+
// which is the silent-empty-window failure that host exists to refuse. A desktop
|
|
26
|
+
// application also ships as one artifact (ADR 0024), so there is no download for
|
|
27
|
+
// laziness to defer.
|
|
28
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
29
|
+
import { posix, resolve, sep } from 'node:path';
|
|
30
|
+
/** The specifier an application imports. Overridable, because a name is a name. */
|
|
31
|
+
export const RN_ROUTES_MODULE_ID = 'virtual:gjsify-rn-routes';
|
|
32
|
+
/**
|
|
33
|
+
* How deep the walk goes before it refuses.
|
|
34
|
+
*
|
|
35
|
+
* A LOUD limit rather than none: an unbounded walk turns a symlink cycle into a hang
|
|
36
|
+
* or an out-of-memory in the bundler, where the failure is not attributable to the
|
|
37
|
+
* directory that caused it. Ten levels is far past any real route tree — the measured
|
|
38
|
+
* application in ADR 0032 uses three — so hitting this is a finding, not a tuning
|
|
39
|
+
* problem, and the message says which path was at the bottom.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_ROUTE_DEPTH = 10;
|
|
42
|
+
/** The error this plugin refuses a build with. */
|
|
43
|
+
export class RouteManifestError extends Error {
|
|
44
|
+
name = 'RouteManifestError';
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(`@gjsify/rolldown-plugin-gjsify: rnRouteManifestPlugin — ${message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every file under `root`, deepest last, sorted — WALKED, never globbed.
|
|
51
|
+
*
|
|
52
|
+
* A glob is blind to the first file that lands in a subdirectory it did not think to
|
|
53
|
+
* match, and going blind is the failure this plugin exists to remove: a route file
|
|
54
|
+
* nobody imports is a screen that is simply not there. Sorted in CODE-UNIT order so the
|
|
55
|
+
* emitted module is byte-identical for the same tree on every machine, which is what
|
|
56
|
+
* makes a build cache honest — see the comparator for what a locale-aware sort cost.
|
|
57
|
+
*
|
|
58
|
+
* Directories are walked and every FILE is reported, whatever its extension. Filtering
|
|
59
|
+
* here would be a silent drop with a plausible excuse — the router refuses a file that
|
|
60
|
+
* matches no convention BY NAME, and that message is the one a reader can act on.
|
|
61
|
+
*/
|
|
62
|
+
export function walkRoutes(root, depth = 0, prefix = '') {
|
|
63
|
+
if (depth > MAX_ROUTE_DEPTH) {
|
|
64
|
+
throw new RouteManifestError(`the routes directory nests more than ${MAX_ROUTE_DEPTH} levels deep at "${prefix}". That is past any ` +
|
|
65
|
+
'real route tree, so this is almost certainly a symlink cycle — an unbounded walk would hang the ' +
|
|
66
|
+
'bundler instead of naming the path');
|
|
67
|
+
}
|
|
68
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
69
|
+
const found = [];
|
|
70
|
+
// CODE-UNIT ORDER, NOT `localeCompare`. Measured: `localeCompare` put
|
|
71
|
+
// `_layout.tsx` before `README.md` here and `index.tsx` between them under ICU's
|
|
72
|
+
// default collation, which treats `_` as variable punctuation and letters
|
|
73
|
+
// case-insensitively — an order that depends on the machine's collation data. A
|
|
74
|
+
// build whose emitted module text depends on the host's locale is a build cache
|
|
75
|
+
// that misses for no reason, and a diff that appears on someone else's laptop.
|
|
76
|
+
for (const entry of [...entries].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) {
|
|
77
|
+
const absolute = `${root}${sep}${entry.name}`;
|
|
78
|
+
const contextKey = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
79
|
+
// `statSync` rather than the dirent's own kind, so a SYMLINK to a directory is
|
|
80
|
+
// walked like the directory it points at — which is what a monorepo doing
|
|
81
|
+
// route sharing produces, and `isDirectory()` on the dirent answers false for.
|
|
82
|
+
if (statSync(absolute).isDirectory())
|
|
83
|
+
found.push(...walkRoutes(absolute, depth + 1, contextKey));
|
|
84
|
+
else
|
|
85
|
+
found.push({ contextKey, file: absolute });
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The virtual module's source.
|
|
91
|
+
*
|
|
92
|
+
* Exported so a spec can assert the text rather than run a bundler for it, and so a
|
|
93
|
+
* consumer's own tooling can produce the same module for a different bundler.
|
|
94
|
+
*
|
|
95
|
+
* The generated imports use POSIX separators even on Windows, because a module
|
|
96
|
+
* specifier is not a path and a backslash in one is an escape.
|
|
97
|
+
*/
|
|
98
|
+
export function renderRouteManifest(routes, routesDir) {
|
|
99
|
+
if (routes.length === 0) {
|
|
100
|
+
throw new RouteManifestError(`the routes directory "${routesDir}" holds no files, so there is nothing to route to. Add an ` +
|
|
101
|
+
'`index.tsx` and a `_layout.tsx` to it, or point `routesDir` at the directory that has them');
|
|
102
|
+
}
|
|
103
|
+
const lines = [
|
|
104
|
+
'// GENERATED by rnRouteManifestPlugin — do not edit, and do not commit.',
|
|
105
|
+
'//',
|
|
106
|
+
'// One static import per route file, and the manifest `@gjsify/react-native/router`',
|
|
107
|
+
`// takes. Walked from ${JSON.stringify(routesDir)}.`,
|
|
108
|
+
'',
|
|
109
|
+
];
|
|
110
|
+
routes.forEach((route, index) => {
|
|
111
|
+
lines.push(`import * as route${index} from ${JSON.stringify(route.file.split(sep).join(posix.sep))};`);
|
|
112
|
+
});
|
|
113
|
+
lines.push('', 'export const manifest = [');
|
|
114
|
+
routes.forEach((route, index) => {
|
|
115
|
+
lines.push(` { contextKey: ${JSON.stringify(route.contextKey)}, module: route${index} },`);
|
|
116
|
+
});
|
|
117
|
+
lines.push('];', '', 'export default manifest;', '');
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Serve the route manifest for `routesDir` as a virtual module.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* gjsifyPlugin({ … }),
|
|
125
|
+
* rnRouteManifestPlugin({ routesDir: 'app' }),
|
|
126
|
+
* ```
|
|
127
|
+
* ```ts
|
|
128
|
+
* import { manifest } from 'virtual:gjsify-rn-routes';
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export function rnRouteManifestPlugin(options) {
|
|
132
|
+
const virtualId = options.virtualId ?? RN_ROUTES_MODULE_ID;
|
|
133
|
+
// `\0` is rollup's own convention for "this id is mine": it survives the resolver
|
|
134
|
+
// chain and makes any other plugin's path handling leave it alone.
|
|
135
|
+
const resolved = `\0${virtualId}`;
|
|
136
|
+
return {
|
|
137
|
+
name: 'gjsify-rn-route-manifest',
|
|
138
|
+
resolveId(source) {
|
|
139
|
+
return source === virtualId ? resolved : null;
|
|
140
|
+
},
|
|
141
|
+
load(id) {
|
|
142
|
+
if (id !== resolved)
|
|
143
|
+
return null;
|
|
144
|
+
const routesDir = resolve(options.routesDir);
|
|
145
|
+
let stats;
|
|
146
|
+
try {
|
|
147
|
+
stats = statSync(routesDir);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
throw new RouteManifestError(`the routes directory "${routesDir}" does not exist. \`routesDir\` is resolved against the ` +
|
|
151
|
+
'process working directory, so a relative path means something different when the ' +
|
|
152
|
+
'bundler is spawned from elsewhere — pass an absolute one if that is in play');
|
|
153
|
+
}
|
|
154
|
+
if (!stats.isDirectory()) {
|
|
155
|
+
throw new RouteManifestError(`"${routesDir}" is a file, not a directory. \`routesDir\` names the directory that holds the route files`);
|
|
156
|
+
}
|
|
157
|
+
const routes = walkRoutes(routesDir);
|
|
158
|
+
// Every file becomes a WATCHED dependency, so adding a route file
|
|
159
|
+
// invalidates this module in a watch build. Without it a new screen needs a
|
|
160
|
+
// restart, which reads as "the router did not pick it up".
|
|
161
|
+
for (const route of routes)
|
|
162
|
+
this.addWatchFile(route.file);
|
|
163
|
+
return renderRouteManifest(routes, routesDir);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
package/lib/types/index.d.ts
CHANGED
package/lib/types/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { App } from './app.js';
|
|
2
|
+
import type { SourceDialect } from './source-dialect.js';
|
|
2
3
|
/** CSS handling forwarded to Rolldown / Lightning CSS. */
|
|
3
4
|
export interface GjsifyCssOptions {
|
|
4
5
|
/** Browserslist-compatible target list. Defaults to `['firefox60']` for `--app gjs`. */
|
|
@@ -67,6 +68,25 @@ export interface PluginOptions {
|
|
|
67
68
|
* the wrapper. Defaults to `false`.
|
|
68
69
|
*/
|
|
69
70
|
preserveDefaultExport?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The dialect the SOURCE is written in (ADR 0032 § 2 + § 8), unset by default.
|
|
73
|
+
* `'react-native'` composes two plugins on the `gjs` and `node` targets:
|
|
74
|
+
*
|
|
75
|
+
* 1. the alias line `'react-native'` → `'@gjsify/react-native'`, so an
|
|
76
|
+
* unmodified React Native application builds without a rename per file;
|
|
77
|
+
* 2. the build-time support gate, which fails on an import of a name whose
|
|
78
|
+
* support-table status is not `supported` or `partial`.
|
|
79
|
+
*
|
|
80
|
+
* OPT-IN and never inferred. A tree with a phone leg has the real
|
|
81
|
+
* `react-native` installed on purpose, and redirecting the specifier for
|
|
82
|
+
* every build in it would change what that leg resolves; `@gjsify/react-native`
|
|
83
|
+
* is also tier 3 and optional, so an unconditional alias would fail builds
|
|
84
|
+
* that never asked for it. Full reasoning: `plugins/react-native-alias.ts`.
|
|
85
|
+
*
|
|
86
|
+
* This is the SOURCE dialect, never the target runtime — the two answer
|
|
87
|
+
* opposite questions and once shared a name. See `SourceDialect`.
|
|
88
|
+
*/
|
|
89
|
+
dialect?: SourceDialect;
|
|
70
90
|
/**
|
|
71
91
|
* Where to look for a `@gjsify/*` the PROJECT cannot resolve, when the input is
|
|
72
92
|
* TOOLCHAIN rather than user code: a file path inside the running CLI's own
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { App } from './app.js';
|
|
2
|
+
/**
|
|
3
|
+
* The dialect the SOURCE is written in — not the runtime it is built for.
|
|
4
|
+
*
|
|
5
|
+
* Two vocabularies in this toolchain used to answer the same-sounding question
|
|
6
|
+
* with the same word, and they mean opposite directions:
|
|
7
|
+
*
|
|
8
|
+
* `gjsify.runtimes['react-native']` — this PACKAGE runs on React Native (Hermes)
|
|
9
|
+
* `gjsify build --dialect react-native` — this SOURCE is a React Native app, being
|
|
10
|
+
* built for GTK
|
|
11
|
+
*
|
|
12
|
+
* A package can truthfully be both: shipped to a phone through Metro AND ported to
|
|
13
|
+
* the desktop through this build. While the flag was also spelled `react-native`,
|
|
14
|
+
* those two true statements read as a contradiction, and a reader could reasonably
|
|
15
|
+
* expect a manifest declaring `react-native: 'none'` to fail a `--react-native`
|
|
16
|
+
* build. It never should — they are unrelated facts.
|
|
17
|
+
*
|
|
18
|
+
* So the closed runtime vocabulary keeps the name (it is the older, machine-checked
|
|
19
|
+
* one, parallel to `nativescript`), and the dialect is spelled as what it is. The
|
|
20
|
+
* type is a union rather than a boolean because NativeScript is the named follow-on:
|
|
21
|
+
* `--dialect nativescript` fits here without a second flag.
|
|
22
|
+
*/
|
|
23
|
+
export type SourceDialect = 'react-native';
|
|
24
|
+
/** Every dialect this build understands, for CLI choices and error messages. */
|
|
25
|
+
export declare const SOURCE_DIALECTS: readonly SourceDialect[];
|
|
26
|
+
/** Whether `value` names a dialect this build understands. */
|
|
27
|
+
export declare function isSourceDialect(value: unknown): value is SourceDialect;
|
|
28
|
+
/**
|
|
29
|
+
* The `--app` targets that actually compose a dialect's plugins.
|
|
30
|
+
*
|
|
31
|
+
* Declared here rather than inferred, because it cannot be inferred: the
|
|
32
|
+
* composition happens in `app/gjs.ts` and `app/node.ts`, and a plugin only ever
|
|
33
|
+
* sees the target it was composed on — so nothing downstream can tell "this
|
|
34
|
+
* target has no dialect support" from "this dialect asked for nothing". The CLI
|
|
35
|
+
* refuses the pair up front instead, and this is the set it refuses against.
|
|
36
|
+
*
|
|
37
|
+
* KEEP IN STEP with the two composition sites. A third target that starts
|
|
38
|
+
* composing and is not added here gets a build refused for no reason; one added
|
|
39
|
+
* here without composing gets a flag accepted and silently ignored, which is the
|
|
40
|
+
* failure this set exists to prevent.
|
|
41
|
+
*/
|
|
42
|
+
export declare const DIALECT_APPS: ReadonlySet<App>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Every dialect this build understands, for CLI choices and error messages. */
|
|
2
|
+
export const SOURCE_DIALECTS = ['react-native'];
|
|
3
|
+
/** Whether `value` names a dialect this build understands. */
|
|
4
|
+
export function isSourceDialect(value) {
|
|
5
|
+
return typeof value === 'string' && SOURCE_DIALECTS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The `--app` targets that actually compose a dialect's plugins.
|
|
9
|
+
*
|
|
10
|
+
* Declared here rather than inferred, because it cannot be inferred: the
|
|
11
|
+
* composition happens in `app/gjs.ts` and `app/node.ts`, and a plugin only ever
|
|
12
|
+
* sees the target it was composed on — so nothing downstream can tell "this
|
|
13
|
+
* target has no dialect support" from "this dialect asked for nothing". The CLI
|
|
14
|
+
* refuses the pair up front instead, and this is the set it refuses against.
|
|
15
|
+
*
|
|
16
|
+
* KEEP IN STEP with the two composition sites. A third target that starts
|
|
17
|
+
* composing and is not added here gets a build refused for no reason; one added
|
|
18
|
+
* here without composing gets a flag accepted and silently ignored, which is the
|
|
19
|
+
* failure this set exists to prevent.
|
|
20
|
+
*/
|
|
21
|
+
export const DIALECT_APPS = new Set(['gjs', 'node']);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** One named import (or re-export) of `name` from a watched package. */
|
|
2
|
+
export interface NamedImport {
|
|
3
|
+
/** The name as the SOURCE module exports it, not the local alias. */
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** The verbatim specifier it came from. */
|
|
6
|
+
readonly specifier: string;
|
|
7
|
+
/** 1-based line, as acorn reports it. */
|
|
8
|
+
readonly line: number;
|
|
9
|
+
/** 0-based column, as acorn reports it. */
|
|
10
|
+
readonly column: number;
|
|
11
|
+
}
|
|
12
|
+
/** A reference to a watched package whose imported NAMES are not statically knowable. */
|
|
13
|
+
export interface OpaqueReference {
|
|
14
|
+
/** Which form hid the names. */
|
|
15
|
+
readonly form: 'namespace' | 'default' | 'export-all' | 'dynamic-import' | 'require';
|
|
16
|
+
readonly specifier: string;
|
|
17
|
+
readonly line: number;
|
|
18
|
+
readonly column: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ScanResult {
|
|
21
|
+
readonly named: readonly NamedImport[];
|
|
22
|
+
readonly opaque: readonly OpaqueReference[];
|
|
23
|
+
}
|
|
24
|
+
/** Raised when a module that mentions a watched package cannot be parsed. */
|
|
25
|
+
export declare class ImportScanParseError extends Error {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly name = "ImportScanParseError";
|
|
28
|
+
constructor(id: string, cause: unknown);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every named import and every opaque reference to one of `specifiers`.
|
|
32
|
+
*
|
|
33
|
+
* `specifiers` is matched EXACTLY. `react-native-web` and
|
|
34
|
+
* `react-native/Libraries/…` are different modules with different contracts, and
|
|
35
|
+
* a prefix match would fail a build over a package this layer never claimed —
|
|
36
|
+
* the deep-subpath case has its own named error in the alias plugin, where the
|
|
37
|
+
* importer is known and the message can say what to do.
|
|
38
|
+
*
|
|
39
|
+
* Type-only imports are absent from the result by construction. ADR 0032 counted
|
|
40
|
+
* five of them (`ViewProps`, `TextProps`, `PressableProps`, `LayoutChangeEvent`,
|
|
41
|
+
* `ColorValue`) in the measured application and recorded that they cost nothing:
|
|
42
|
+
* a type erases at build time and reaches no runtime, so a gate that failed on
|
|
43
|
+
* `import type { ViewProps }` would refuse a program that cannot fail.
|
|
44
|
+
*
|
|
45
|
+
* @throws {ImportScanParseError} when the source cannot be parsed.
|
|
46
|
+
*/
|
|
47
|
+
export declare function scanNamedImports(code: string, id: string, specifiers: readonly string[]): ScanResult;
|