@motujs/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
// EJECT — materialise the wiring the archipelago was holding, so removing motu is a no-op for the
|
|
2
|
+
// APP rather than for the framework's convenience (docs/plan-key-ownership.md, D1).
|
|
3
|
+
//
|
|
4
|
+
// Removal used to be "delete motu's files, unwrap motu's tags". That works only while the host also
|
|
5
|
+
// keeps its own copy of every coupling — which is exactly the duplication ownership exists to remove:
|
|
6
|
+
// once a key is an island's to produce, the page reads it (`useRegionValue`) and seeds it
|
|
7
|
+
// (`seedArchipelago`) instead of deriving it, and unwrapping alone would leave those calls dangling.
|
|
8
|
+
//
|
|
9
|
+
// So before the tags come off, this generates the plain-React equivalent:
|
|
10
|
+
//
|
|
11
|
+
// const overallProgress = useRegionValue('overallProgress') ?? 0 // read
|
|
12
|
+
// -> const [overallProgress, setOverallProgress] = useState(0)
|
|
13
|
+
// seedArchipelago(arch, 'weekMissions', m) // seed
|
|
14
|
+
// -> setWeekMissions(m)
|
|
15
|
+
// <Island slot="week-actions"><WeekActionsView … /> // producer
|
|
16
|
+
// -> <WeekActionsView … onProgress={(d) => { setOverallProgress(d.overallProgress); … }} />
|
|
17
|
+
//
|
|
18
|
+
// It is a CODEMOD, not a refactor: it may leave state the page also keeps under another name. That is
|
|
19
|
+
// the honest trade — the ejected app compiles and behaves the same, and a human can tidy after.
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { islandsFromSource, rootSlots as parsedRootSlots, regionRefsDeclared } from './lagoon-declares.mjs';
|
|
22
|
+
import { blankComments as blankCommentsShared } from './util.mjs';
|
|
23
|
+
import { dirname, resolve } from 'node:path';
|
|
24
|
+
import { SyntaxKind } from 'ts-morph';
|
|
25
|
+
import { readGeneratedContracts } from './contracts.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read every archipelago's wiring from its config file: slot -> { element, writes }.
|
|
29
|
+
*
|
|
30
|
+
* Parsed from source rather than imported: the config is TypeScript with app imports, and eject runs
|
|
31
|
+
* in node with no bundler. Only literal shapes are read — anything computed is reported, not guessed.
|
|
32
|
+
*/
|
|
33
|
+
export function readRegions(archipelagosDir) {
|
|
34
|
+
const regions = [];
|
|
35
|
+
if (!existsSync(archipelagosDir)) return regions;
|
|
36
|
+
for (const entry of readdirSync(archipelagosDir, { withFileTypes: true })) {
|
|
37
|
+
if (!entry.isDirectory()) continue;
|
|
38
|
+
const file = resolve(archipelagosDir, entry.name, `${entry.name}.archipelago.ts`);
|
|
39
|
+
if (!existsSync(file)) continue;
|
|
40
|
+
const text = readFileSync(file, 'utf8');
|
|
41
|
+
regions.push({
|
|
42
|
+
id: entry.name,
|
|
43
|
+
file,
|
|
44
|
+
/** `catalogue` when the island list is data — see ArchipelagoConfig.membership. */
|
|
45
|
+
membership: text.match(/\bmembership:\s*'(placed|catalogue)'/)?.[1] ?? 'placed',
|
|
46
|
+
islands: readIslands(text),
|
|
47
|
+
sources: readSources(text, file),
|
|
48
|
+
// The region's ROOT and how its props map — what an ejected page needs to keep composing the
|
|
49
|
+
// same arrangement once `<X.Root>` is gone.
|
|
50
|
+
...readRoot(text, file),
|
|
51
|
+
// The APP's own region type, and where it comes from. Generated state is typed with it —
|
|
52
|
+
// `useState<ActionsRegion['weekMissions']>()` — so an ejected page keeps the exact types it had,
|
|
53
|
+
// from a declaration that is the app's and survives motu's removal.
|
|
54
|
+
...regionTypeOf(text),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return regions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* WHICH REGION a `<X.Root …>` composes, from the props it passes.
|
|
62
|
+
*
|
|
63
|
+
* The binding is named by the page (`Directory`, `Actions`, anything), so the tag cannot say it. What
|
|
64
|
+
* can is the prop set: a root's props are that region's own vocabulary, and a page passing four of
|
|
65
|
+
* the annuaire's is composing the annuaire.
|
|
66
|
+
*
|
|
67
|
+
* SCORED, not "the first region with a prop in common" — which is what this was, and it broke the
|
|
68
|
+
* moment a second region declared a root: both acme's regions have a `header`, so the annuaire's page
|
|
69
|
+
* matched the actions region, was typed with `ActionsRegion`, and had its host-slot prop rewritten
|
|
70
|
+
* with the wrong mapping. Two type errors in a page that had not changed.
|
|
71
|
+
*/
|
|
72
|
+
export function regionOfRoot(el, regions) {
|
|
73
|
+
const passed = new Set(
|
|
74
|
+
(el.getAttributes?.() ?? []).map((a) => a.getNameNode?.().getText?.()).filter(Boolean),
|
|
75
|
+
);
|
|
76
|
+
let best = null;
|
|
77
|
+
let bestScore = 0;
|
|
78
|
+
for (const region of regions) {
|
|
79
|
+
if (!region.root) continue;
|
|
80
|
+
const names = [
|
|
81
|
+
...Object.keys(region.rootSlots ?? {}),
|
|
82
|
+
...Object.keys(region.rootHostSlots ?? {}),
|
|
83
|
+
...Object.keys(region.rootRegions ?? {}),
|
|
84
|
+
];
|
|
85
|
+
const score = names.filter((n) => passed.has(n)).length;
|
|
86
|
+
if (score > bestScore) {
|
|
87
|
+
best = region;
|
|
88
|
+
bestScore = score;
|
|
89
|
+
} else if (score === bestScore && score > 0) {
|
|
90
|
+
best = null; // ambiguous: two regions match equally well, so say nothing rather than guess
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What actually STANDS IN a root slot, from the expression the page passed for it.
|
|
98
|
+
*
|
|
99
|
+
* Only the branch results — the element itself, or each side of a conditional / `&&` guard. NOT every
|
|
100
|
+
* JSX descendant: `weekNav={<WeekNavigator ambassador={<X.Island>…</X.Island>} …/>}` contains four
|
|
101
|
+
* more elements, and wiring them all put the week navigator's `onChange` on the ambassador strip, the
|
|
102
|
+
* summary button and everything inside them. The generated page still compiled, which is the whole
|
|
103
|
+
* reason this needed looking at rather than trusting.
|
|
104
|
+
*/
|
|
105
|
+
function slotElements(expr) {
|
|
106
|
+
if (!expr) return [];
|
|
107
|
+
switch (expr.getKind()) {
|
|
108
|
+
case SyntaxKind.JsxSelfClosingElement:
|
|
109
|
+
return [expr];
|
|
110
|
+
case SyntaxKind.JsxElement:
|
|
111
|
+
return [expr.getOpeningElement()];
|
|
112
|
+
case SyntaxKind.ParenthesizedExpression:
|
|
113
|
+
return slotElements(expr.getExpression());
|
|
114
|
+
case SyntaxKind.ConditionalExpression:
|
|
115
|
+
return [...slotElements(expr.getWhenTrue()), ...slotElements(expr.getWhenFalse())];
|
|
116
|
+
case SyntaxKind.BinaryExpression:
|
|
117
|
+
// `cond && <C/>` — the element is the right-hand side; the left is the test.
|
|
118
|
+
return expr.getOperatorToken().getText() === '&&' ? slotElements(expr.getRight()) : [];
|
|
119
|
+
case SyntaxKind.JsxFragment:
|
|
120
|
+
return expr
|
|
121
|
+
.getJsxChildren()
|
|
122
|
+
.flatMap((c) => (c.getKind() === SyntaxKind.JsxElement ? [c.getOpeningElement()] : c.getKind() === SyntaxKind.JsxSelfClosingElement ? [c] : []));
|
|
123
|
+
default:
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The region's `root`, its `slots` and its `hostSlots`, with the module each component comes from.
|
|
130
|
+
*
|
|
131
|
+
* `removal-check` needs all of it: `<Directory.Root search={…} header={{ member }} />` has to become
|
|
132
|
+
* `<DirectoryLayout search={…} header={<DirectoryHeader member={member} />} />`, with both imports
|
|
133
|
+
* added — and every prop mapped in `slots` has to keep the wiring the region was holding for it.
|
|
134
|
+
*
|
|
135
|
+
* Read from TEXT, like the rest of this module. A `root` is a component reference, so importing the
|
|
136
|
+
* archipelago to read it would pull the application's UI into the CLI.
|
|
137
|
+
*/
|
|
138
|
+
function readRoot(text, file) {
|
|
139
|
+
const code = blankComments(text);
|
|
140
|
+
const root = code.match(/^\s*root\s*:\s*([A-Za-z_$][\w$]*)\s*,/m)?.[1] ?? null;
|
|
141
|
+
if (!root) return { root: null, rootFrom: null, rootSlots: {}, rootHostSlots: {}, rootRegions: {} };
|
|
142
|
+
const importOf = (name) =>
|
|
143
|
+
[...code.matchAll(/import\s*\{([^}]*)\}\s*from\s*'([^']+)'/g)].find(([, names]) =>
|
|
144
|
+
new RegExp(`(^|[\\s,])${name}([\\s,]|$)`).test(names),
|
|
145
|
+
)?.[2] ?? null;
|
|
146
|
+
const rootSlots = {};
|
|
147
|
+
// One-line and multi-line forms both — see `rootSlotMap` for what matching only one of them cost.
|
|
148
|
+
const slotBlock =
|
|
149
|
+
code.match(/^ {2}slots\s*:\s*\{([^}\n]*)\}/m) ?? code.match(/^ {2}slots\s*:\s*\{([\s\S]*?)^ {2}\},/m);
|
|
150
|
+
// Both value forms — see `rootSlotMap` for the object one.
|
|
151
|
+
// A prop another REGION fills (`{ region: 'x' }`) is not an island slot: nothing of this region's
|
|
152
|
+
// stands in it. It is still this root's vocabulary, so `regionOfRoot` counts it.
|
|
153
|
+
const rootRegions = {};
|
|
154
|
+
for (const [, prop, rest] of (slotBlock?.[1] ?? '').matchAll(/(\w+)\s*:\s*(\{[^}]*\}|'[^']+')/g)) {
|
|
155
|
+
const region = rest.startsWith('{') ? rest.match(/\bregion\s*:\s*'([^']+)'/)?.[1] : null;
|
|
156
|
+
if (region) {
|
|
157
|
+
rootRegions[prop] = region;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const slot = rest.startsWith('{') ? rest.match(/\bslot\s*:\s*'([^']+)'/)?.[1] : rest.slice(1, -1);
|
|
161
|
+
if (slot) rootSlots[prop] = slot;
|
|
162
|
+
}
|
|
163
|
+
const rootHostSlots = {};
|
|
164
|
+
const hostBlock = code.match(/^\s*hostSlots\s*:\s*\{([^}]*)\}/m);
|
|
165
|
+
for (const [, prop, comp] of (hostBlock?.[1] ?? '').matchAll(/(\w+)\s*:\s*([A-Za-z_$][\w$]*)/g)) {
|
|
166
|
+
rootHostSlots[prop] = { component: comp, from: importOf(comp) };
|
|
167
|
+
}
|
|
168
|
+
// PARSED where the file parses, the patterns above only as the fallback. The one-line pattern stops
|
|
169
|
+
// at the first `}`, so `slots: { main: 'm', panel: { region: 'x' } }` read the reference's own
|
|
170
|
+
// `region` key as a ROOT PROP named `region` — the same misreading `rootSlots` was written to end.
|
|
171
|
+
const parsedSlots = file ? parsedRootSlots(file) : null;
|
|
172
|
+
const parsedRefs = file ? regionRefsDeclared(file) : null;
|
|
173
|
+
if (parsedSlots && parsedRefs) {
|
|
174
|
+
return { root, rootFrom: importOf(root), rootSlots: parsedSlots, rootHostSlots, rootRegions: parsedRefs };
|
|
175
|
+
}
|
|
176
|
+
return { root, rootFrom: importOf(root), rootSlots, rootHostSlots, rootRegions };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The app-side region type a config is declared against, and the module it comes from.
|
|
181
|
+
*
|
|
182
|
+
* Either declaration form carries it: `satisfies ArchipelagoConfig<ActionsRegion, …>` or
|
|
183
|
+
* `archipelago<ActionsRegion, …>()`. The argument list can carry more than the region, so stop at the
|
|
184
|
+
* first `,` or `>`, and find the import by the NAME just captured rather than by anything hard-coded.
|
|
185
|
+
*/
|
|
186
|
+
function regionTypeOf(text) {
|
|
187
|
+
const regionType = text.match(/(?:ArchipelagoConfig|\barchipelago)<\s*([A-Za-z_$][\w$]*)\s*[,>]/)?.[1] ?? null;
|
|
188
|
+
if (!regionType) return { regionType: null, regionTypeFrom: null };
|
|
189
|
+
const imported = [...text.matchAll(/import type \{([^}]*)\}\s*from\s*'([^']+)'/g)].find(([, names]) =>
|
|
190
|
+
new RegExp(`\\b${regionType}\\b`).test(names),
|
|
191
|
+
);
|
|
192
|
+
return { regionType, regionTypeFrom: imported?.[2] ?? null };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* id -> { produces: [...] } for each DECLARED source, from the config's text.
|
|
197
|
+
*
|
|
198
|
+
* Two forms, and the difference matters to how far this has to look:
|
|
199
|
+
* sources: { favorites: { module: '…', produces: ['favoriteIds'] } } produces is right here
|
|
200
|
+
* sources: { results: resultsSource } produces is in ANOTHER file
|
|
201
|
+
* The second is the form the rules prefer — the region points at what produces its keys, not at a
|
|
202
|
+
* string — so the import is followed and the `produces` array read from the source's own module.
|
|
203
|
+
* Returning `{}` when a region declares none is correct; returning it when a region declares some and
|
|
204
|
+
* the import could not be followed is a check that looked at nothing, so that case says so instead.
|
|
205
|
+
*/
|
|
206
|
+
function readSources(text, file) {
|
|
207
|
+
const code = blankComments(text);
|
|
208
|
+
const block = blockAfter(code, 'sources:', '{', 0);
|
|
209
|
+
if (!block) return {};
|
|
210
|
+
const out = {};
|
|
211
|
+
for (const entry of splitTopLevel(block.body)) {
|
|
212
|
+
const m = entry.match(/^\s*(\w+)\s*:\s*([\s\S]+)$/);
|
|
213
|
+
if (!m) continue;
|
|
214
|
+
const [, id, value] = m;
|
|
215
|
+
const inline = value.match(/\bproduces:\s*\[([^\]]*)\]/);
|
|
216
|
+
if (inline) {
|
|
217
|
+
const names = quotedNames(inline[1]);
|
|
218
|
+
out[id] = names ? { produces: names } : { produces: null, unresolved: `${id}.produces` };
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const ident = value.trim().match(/^([A-Za-z_$][\w$]*)\s*,?$/)?.[1];
|
|
222
|
+
if (!ident) continue;
|
|
223
|
+
const spec = [...code.matchAll(/import\s*\{([^}]*)\}\s*from\s*'([^']+)'/g)].find(([, names]) =>
|
|
224
|
+
new RegExp(`\\b${ident}\\b`).test(names),
|
|
225
|
+
)?.[2];
|
|
226
|
+
const produces = spec ? producesOf(resolveSourceFile(spec, file), ident) : null;
|
|
227
|
+
// UNRESOLVED IS NOT EMPTY. A source whose module could not be read has unknown keys, and saying
|
|
228
|
+
// `[]` would let every check downstream conclude that nothing is source-owned.
|
|
229
|
+
out[id] = produces ? { produces } : { produces: null, unresolved: spec ?? ident };
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where a specifier written in an archipelago actually lives.
|
|
236
|
+
*
|
|
237
|
+
* Relative is exact. `@/` is the app's own tsconfig alias and motu does not read tsconfig here — it
|
|
238
|
+
* points at the repo root in one project and at `src/` in another — so rather than guess, walk up
|
|
239
|
+
* from the archipelago and take the first ancestor where the rest of the path is a real file. Wrong
|
|
240
|
+
* only if two ancestors both contain the same path, which would make the app's own imports ambiguous.
|
|
241
|
+
*/
|
|
242
|
+
function resolveSourceFile(spec, fromFile) {
|
|
243
|
+
const isFile = (p) => {
|
|
244
|
+
try {
|
|
245
|
+
return existsSync(p) && statSync(p).isFile();
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
// `.js` NAMING A `.ts` FILE is how NodeNext/ESM TypeScript writes every relative import — the demo
|
|
251
|
+
// app's archipelagos import `../../shared/events-source.js`, and without this the source behind it
|
|
252
|
+
// was "unresolved", so nothing was checked against what it produces.
|
|
253
|
+
const swapped = (base) => (/\.(m|c)?jsx?$/.test(base) ? [base.replace(/\.(m|c)?js(x?)$/, '.ts$2'), base.replace(/\.(m|c)?jsx?$/, '.tsx')] : []);
|
|
254
|
+
const withExt = (base) => [base, ...swapped(base), `${base}.ts`, `${base}.tsx`, resolve(base, 'index.ts')].find(isFile) ?? null;
|
|
255
|
+
|
|
256
|
+
if (spec.startsWith('.')) return withExt(resolve(dirname(fromFile), spec));
|
|
257
|
+
if (!spec.startsWith('@/')) return null;
|
|
258
|
+
const rest = spec.slice(2);
|
|
259
|
+
for (let dir = dirname(fromFile); ; dir = dirname(dir)) {
|
|
260
|
+
const hit = withExt(resolve(dir, rest));
|
|
261
|
+
if (hit) return hit;
|
|
262
|
+
if (dir === dirname(dir)) return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The `produces: [...]` of a named export in a source module. */
|
|
267
|
+
function producesOf(file, ident) {
|
|
268
|
+
if (!file) return null;
|
|
269
|
+
let text;
|
|
270
|
+
try {
|
|
271
|
+
text = readFileSync(file, 'utf8');
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const code = blankComments(text);
|
|
276
|
+
const at = code.search(new RegExp(`export\\s+const\\s+${ident}\\b`));
|
|
277
|
+
if (at === -1) return null;
|
|
278
|
+
const arr = code.slice(at).match(/\bproduces:\s*\[([^\]]*)\]/);
|
|
279
|
+
return arr ? quotedNames(arr[1]) : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The quoted names in an array literal — single, double or backtick, because the QUOTE STYLE IS THE
|
|
284
|
+
* APP'S and its prettier config decides it, not motu's.
|
|
285
|
+
*
|
|
286
|
+
* Returns null rather than `[]` when there was text it could not parse. Reading `["shots"]` with a
|
|
287
|
+
* single-quote regex yields an empty array, and an empty array is a perfectly good answer meaning
|
|
288
|
+
* "this source produces nothing" — so the caller concludes no key is source-owned and every check
|
|
289
|
+
* downstream passes by looking at nothing. An honest failure is the only safe kind here.
|
|
290
|
+
*/
|
|
291
|
+
function quotedNames(body) {
|
|
292
|
+
const names = [...body.matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
|
|
293
|
+
if (names.length) return names;
|
|
294
|
+
return body.trim() ? null : [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Split an object body on commas at depth 0. */
|
|
298
|
+
function splitTopLevel(body) {
|
|
299
|
+
const out = [];
|
|
300
|
+
let depth = 0;
|
|
301
|
+
let start = 0;
|
|
302
|
+
for (let i = 0; i < body.length; i++) {
|
|
303
|
+
const c = body[i];
|
|
304
|
+
if ('{[('.includes(c)) depth++;
|
|
305
|
+
else if ('}])'.includes(c)) depth--;
|
|
306
|
+
else if (c === ',' && depth === 0) {
|
|
307
|
+
out.push(body.slice(start, i));
|
|
308
|
+
start = i + 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
out.push(body.slice(start));
|
|
312
|
+
return out.filter((e) => e.trim());
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** slot -> { element, writes: { event: key | { field: key } } }, from the config's text. */
|
|
316
|
+
function readIslands(text) {
|
|
317
|
+
// PARSED FIRST. The reader below matches text, and two of its misses were load-bearing:
|
|
318
|
+
//
|
|
319
|
+
// - `writes` required the EVENT NAME TO BE QUOTED, so `writes: { searchTerm: 'searchTerm' }` —
|
|
320
|
+
// ordinary code — parsed as writing nothing. `ownership` is computed from these writes, so on
|
|
321
|
+
// any project spelling them that way the "one producer per key" rule compared empty sets and
|
|
322
|
+
// could not fire. Measured on shlink: two islands claiming one key, `motu check` green.
|
|
323
|
+
// - the root's `slots` mapping was read as if its entries were ISLANDS, inventing an island per
|
|
324
|
+
// mapped slot with no element — which is where "login-form, login-form" and a two-slot region
|
|
325
|
+
// reporting "1/4" came from.
|
|
326
|
+
//
|
|
327
|
+
// The regex stays as the fallback for a file ts-morph cannot parse: losing every island there would
|
|
328
|
+
// be worse than the misses above, and `islandsFromSource` answers null rather than [] for that case.
|
|
329
|
+
const parsed = islandsFromSource(text);
|
|
330
|
+
if (parsed) return parsed;
|
|
331
|
+
const code = blankComments(text);
|
|
332
|
+
const islands = [];
|
|
333
|
+
for (const block of balancedBlocks(code, /\bslot:\s*'([^']+)'/g)) {
|
|
334
|
+
const element = block.body.match(/\belement:\s*'([^']+)'/)?.[1];
|
|
335
|
+
const writes = {};
|
|
336
|
+
const w = blockAfter(code, 'writes:', '{', block.start);
|
|
337
|
+
if (w && w.start < block.end) {
|
|
338
|
+
for (const [, event, target] of w.body.matchAll(/'([^']+)':\s*(\{[^}]*\}|'[^']*')/g)) {
|
|
339
|
+
writes[event] = target.startsWith('{')
|
|
340
|
+
? Object.fromEntries([...target.matchAll(/(\w+):\s*'([^']+)'/g)].map((m) => [m[1], m[2]]))
|
|
341
|
+
: target.slice(1, -1);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const member = block.body.match(/\bmember:\s*'([^']+)'/)?.[1];
|
|
345
|
+
const readsBlock = block.body.match(/\breads:\s*\[([^\]]*)\]/)?.[1] ?? '';
|
|
346
|
+
const reads = [...readsBlock.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
347
|
+
const planned = /\bplanned:\s*true/.test(block.body);
|
|
348
|
+
// WHICH PROP CARRIES WHICH KEY. Both `bind` forms: the rename map `{ prop: 'key' }` and the short
|
|
349
|
+
// array `['key']`, where the prop and the key are the same word. Needed by `integrate check` to ask
|
|
350
|
+
// whether the PAGE actually passes what the island says it binds — a slot can be placed, composed
|
|
351
|
+
// and read while the island quietly runs on its defaults.
|
|
352
|
+
const bind = {};
|
|
353
|
+
const bindMap = block.body.match(/\bbind:\s*\{([^}]*)\}/)?.[1];
|
|
354
|
+
if (bindMap) {
|
|
355
|
+
for (const [, prop, key] of bindMap.matchAll(/(\w+)\s*:\s*'([^']+)'/g)) bind[prop] = key;
|
|
356
|
+
} else {
|
|
357
|
+
const bindArr = block.body.match(/\bbind:\s*\[([^\]]*)\]/)?.[1];
|
|
358
|
+
if (bindArr) {
|
|
359
|
+
// RENAMES FIRST, then the bare entries with the objects removed. Reading every quoted string
|
|
360
|
+
// as a bare entry turned `[{ value: 'filters' }]` into a binding on a prop called `filters`,
|
|
361
|
+
// which the component does not have — and the check then reported the page as not passing a
|
|
362
|
+
// prop nobody ever declared.
|
|
363
|
+
for (const [, prop, key] of bindArr.matchAll(/(\w+)\s*:\s*'([^']+)'/g)) bind[prop] = key;
|
|
364
|
+
for (const [, key] of bindArr.replace(/\{[^}]*\}/g, '').matchAll(/'([^']+)'/g)) bind[key] = key;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
islands.push({ slot: block.name, element, writes, member, reads, planned, bind });
|
|
368
|
+
}
|
|
369
|
+
return islands;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** tag -> { callbackProp: eventName }, from each island file's declared contract output. */
|
|
373
|
+
export function readOutputs(islandFiles, islandsDir) {
|
|
374
|
+
// The generated contracts first: with the short island form (`island('x-tag', Component)`) they are
|
|
375
|
+
// the only place the event names exist. An island file that still declares its own contract wins for
|
|
376
|
+
// its own tag — nothing has to be migrated for eject to keep working.
|
|
377
|
+
const out = { ...Object.fromEntries(Object.entries(readGeneratedContracts(islandsDir ?? '')).map(([tag, c]) => [tag, c.output])) };
|
|
378
|
+
for (const file of islandFiles) {
|
|
379
|
+
const text = blankComments(readFileSync(file, 'utf8'));
|
|
380
|
+
const tag = text.match(/\btag:\s*'([^']+)'/)?.[1];
|
|
381
|
+
if (!tag) continue;
|
|
382
|
+
const block = blockAfter(text, 'output:', '{');
|
|
383
|
+
out[tag] = block
|
|
384
|
+
? Object.fromEntries([...block.body.matchAll(/(\w+):\s*'([^']+)'/g)].map((m) => [m[1], m[2]]))
|
|
385
|
+
: {};
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Rewrite one host source file so it no longer needs motu, and report what was generated.
|
|
392
|
+
*
|
|
393
|
+
* Runs BEFORE the unwrap: it needs `<Island slot="…">` still in place to find which element is the
|
|
394
|
+
* producer. Returns [] when the file holds no region reads or seeds.
|
|
395
|
+
*/
|
|
396
|
+
export function ejectFile(sf, regions, outputs) {
|
|
397
|
+
// WHICH region this file belongs to, by the slots it actually places — a project has several, and
|
|
398
|
+
// picking the first typed one silently typed a page's state with another region's shape.
|
|
399
|
+
const usedSlots = new Set(
|
|
400
|
+
sf
|
|
401
|
+
.getDescendantsOfKind(SyntaxKind.JsxElement)
|
|
402
|
+
.filter((el) => el.getOpeningElement().getTagNameNode().getText().split('.').pop() === 'Island')
|
|
403
|
+
.map((el) => jsxStringProp(el.getOpeningElement(), 'slot'))
|
|
404
|
+
.filter(Boolean),
|
|
405
|
+
);
|
|
406
|
+
// A ROOT REGION places nothing by slot — that is the point of it — so the page names PROPS instead.
|
|
407
|
+
// Without this the file matched no region and fell back to "the first typed one", which typed the
|
|
408
|
+
// annuaire's state with the actions region's shape and failed the removal proof on a page that was
|
|
409
|
+
// otherwise correct.
|
|
410
|
+
const rootRegions = new Set(
|
|
411
|
+
[
|
|
412
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
413
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
414
|
+
]
|
|
415
|
+
.filter((el) => el.getTagNameNode().getText().split('.').pop() === 'Root')
|
|
416
|
+
.map((el) => regionOfRoot(el, regions))
|
|
417
|
+
.filter(Boolean),
|
|
418
|
+
);
|
|
419
|
+
const mine = regions.filter((r) => r.islands.some((i) => usedSlots.has(i.slot)) || rootRegions.has(r));
|
|
420
|
+
const typed = (mine.length ? mine : regions).find((r) => r.regionType && r.regionTypeFrom) ?? null;
|
|
421
|
+
// A key with no fallback in the host's read has no value until its producer fires, so the generated
|
|
422
|
+
// state is `T | undefined` — the same thing `useRegionValue` returned.
|
|
423
|
+
const typeOf = (key, initial) =>
|
|
424
|
+
typed ? `<${typed.regionType}[${JSON.stringify(key)}]${initial === 'undefined' ? ' | undefined' : ''}>` : '';
|
|
425
|
+
const notes = [];
|
|
426
|
+
const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
427
|
+
const reads = calls.filter((c) => isCall(c, 'useRegionValue'));
|
|
428
|
+
const regionReads = calls.filter((c) => isCall(c, 'useRegion'));
|
|
429
|
+
// `seedArchipelago(id, key, value)` and a binding's `seed(key, value)` — same act, one argument apart.
|
|
430
|
+
const seeds = calls.filter((c) => isCall(c, 'seedArchipelago', 'seed'));
|
|
431
|
+
if (!reads.length && !regionReads.length && !seeds.length) return notes;
|
|
432
|
+
|
|
433
|
+
// The component the reads live in, captured BEFORE any rewrite: replacing a node forgets it, and
|
|
434
|
+
// with it every ancestor lookup it could have answered.
|
|
435
|
+
const anchor = reads[0] ?? regionReads[0] ?? seeds[0];
|
|
436
|
+
const holderFn =
|
|
437
|
+
anchor.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration) ??
|
|
438
|
+
anchor.getFirstAncestorByKind(SyntaxKind.ArrowFunction);
|
|
439
|
+
|
|
440
|
+
// key -> { name, setter, initial } for every key this file reads or seeds.
|
|
441
|
+
const state = new Map();
|
|
442
|
+
const claim = (key, name, initial) => {
|
|
443
|
+
if (!state.has(key)) {
|
|
444
|
+
state.set(key, { key, name, setter: `set${name[0].toUpperCase()}${name.slice(1)}`, initial });
|
|
445
|
+
}
|
|
446
|
+
return state.get(key);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// 1. Reads become state. `const x = useRegionValue<T>('k') ?? 0` keeps its name and its fallback as
|
|
450
|
+
// the initial value — the fallback is what the page already decided an absent key means.
|
|
451
|
+
for (const call of reads) {
|
|
452
|
+
const key = literalArg(call, 0);
|
|
453
|
+
if (!key) continue;
|
|
454
|
+
const parent = call.getParent();
|
|
455
|
+
const binary = parent?.getKind() === SyntaxKind.BinaryExpression ? parent : null;
|
|
456
|
+
const initial = binary ? binary.getRight().getText() : 'undefined';
|
|
457
|
+
const decl = (binary ?? call).getParentIfKind(SyntaxKind.VariableDeclaration);
|
|
458
|
+
const name = decl ? decl.getName() : key;
|
|
459
|
+
const s = claim(key, name, initial);
|
|
460
|
+
if (decl) {
|
|
461
|
+
decl.replaceWithText(`[${s.name}, ${s.setter}] = useState${typeOf(key, s.initial)}(${s.initial})`);
|
|
462
|
+
} else {
|
|
463
|
+
(binary ?? call).replaceWithText(s.name);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 1b. `const { a = 0, b } = useRegion<R>()` — every destructured key becomes state, and the
|
|
468
|
+
// statement goes: the declarations are re-inserted together at the top of the component.
|
|
469
|
+
for (const call of regionReads) {
|
|
470
|
+
// See 1c for why this guard exists — it is the same array, walked three times.
|
|
471
|
+
if (call.wasForgotten()) continue;
|
|
472
|
+
const decl = call.getParentIfKind(SyntaxKind.VariableDeclaration);
|
|
473
|
+
const pattern = decl?.getNameNode();
|
|
474
|
+
if (!pattern || pattern.getKind() !== SyntaxKind.ObjectBindingPattern) continue;
|
|
475
|
+
for (const el of pattern.getElements()) {
|
|
476
|
+
const key = (el.getPropertyNameNode() ?? el.getNameNode()).getText();
|
|
477
|
+
claim(key, el.getNameNode().getText(), el.getInitializer()?.getText() ?? 'undefined');
|
|
478
|
+
}
|
|
479
|
+
decl.getVariableStatement()?.remove();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 1c. `const region = X.useRegion()` — the WHOLE region bound to one name, read as `region.key`.
|
|
483
|
+
//
|
|
484
|
+
// Only the destructured form was handled, so this one fell through untouched and removal left
|
|
485
|
+
// `const region = ListMembers.useRegion()` pointing at a module it had just deleted. The page
|
|
486
|
+
// then failed to compile and `removal-check` reported motu as load-bearing — on the very shape
|
|
487
|
+
// motu's own documentation recommends for reading a region back. Measured on a Rails adoption.
|
|
488
|
+
//
|
|
489
|
+
// The keys are not in the binding, so they are read from how the name is USED: every
|
|
490
|
+
// `region.<key>` in the file becomes its own state, and the declaration becomes a plain object
|
|
491
|
+
// of those, which keeps every `region.key` read working untouched.
|
|
492
|
+
for (const call of regionReads) {
|
|
493
|
+
// ONE CALL IS HANDLED ONCE, BY WHICHEVER SHAPE MATCHED IT FIRST.
|
|
494
|
+
//
|
|
495
|
+
// 1b and 1c walk the SAME `regionReads` array, and 1b ends with
|
|
496
|
+
// `decl.getVariableStatement()?.remove()` — which forgets the call node inside it. Reaching for
|
|
497
|
+
// `call.getParentIfKind(...)` here then threw "Attempted to get information from a node that was
|
|
498
|
+
// removed or forgotten", the surgery aborted, and the file was left untouched. `removal-check`
|
|
499
|
+
// reported the host as load-bearing on three pages whose only crime was to READ their region
|
|
500
|
+
// back with the destructured form — the shape motu's own docs recommend — and the TS errors it
|
|
501
|
+
// printed afterwards all named those same untouched files, so they looked like the finding.
|
|
502
|
+
//
|
|
503
|
+
// The function already knew about this hazard: the `anchor` above is captured before any rewrite
|
|
504
|
+
// "because replacing a node forgets it". The loops were simply never given the same care.
|
|
505
|
+
if (call.wasForgotten()) continue;
|
|
506
|
+
const decl = call.getParentIfKind(SyntaxKind.VariableDeclaration);
|
|
507
|
+
const nameNode = decl?.getNameNode();
|
|
508
|
+
if (!nameNode || nameNode.getKind() !== SyntaxKind.Identifier) continue;
|
|
509
|
+
const ident = nameNode.getText();
|
|
510
|
+
const keys = [
|
|
511
|
+
...new Set(
|
|
512
|
+
decl
|
|
513
|
+
.getSourceFile()
|
|
514
|
+
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
|
|
515
|
+
.filter((a) => a.getExpression().getText() === ident)
|
|
516
|
+
.map((a) => a.getNameNode().getText()),
|
|
517
|
+
),
|
|
518
|
+
];
|
|
519
|
+
// No `region.x` anywhere: the binding is unused, so the statement is simply dropped.
|
|
520
|
+
for (const key of keys) claim(key, key, 'undefined');
|
|
521
|
+
decl.setInitializer(keys.length ? `{ ${keys.join(', ')} }` : '{}');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 2. Seeds become a plain setState on that key's state.
|
|
525
|
+
for (const call of seeds) {
|
|
526
|
+
// The archipelago id is only present on the bare form, so find the key by shape, not by position.
|
|
527
|
+
const keyIndex = literalArg(call, 0) && !literalArg(call, 1) ? 0 : literalArg(call, 1) ? 1 : -1;
|
|
528
|
+
const key = keyIndex === -1 ? null : literalArg(call, keyIndex);
|
|
529
|
+
if (!key) continue;
|
|
530
|
+
const s = claim(key, key, 'undefined');
|
|
531
|
+
call.replaceWithText(`${s.setter}(${call.getArguments()[keyIndex + 1]?.getText() ?? 'undefined'})`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 3. The producer's output prop gets the wiring the archipelago's `writes` mapping described.
|
|
535
|
+
//
|
|
536
|
+
// The producer is reached two ways, and BOTH have to be walked. `<X.Island slot="s"><C/></X.Island>`
|
|
537
|
+
// is the page composing it directly; `<X.Root s={<C/>} />` is the page naming a prop and the region
|
|
538
|
+
// deciding which island wraps it. Handling only the first is not a smaller check, it is a silent
|
|
539
|
+
// one: the annuaire ejected `query` and `filters` as state and generated NOTHING to set them, so
|
|
540
|
+
// the rewritten page compiled — which is all the removal proof asserts — with a search box and a
|
|
541
|
+
// filter panel that could no longer change anything.
|
|
542
|
+
const wireInto = (opening, spec, label) => {
|
|
543
|
+
for (const [event, target] of Object.entries(spec.writes ?? {})) {
|
|
544
|
+
const callbackProp = Object.entries(outputs[spec.element] ?? {}).find(([, e]) => e === event)?.[0];
|
|
545
|
+
if (!callbackProp) continue;
|
|
546
|
+
// Only the keys the host actually holds state for: a produced key nobody here reads needs no
|
|
547
|
+
// wiring, and inventing a setter for it would be generated dead code.
|
|
548
|
+
const wired =
|
|
549
|
+
typeof target === 'string'
|
|
550
|
+
? state.has(target)
|
|
551
|
+
? [[target, `${state.get(target).setter}(d)`]]
|
|
552
|
+
: []
|
|
553
|
+
: Object.entries(target)
|
|
554
|
+
.filter(([, key]) => state.has(key))
|
|
555
|
+
.map(([field, key]) => [key, `${state.get(key).setter}(d.${field})`]);
|
|
556
|
+
if (!wired.length) continue;
|
|
557
|
+
const body = wired.map(([, call]) => call).join('; ');
|
|
558
|
+
|
|
559
|
+
// A prop the page already passes is KEPT and called first. Replacing it would silently drop
|
|
560
|
+
// wiring the app had of its own — the opposite of what eject is for.
|
|
561
|
+
const existing = opening.getAttribute(callbackProp);
|
|
562
|
+
const previous = existing?.getInitializer?.()?.getExpression?.()?.getText();
|
|
563
|
+
const handler = `(d) => { ${previous ? `(${previous})(d); ` : ''}${body}; }`;
|
|
564
|
+
if (existing) existing.replaceWithText(`${callbackProp}={${handler}}`);
|
|
565
|
+
else opening.addAttribute({ name: callbackProp, initializer: `{${handler}}` });
|
|
566
|
+
notes.push(`${label}.${callbackProp} -> ${wired.map(([key]) => key).join(', ')}${previous ? ' (kept the page\'s own handler)' : ''}`);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
for (const island of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
|
571
|
+
// `<Island>` or a binding's `<Actions.Island>`.
|
|
572
|
+
if (island.getOpeningElement().getTagNameNode().getText().split('.').pop() !== 'Island') continue;
|
|
573
|
+
const slot = jsxStringProp(island.getOpeningElement(), 'slot');
|
|
574
|
+
const spec = regions.flatMap((r) => r.islands).find((i) => i.slot === slot);
|
|
575
|
+
if (!spec) continue;
|
|
576
|
+
const child = island.getJsxChildren().find((c) => c.getKind() === SyntaxKind.JsxSelfClosingElement || c.getKind() === SyntaxKind.JsxElement);
|
|
577
|
+
if (!child) continue;
|
|
578
|
+
wireInto(child.getKind() === SyntaxKind.JsxElement ? child.getOpeningElement() : child, spec, slot);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// The root form: the prop names the slot, and the component to wire is whatever JSX the page put in
|
|
582
|
+
// it. A conditional prop (`notice={cond ? <A/> : null}`) holds it one level down, so every element
|
|
583
|
+
// inside the attribute is wired — each of them stands in that slot when its branch is the one taken.
|
|
584
|
+
for (const el of [
|
|
585
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
586
|
+
...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
587
|
+
]) {
|
|
588
|
+
if (el.getTagNameNode().getText().split('.').pop() !== 'Root') continue;
|
|
589
|
+
const region = regionOfRoot(el, regions);
|
|
590
|
+
if (region) {
|
|
591
|
+
for (const [prop, slot] of Object.entries(region.rootSlots ?? {})) {
|
|
592
|
+
const spec = region.islands.find((i) => i.slot === slot);
|
|
593
|
+
if (!spec?.writes) continue;
|
|
594
|
+
const expr = el.getAttribute?.(prop)?.getInitializer?.()?.getExpression?.();
|
|
595
|
+
if (!expr) continue;
|
|
596
|
+
for (const target of slotElements(expr)) wireInto(target, spec, `${slot} (via ${prop})`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 4. The state itself, at the top of the component the reads were in, plus React's useState.
|
|
602
|
+
if (state.size) {
|
|
603
|
+
const fn = holderFn;
|
|
604
|
+
const declared = new Set([...sf.getText().matchAll(/\[(\w+), (set\w+)\] = useState/g)].map((m) => m[1]));
|
|
605
|
+
const missing = [...state.values()].filter((s) => !declared.has(s.name));
|
|
606
|
+
if (fn && missing.length) {
|
|
607
|
+
fn.getBody()?.insertStatements(
|
|
608
|
+
0,
|
|
609
|
+
missing.map((s) => `const [${s.name}, ${s.setter}] = useState${typeOf(s.key, s.initial)}(${s.initial})`),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
ensureUseState(sf);
|
|
613
|
+
if (typed) ensureRegionType(sf, typed);
|
|
614
|
+
for (const s of state.values()) notes.push(`state ${s.name} (was region key)`);
|
|
615
|
+
}
|
|
616
|
+
return notes;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A call to `name`, however it is reached: bare (`useRegion()`), namespaced by a region binding
|
|
621
|
+
* (`Actions.useRegion()`), or with type arguments. Aliases are the point of `createRegion` — the host
|
|
622
|
+
* calls the region's own surface, not the framework's.
|
|
623
|
+
*/
|
|
624
|
+
function isCall(call, ...names) {
|
|
625
|
+
const text = call.getExpression().getText().replace(/<.*>$/s, '');
|
|
626
|
+
const last = text.split('.').pop();
|
|
627
|
+
return names.includes(text) || names.includes(last);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function literalArg(call, index) {
|
|
631
|
+
const arg = call.getArguments()[index];
|
|
632
|
+
return arg?.getKind() === SyntaxKind.StringLiteral ? arg.getLiteralText() : null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function jsxStringProp(opening, name) {
|
|
636
|
+
const attr = opening.getAttribute(name);
|
|
637
|
+
const init = attr?.getInitializer?.();
|
|
638
|
+
return init?.getKind() === SyntaxKind.StringLiteral ? init.getLiteralText() : null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The app's region type, imported for the generated state's annotations. */
|
|
642
|
+
function ensureRegionType(sf, region) {
|
|
643
|
+
// Already imported under any specifier — the app may reach the same module by a relative path where
|
|
644
|
+
// the archipelago used an alias, and adding a second import is a duplicate identifier, not a no-op.
|
|
645
|
+
const alreadyNamed = sf
|
|
646
|
+
.getImportDeclarations()
|
|
647
|
+
.some((i) => i.getNamedImports().some((n) => (n.getAliasNode() ?? n.getNameNode()).getText() === region.regionType));
|
|
648
|
+
if (alreadyNamed) return;
|
|
649
|
+
const existing = sf.getImportDeclaration((i) => i.getModuleSpecifierValue() === region.regionTypeFrom);
|
|
650
|
+
if (existing) {
|
|
651
|
+
if (!existing.getNamedImports().some((n) => n.getName() === region.regionType)) {
|
|
652
|
+
existing.addNamedImport({ name: region.regionType, isTypeOnly: !existing.isTypeOnly() });
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
sf.insertStatements(0, `import type { ${region.regionType} } from "${region.regionTypeFrom}"`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function ensureUseState(sf) {
|
|
660
|
+
const react = sf.getImportDeclaration((i) => i.getModuleSpecifierValue() === 'react');
|
|
661
|
+
if (!react) {
|
|
662
|
+
sf.insertStatements(0, `import { useState } from "react"`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (!react.getNamedImports().some((n) => n.getName() === 'useState')) react.addNamedImport('useState');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// --- text helpers (shared shape with verify's balanced-block reader) -----------------------------
|
|
669
|
+
|
|
670
|
+
// One implementation, in util: a glob is a string containing `/*`, and three hand-rolled copies of
|
|
671
|
+
// this got that wrong in three places.
|
|
672
|
+
const blankComments = blankCommentsShared;
|
|
673
|
+
|
|
674
|
+
function blockAfter(code, label, open, from = 0) {
|
|
675
|
+
const at = code.indexOf(label, from);
|
|
676
|
+
if (at === -1) return null;
|
|
677
|
+
const start = code.indexOf(open, at);
|
|
678
|
+
if (start === -1) return null;
|
|
679
|
+
const close = open === '{' ? '}' : ']';
|
|
680
|
+
let depth = 0;
|
|
681
|
+
for (let i = start; i < code.length; i++) {
|
|
682
|
+
if (code[i] === open) depth++;
|
|
683
|
+
else if (code[i] === close && --depth === 0) return { body: code.slice(start + 1, i), start, end: i };
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** The `{ … }` object literal around each match of `re`, with the capture as `name`. */
|
|
689
|
+
function balancedBlocks(code, re) {
|
|
690
|
+
const out = [];
|
|
691
|
+
for (const m of code.matchAll(re)) {
|
|
692
|
+
let depth = 0;
|
|
693
|
+
let start = -1;
|
|
694
|
+
for (let i = m.index; i >= 0; i--) {
|
|
695
|
+
if (code[i] === '}') depth++;
|
|
696
|
+
else if (code[i] === '{') {
|
|
697
|
+
if (depth === 0) {
|
|
698
|
+
start = i;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
depth--;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (start === -1) continue;
|
|
705
|
+
depth = 0;
|
|
706
|
+
for (let i = start; i < code.length; i++) {
|
|
707
|
+
if (code[i] === '{') depth++;
|
|
708
|
+
else if (code[i] === '}' && --depth === 0) {
|
|
709
|
+
out.push({ name: m[1], body: code.slice(start + 1, i), start, end: i });
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|