@gjsify/rolldown-plugin-gjsify 0.43.0 → 0.45.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.
@@ -0,0 +1,240 @@
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.
19
+ //
20
+ // WHAT THAT IMPORT DRAGS IN, measured on the built file rather than assumed: two
21
+ // RELATIVE imports, rolldown's two-helper runtime shim and the generated
22
+ // `own-exports.js`, which is a string array importing nothing. Relative is the half
23
+ // that matters twice over — nothing reachable from here can pull GTK into the
24
+ // bundler process, and GJS's ESM loader follows a relative specifier where it does
25
+ // not follow `package.json#exports`. This comment used to claim "no imports at all",
26
+ // which the runtime shim already disproved before the second one arrived.
27
+ //
28
+ // AND IT IS NOT A DEPENDENCY, deliberately. `@gjsify/rolldown-plugin-gjsify` is
29
+ // tier 1 and `@gjsify/react-native` is tier 3; a tier-1 package may not depend on
30
+ // a higher tier (`scripts/audit-runtimes.mjs`). Resolving the table from the
31
+ // PROJECT is also the more correct answer than bundling a snapshot would be: the
32
+ // gate then states exactly what the build's own copy of the layer supports, so a
33
+ // version skew between plugin and layer cannot make the gate lie in either
34
+ // direction.
35
+ import { pathToFileURL } from 'node:url';
36
+ import { REACT_NATIVE_ALIAS_TARGET, REACT_NATIVE_SPECIFIER } from './react-native-alias.js';
37
+ import { REWRITE_FILTER } from './rewrite-node-modules-paths.js';
38
+ import { ImportScanParseError, scanNamedImports, } from '../utils/scan-named-imports.js';
39
+ /** The subpath that carries the table in an installed `@gjsify/react-native`. */
40
+ export const SUPPORT_TABLE_SUBPATH = `${REACT_NATIVE_ALIAS_TARGET}/support-table`;
41
+ /**
42
+ * Both spellings of the layer are watched.
43
+ *
44
+ * A ported application writes `react-native` (the alias rewrites it, and the
45
+ * rewrite happens in `resolveId` — the SOURCE text still says `react-native`
46
+ * when this hook reads it). A gjsify-native application, this repository's own
47
+ * showcase included, writes `@gjsify/react-native`. The gate is about the
48
+ * SURFACE, not about which name reached it.
49
+ */
50
+ export const WATCHED_SPECIFIERS = [REACT_NATIVE_SPECIFIER, REACT_NATIVE_ALIAS_TARGET];
51
+ /** Thrown for every refused import in one module, in one message. */
52
+ export class ReactNativeUnsupportedImportError extends Error {
53
+ id;
54
+ violations;
55
+ name = 'ReactNativeUnsupportedImportError';
56
+ constructor(id, violations) {
57
+ super(formatSupportViolations(id, violations));
58
+ this.id = id;
59
+ this.violations = violations;
60
+ }
61
+ }
62
+ /** Thrown when the gate is on but the table it must read is not reachable. */
63
+ export class SupportTableUnreadableError extends Error {
64
+ name = 'SupportTableUnreadableError';
65
+ constructor(detail, cause) {
66
+ super(`gjsify react-native gate: cannot read ${SUPPORT_TABLE_SUBPATH} — ${detail}. The gate has no ` +
67
+ `second source to fall back on, and ADR 0032 § 8 is explicit that a hand-maintained table ` +
68
+ `beside it would be the second truth this repository has already collected several times. ` +
69
+ `Install ${REACT_NATIVE_ALIAS_TARGET} (and build it, if it is a workspace link), or drop ` +
70
+ `the react-native opt-in.`, cause === undefined ? undefined : { cause });
71
+ }
72
+ }
73
+ /**
74
+ * All violations for one module as one message.
75
+ *
76
+ * One message and not one per name: an application that imports `FlatList`,
77
+ * `Animated` and `Image` from the same file has one porting decision to make,
78
+ * and three separate build failures would show it one third of that decision per
79
+ * run.
80
+ */
81
+ export function formatSupportViolations(id, violations) {
82
+ const lines = violations.map((v) => ` ${id}:${v.line}:${v.column} ${v.name}\n ${v.reason}`);
83
+ return (`gjsify react-native gate: ${violations.length} import(s) this layer does not answer for:\n` +
84
+ `${lines.join('\n')}\n` +
85
+ `Each sentence above is the support table's own (ADR 0032 § 8), so it is the same reason the ` +
86
+ `runtime would give. A "not implemented yet" entry is where to contribute; a refusal names what ` +
87
+ `to write instead.`);
88
+ }
89
+ /** The refused subset of a scan, in the table's words. */
90
+ export function findSupportViolations(named, table) {
91
+ const out = [];
92
+ for (const entry of named) {
93
+ if (table.isImportable(entry.name))
94
+ continue;
95
+ out.push({
96
+ name: entry.name,
97
+ specifier: entry.specifier,
98
+ line: entry.line,
99
+ column: entry.column,
100
+ // The table's sentence, never a rephrasing of it: `explainUnsupported`
101
+ // exists so the build error and the runtime throw cannot describe the
102
+ // same gap differently.
103
+ reason: table.explainUnsupported(entry.name),
104
+ });
105
+ }
106
+ return out;
107
+ }
108
+ /** The warning for a reference whose names the scan cannot see. */
109
+ export function formatOpaqueReference(id, ref) {
110
+ const forms = {
111
+ namespace: 'a namespace import (`import * as …`)',
112
+ default: 'a default import',
113
+ 'export-all': 'a star re-export (`export * from …`)',
114
+ 'dynamic-import': 'a dynamic `import()`',
115
+ require: 'a `require()`',
116
+ };
117
+ return (`gjsify react-native gate: ${id}:${ref.line}:${ref.column} reaches "${ref.specifier}" through ` +
118
+ `${forms[ref.form]}, so the NAMES it uses are not knowable at build time and this gate cannot ` +
119
+ `check them. The runtime refusal still covers them — it reads the same table (ADR 0032 § 8) — but ` +
120
+ `it fires when the code path runs, not now. Import the names you use to move the answer back to ` +
121
+ `build time.`);
122
+ }
123
+ /**
124
+ * The warning for a module the parser could not read.
125
+ *
126
+ * A WARNING and not a build error, which is the one place this gate deliberately
127
+ * does not fail. `acorn-typescript` 1.4.13 cannot parse a `satisfies` expression
128
+ * or a `const` type parameter (measured; see `scan-named-imports.ts`), both of
129
+ * which are ordinary TypeScript in a tree pinned to `typescript: "^6.0.3"`.
130
+ * Erroring there would refuse a program that is correct, and this repository has
131
+ * already written down what that costs: "a false violation is how a checker
132
+ * teaches people to ignore it" (`check-rn-surface.mjs`). The shape used instead
133
+ * is the one that file also uses — degrade to the weaker half and SAY SO, out
134
+ * loud, per file, rather than degrade quietly.
135
+ */
136
+ export function formatUnreadableModule(error) {
137
+ return (`${error.message} The build continues: the runtime refusal reads the same table (ADR 0032 § 8) and ` +
138
+ `still covers this module, but it fires when the code path runs rather than now.`);
139
+ }
140
+ /**
141
+ * Resolve and import the support table.
142
+ *
143
+ * `importer` is a module from the project being built, so the resolve lands in
144
+ * the PROJECT's dependency tree rather than the CLI's — the gate must describe
145
+ * the layer this bundle will actually contain.
146
+ */
147
+ export async function loadSupportTable(resolver, importer, load = (href) => import(/* @vite-ignore */ href)) {
148
+ let resolved;
149
+ try {
150
+ resolved = await resolver.resolve(SUPPORT_TABLE_SUBPATH, importer);
151
+ }
152
+ catch (cause) {
153
+ throw new SupportTableUnreadableError(`the resolver threw for ${importer}`, cause);
154
+ }
155
+ if (!resolved)
156
+ throw new SupportTableUnreadableError(`it does not resolve from ${importer}`);
157
+ if (resolved.external) {
158
+ throw new SupportTableUnreadableError(`it resolved as EXTERNAL from ${importer}, so there is no file to read`);
159
+ }
160
+ let mod;
161
+ try {
162
+ // A file path, not the bare specifier: GJS's ESM loader does not follow
163
+ // `package.json#exports`, so the bare form works on Node and fails under
164
+ // the GJS engine — the same limitation the CLI's by-name plugin loader
165
+ // documents. The resolver already did the exports-map hop.
166
+ mod = await load(pathToFileURL(resolved.id).href);
167
+ }
168
+ catch (cause) {
169
+ throw new SupportTableUnreadableError(`importing ${resolved.id} failed`, cause);
170
+ }
171
+ const candidate = mod;
172
+ if (typeof candidate.isImportable !== 'function' || typeof candidate.explainUnsupported !== 'function') {
173
+ throw new SupportTableUnreadableError(`${resolved.id} does not export isImportable + explainUnsupported. ` +
174
+ `That is a version skew between the bundler plugin and the layer, not a missing install`);
175
+ }
176
+ return { isImportable: candidate.isImportable, explainUnsupported: candidate.explainUnsupported };
177
+ }
178
+ /**
179
+ * Fail the build on an import of a React Native name whose status is not
180
+ * `supported` or `partial`.
181
+ *
182
+ * A `transform` hook at `order: 'pre'`, which is what makes `import type` visible
183
+ * — by the time a normal-order hook runs, the annotations may already have been
184
+ * stripped, and the gate would then be unable to tell the five type-only imports
185
+ * ADR 0032 measured from five value imports. Same reason `deepkitPlugin` is
186
+ * composed as a prePlugin.
187
+ */
188
+ export function reactNativeSupportGatePlugin(options = {}) {
189
+ let table = options.table;
190
+ return {
191
+ name: 'gjsify-react-native-gate',
192
+ transform: {
193
+ order: 'pre',
194
+ filter: { id: REWRITE_FILTER },
195
+ async handler(code, id) {
196
+ // Re-applied inside the handler, the way
197
+ // `nodeModulesPathRewritePlugin` does: the object-form `filter` is
198
+ // engine plumbing (under GJS `toNativePlugin` lifts `filter.id`
199
+ // into `@gjsify/rolldown-native`'s plugin-level `idFilter`), and a
200
+ // gate must not depend on plumbing to stay off `.css` / `.blp` /
201
+ // a data URL, all of which acorn would reject as invalid JS.
202
+ if (!REWRITE_FILTER.test(id))
203
+ return null;
204
+ // Text prefilter before the parse, the same cheap gate
205
+ // `shouldInline` uses: both watched specifiers contain
206
+ // `react-native`, so one `includes` clears every module that has
207
+ // nothing to do with this layer.
208
+ if (!code.includes(REACT_NATIVE_SPECIFIER))
209
+ return null;
210
+ // The ONE catch in this file, with a real throw path and a
211
+ // stated reason: `scanNamedImports` throws on syntax the pinned
212
+ // acorn-typescript cannot read, and the policy for that lives
213
+ // here rather than in the pure function — see
214
+ // {@link formatUnreadableModule}.
215
+ let scanned;
216
+ try {
217
+ scanned = scanNamedImports(code, id, WATCHED_SPECIFIERS);
218
+ }
219
+ catch (error) {
220
+ if (!(error instanceof ImportScanParseError))
221
+ throw error;
222
+ this.warn(formatUnreadableModule(error));
223
+ return null;
224
+ }
225
+ const { named, opaque } = scanned;
226
+ if (named.length === 0 && opaque.length === 0)
227
+ return null;
228
+ table ??= await loadSupportTable(this, id);
229
+ const violations = findSupportViolations(named, table);
230
+ if (violations.length > 0)
231
+ throw new ReactNativeUnsupportedImportError(id, violations);
232
+ for (const ref of opaque)
233
+ this.warn(formatOpaqueReference(id, ref));
234
+ // A gate, not a transform: the source is returned unchanged and
235
+ // the module goes on through the chain.
236
+ return null;
237
+ },
238
+ },
239
+ };
240
+ }
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ export * from './source-dialect.js';
1
2
  export * from './app.js';
2
3
  export * from './resolve-alias-options.js';
3
4
  export * from './plugin-options.js';
@@ -1,3 +1,4 @@
1
+ export * from './source-dialect.js';
1
2
  export * from './app.js';
2
3
  export * from './resolve-alias-options.js';
3
4
  export * from './plugin-options.js';
@@ -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;