@component-compass/parser-react 0.0.2 → 0.0.4
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/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/walk-jsx.d.ts +4 -1
- package/dist/walk-jsx.js +924 -344
- package/dist/walk-jsx.js.map +1 -1
- package/package.json +6 -6
package/dist/walk-jsx.js
CHANGED
|
@@ -1,111 +1,346 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { relative, isAbsolute } from "node:path";
|
|
2
|
+
import { walk } from "oxc-walker";
|
|
2
3
|
import { serialiseComponentId, externalPackageOf } from "@component-compass/plugin-core";
|
|
3
4
|
import { asTagName, asWrapperId } from "@component-compass/plugin-core";
|
|
4
|
-
import {
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
import { posixPath, classifyImportOrigin, positionAt } from "@component-compass/ast-utils";
|
|
6
|
+
// React-API factories whose return value is conventionally a component.
|
|
7
|
+
// Detected by callee identifier name OR `React.<name>` member-expression.
|
|
8
|
+
// Detection is callee-name only — we don't try to verify the factory's
|
|
9
|
+
// return shape.
|
|
10
|
+
const LOCAL_COMPONENT_FACTORIES = new Set(["forwardRef", "memo", "lazy"]);
|
|
11
|
+
// HOC names recognised for owner-binding unwrap (mirrors find-owner.ts).
|
|
12
|
+
const HOC_NAMES = new Set(["memo", "forwardRef", "observer"]);
|
|
13
|
+
/**
|
|
14
|
+
* Walk a JSXMemberExpression chain to its root identifier, returning the
|
|
15
|
+
* flat dotted name (e.g. "Tabs.Trigger.Inner") and the root identifier name.
|
|
16
|
+
* Returns null if the chain bottoms out at anything other than a JSXIdentifier.
|
|
17
|
+
*/
|
|
18
|
+
function readMemberChain(node) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
let current = node;
|
|
21
|
+
while (current.type === "JSXMemberExpression") {
|
|
22
|
+
parts.unshift(current.property.name);
|
|
23
|
+
current = current.object;
|
|
24
|
+
}
|
|
25
|
+
if (current.type !== "JSXIdentifier")
|
|
26
|
+
return null;
|
|
27
|
+
parts.unshift(current.name);
|
|
28
|
+
return { compoundName: parts.join("."), rootName: current.name };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build an opportunistic Occurrence for an unmapped import — used when
|
|
32
|
+
* `resolveOpening` returns undefined but the root identifier matches an
|
|
33
|
+
* unmappedImports entry. `tail` is the compound-name suffix after the root
|
|
34
|
+
* (null for bare `<Foo/>`, "Bar" for `<Foo.Bar/>`, "Bar.Baz" for deeper).
|
|
35
|
+
*
|
|
36
|
+
* `unmapped.modulePath` is the package-relative resolved module path when the
|
|
37
|
+
* lazy resolver landed on a leaf (paired with non-null `leafPackage`); null
|
|
38
|
+
* otherwise (unresolvable specifier or bare-package fallback through
|
|
39
|
+
* classifyImportOrigin). The emission threads it onto the external identity
|
|
40
|
+
* so downstream consumers can disambiguate same-export entries served from
|
|
41
|
+
* different package subpaths (#63).
|
|
42
|
+
*
|
|
43
|
+
* Two terminal paths:
|
|
44
|
+
* - `unmapped.leafPackage` set → opaque external occurrence rooted at the
|
|
45
|
+
* leaf package; compound usages prefix the tail with the importedName
|
|
46
|
+
* (with the `default.` prefix stripped for default-import roots).
|
|
47
|
+
* - else → classifyImportOrigin → local-source match against the
|
|
48
|
+
* local-index by-path map.
|
|
49
|
+
*/
|
|
50
|
+
function emitOpportunistic(unmapped, tail, opening, opts, source) {
|
|
51
|
+
const compoundExport = tail === null
|
|
52
|
+
? unmapped.importedName
|
|
53
|
+
: unmapped.importedName === "default"
|
|
54
|
+
? tail
|
|
55
|
+
: `${unmapped.importedName}.${tail}`;
|
|
56
|
+
const via = { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName };
|
|
57
|
+
const loc = locOf(opening, opts.file, source);
|
|
58
|
+
if (unmapped.leafPackage) {
|
|
59
|
+
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
60
|
+
return {
|
|
61
|
+
componentId: {
|
|
62
|
+
kind: "react-component",
|
|
63
|
+
export: compoundExport,
|
|
64
|
+
source: {
|
|
65
|
+
type: "external",
|
|
66
|
+
package: unmapped.leafPackage,
|
|
67
|
+
...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
loc,
|
|
71
|
+
via,
|
|
72
|
+
props,
|
|
73
|
+
events,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Fallback: classifyImportOrigin to determine whether the specifier is
|
|
77
|
+
// external or local. The lazy resolver returns null only for genuinely
|
|
78
|
+
// unresolvable specifiers or workspace siblings now (identity resolution
|
|
79
|
+
// always runs for external imports). Distinguish the two cases here:
|
|
80
|
+
// - external → emit an opaque occurrence keyed by package name; manifest
|
|
81
|
+
// stays null (manifest enrichment and identity resolution are separate)
|
|
82
|
+
// - local → match against the local index (original path below)
|
|
83
|
+
// - unresolved → nothing to emit
|
|
84
|
+
const origin = classifyImportOrigin(unmapped.specifier, {
|
|
85
|
+
repoRoot: opts.repoRoot,
|
|
86
|
+
fromFile: opts.file,
|
|
13
87
|
});
|
|
88
|
+
if (origin.type === "external") {
|
|
89
|
+
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
90
|
+
return {
|
|
91
|
+
componentId: {
|
|
92
|
+
kind: "react-component",
|
|
93
|
+
export: compoundExport,
|
|
94
|
+
source: {
|
|
95
|
+
type: "external",
|
|
96
|
+
package: origin.package,
|
|
97
|
+
...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
loc,
|
|
101
|
+
via,
|
|
102
|
+
props,
|
|
103
|
+
events,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Bare-specifier fallback: classifyImportOrigin returns "unresolved" for
|
|
107
|
+
// workspace-symlinked packages (no node_modules/ segment in the resolved
|
|
108
|
+
// path, yet outside the fixture root). If the specifier looks like a bare
|
|
109
|
+
// npm package name, use it directly as the package name — identity is
|
|
110
|
+
// preserved; manifest stays null.
|
|
111
|
+
if (origin.type === "unresolved" && isBarePackageSpecifier(unmapped.specifier)) {
|
|
112
|
+
const pkgName = unmapped.specifier.startsWith("@")
|
|
113
|
+
? unmapped.specifier.split("/").slice(0, 2).join("/")
|
|
114
|
+
: (unmapped.specifier.split("/")[0] ?? unmapped.specifier);
|
|
115
|
+
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
116
|
+
return {
|
|
117
|
+
componentId: {
|
|
118
|
+
kind: "react-component",
|
|
119
|
+
export: compoundExport,
|
|
120
|
+
source: {
|
|
121
|
+
type: "external",
|
|
122
|
+
package: pkgName,
|
|
123
|
+
...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
loc,
|
|
127
|
+
via,
|
|
128
|
+
props,
|
|
129
|
+
events,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (origin.type !== "local")
|
|
133
|
+
return undefined;
|
|
134
|
+
const localDefs = opts.localIndex.byPath.get(origin.filePath);
|
|
135
|
+
const matched = localDefs?.find((d) => unmapped.importedName === "default"
|
|
136
|
+
? d.isDefault
|
|
137
|
+
: d.exportName === unmapped.importedName && !d.isDefault);
|
|
138
|
+
if (!matched || matched.componentId.kind !== "react-component")
|
|
139
|
+
return undefined;
|
|
140
|
+
if (tail !== null && matched.componentId.source.type !== "local")
|
|
141
|
+
return undefined;
|
|
142
|
+
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
143
|
+
// For bare usage (tail null): identity is the local def's componentId.
|
|
144
|
+
// For compound: rebuild with `matched.exportName.tail` (stripping
|
|
145
|
+
// `default.` for default-exported locals — mirrors the leafPackage path).
|
|
146
|
+
if (tail === null) {
|
|
147
|
+
return { componentId: matched.componentId, loc, via, props, events };
|
|
148
|
+
}
|
|
149
|
+
const localCompoundExport = matched.exportName === "default" ? tail : `${matched.exportName}.${tail}`;
|
|
150
|
+
return {
|
|
151
|
+
componentId: {
|
|
152
|
+
kind: "react-component",
|
|
153
|
+
export: localCompoundExport,
|
|
154
|
+
source: matched.componentId.source,
|
|
155
|
+
},
|
|
156
|
+
loc,
|
|
157
|
+
via,
|
|
158
|
+
props,
|
|
159
|
+
events,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Pre-parsed oxc Program is supplied by scan.ts (single source of truth for
|
|
163
|
+
// parsing). Tasks 1-7 of Phase C swapped parsing to oxc-parser; this walker
|
|
164
|
+
// drives off the resulting Program node. Recovered parse errors land on the
|
|
165
|
+
// raw ParseResult.errors field upstream — oxc-parser carries them on the
|
|
166
|
+
// result, not the Program, so they are warned at parse time and not
|
|
167
|
+
// re-emitted here.
|
|
168
|
+
export function walkJsx(opts) {
|
|
169
|
+
const program = opts.ast;
|
|
170
|
+
const source = opts.source;
|
|
14
171
|
const occurrences = [];
|
|
15
172
|
const wrappers = [];
|
|
16
173
|
const imports = [];
|
|
17
174
|
const warnings = [];
|
|
18
|
-
const recovered = ast.errors ?? [];
|
|
19
|
-
for (const err of recovered) {
|
|
20
|
-
warnings.push({
|
|
21
|
-
code: "PARSE_FAILURE",
|
|
22
|
-
message: `Recovered parse error: ${err.message}`,
|
|
23
|
-
file: opts.file,
|
|
24
|
-
// line: 0 means "unknown" when Babel didn't supply a location.
|
|
25
|
-
line: typeof err.loc?.line === "number" ? err.loc.line : 0,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
175
|
const localBindings = new Map();
|
|
29
|
-
// Tracks imports that did NOT match a manifest entry: local → { importedName,
|
|
30
|
-
// `leafPackage` is non-null when the
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// origin classification on the
|
|
176
|
+
// Tracks imports that did NOT match a manifest entry: local → { importedName,
|
|
177
|
+
// specifier, leafPackage, modulePath }. `leafPackage` is non-null when the
|
|
178
|
+
// lazy resolver landed on a leaf package but had no manifest entry for
|
|
179
|
+
// `importedName` — in that case the third pass attributes the opaque external
|
|
180
|
+
// occurrence to the leaf rather than re-running origin classification on the
|
|
181
|
+
// user-written aggregator specifier. `modulePath` is the package-relative
|
|
182
|
+
// resolved path (paired with `leafPackage`); null when the resolver returned
|
|
183
|
+
// no hit, threaded onto the external identity by the JSX-emission pass.
|
|
34
184
|
const unmappedImports = new Map();
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// exportName won't match anything in matchInLeaf, but the call still
|
|
44
|
-
// walks into the leaf and populates byTag for tag-only consumers.
|
|
45
|
-
opts.resolveLazyManifest(opts.file, source, "");
|
|
185
|
+
// ── Pass 1: collect imports + local Context / factory bindings ──────────
|
|
186
|
+
// Single pre-pass over the program populates `localBindings` and
|
|
187
|
+
// `unmappedImports` so the wrapper-detection (pass 2) and JSX-emission
|
|
188
|
+
// (pass 3) passes have a complete view of what's bound.
|
|
189
|
+
walk(program, {
|
|
190
|
+
enter(node) {
|
|
191
|
+
if (node.type === "VariableDeclarator") {
|
|
192
|
+
collectLocalVarBinding(node, localBindings);
|
|
46
193
|
return;
|
|
47
194
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
continue;
|
|
51
|
-
const imported = spec.type === "ImportSpecifier" ? identName(spec.imported) : "default";
|
|
52
|
-
const local = spec.local.name;
|
|
53
|
-
const hit = opts.resolveLazyManifest(opts.file, source, imported);
|
|
54
|
-
if (hit?.matched?.kind === "custom-element") {
|
|
55
|
-
localBindings.set(local, {
|
|
56
|
-
kind: "wc-wrapper",
|
|
57
|
-
tagName: asTagName(hit.matched.tagName),
|
|
58
|
-
package: hit.leafPackage,
|
|
59
|
-
specifier: source,
|
|
60
|
-
importName: imported,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
else if (hit?.matched?.kind === "react-component") {
|
|
64
|
-
localBindings.set(local, {
|
|
65
|
-
kind: "react-direct",
|
|
66
|
-
package: hit.leafPackage,
|
|
67
|
-
export: hit.matched.export,
|
|
68
|
-
specifier: source,
|
|
69
|
-
importName: imported,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
else if (hit) {
|
|
73
|
-
// Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
|
|
74
|
-
unmappedImports.set(local, {
|
|
75
|
-
importedName: imported,
|
|
76
|
-
specifier: source,
|
|
77
|
-
leafPackage: hit.leafPackage,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
// Specifier did not resolve (could be local relative or unresolvable).
|
|
82
|
-
unmappedImports.set(local, {
|
|
83
|
-
importedName: imported,
|
|
84
|
-
specifier: source,
|
|
85
|
-
leafPackage: null,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
|
|
195
|
+
if (node.type === "ImportDeclaration") {
|
|
196
|
+
collectImportBindings(node, opts, localBindings, unmappedImports, imports, source);
|
|
89
197
|
}
|
|
90
198
|
},
|
|
91
199
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
200
|
+
const wrapperStack = [];
|
|
201
|
+
const ancestorMap = new WeakMap();
|
|
202
|
+
const parentOf = (n) => ancestorMap.get(n) ?? null;
|
|
203
|
+
walk(program, {
|
|
204
|
+
enter(node, parent) {
|
|
205
|
+
if (parent)
|
|
206
|
+
ancestorMap.set(node, parent);
|
|
207
|
+
const candidate = wrapperCandidate(node, parent ?? null, program, parentOf);
|
|
208
|
+
if (candidate)
|
|
209
|
+
wrapperStack.push(candidate);
|
|
210
|
+
if (node.type === "JSXOpeningElement") {
|
|
211
|
+
const frame = wrapperStack[wrapperStack.length - 1];
|
|
212
|
+
if (frame)
|
|
213
|
+
collectWrapperDep(node, localBindings, opts.lookupByTag, frame);
|
|
214
|
+
}
|
|
96
215
|
},
|
|
97
|
-
|
|
98
|
-
|
|
216
|
+
leave(node) {
|
|
217
|
+
const top = wrapperStack[wrapperStack.length - 1];
|
|
218
|
+
if (top && top.node === node) {
|
|
219
|
+
wrapperStack.pop();
|
|
220
|
+
if (top.deps.length > 0) {
|
|
221
|
+
wrappers.push({
|
|
222
|
+
id: wrapperId(top.name),
|
|
223
|
+
name: top.name,
|
|
224
|
+
file: posixPath(opts.file),
|
|
225
|
+
exportName: top.exportName,
|
|
226
|
+
componentDeps: top.deps,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
99
230
|
},
|
|
100
231
|
});
|
|
101
232
|
/** Topmost element is the current tracked parent. node is kept for matched
|
|
102
233
|
* pop on exit (depth-first traversal guarantees the topmost stack entry is
|
|
103
234
|
* the current node when its exit handler fires). */
|
|
104
235
|
const parentStack = [];
|
|
236
|
+
const renderPropStack = [];
|
|
237
|
+
const ownerStack = [];
|
|
105
238
|
function resolveOpening(opening) {
|
|
106
239
|
const nameNode = opening.name;
|
|
107
|
-
|
|
240
|
+
const start = opening.start;
|
|
241
|
+
const loc = typeof start === "number" ? positionAt(source, start) : { line: 1, column: 0 };
|
|
242
|
+
if (nameNode.type === "JSXMemberExpression") {
|
|
243
|
+
const chain = readMemberChain(nameNode);
|
|
244
|
+
if (!chain) {
|
|
245
|
+
opts.collector?.emit({
|
|
246
|
+
code: "jsx-shape-unsupported",
|
|
247
|
+
severity: "warning",
|
|
248
|
+
filePath: opts.file,
|
|
249
|
+
line: loc.line,
|
|
250
|
+
column: loc.column,
|
|
251
|
+
nameType: "JSXMemberExpression-malformed",
|
|
252
|
+
detail: "member chain did not bottom out at JSXIdentifier",
|
|
253
|
+
});
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
if (/^[a-z]/.test(chain.rootName)) {
|
|
257
|
+
opts.collector?.emit({
|
|
258
|
+
code: "jsx-shape-unsupported",
|
|
259
|
+
severity: "warning",
|
|
260
|
+
filePath: opts.file,
|
|
261
|
+
line: loc.line,
|
|
262
|
+
column: loc.column,
|
|
263
|
+
nameType: "JSXMemberExpression-lowercase-root",
|
|
264
|
+
detail: `lowercase root identifier "${chain.rootName}" cannot be a React component`,
|
|
265
|
+
});
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
const bound = localBindings.get(chain.rootName);
|
|
269
|
+
if (bound) {
|
|
270
|
+
// Compound subcomponents always emit as react-component, regardless of
|
|
271
|
+
// whether the root binding is react-direct or wc-wrapper. The WC-wrapper
|
|
272
|
+
// folding rule applies only to bare root usage (<Tabs />), never to
|
|
273
|
+
// compound subcomponents (<Tabs.Trigger />).
|
|
274
|
+
if (bound.kind === "local-context") {
|
|
275
|
+
// `<MyContext.Provider>` / `<MyContext.Consumer>` is access to a
|
|
276
|
+
// member of a React Context object, not a separate component
|
|
277
|
+
// definition. Skip emission so the artifact doesn't gain a spurious
|
|
278
|
+
// `MyContext.Provider` component identity without a real binding site.
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
if (bound.kind === "local-component") {
|
|
282
|
+
// Compound usage of a local var-bound component (e.g. forwardRef'd
|
|
283
|
+
// root with static subcomponents attached: `<Button.Header />`).
|
|
284
|
+
// Full compound name preserves the access pattern so subcomponents
|
|
285
|
+
// surface as distinct identities under the same local-source root.
|
|
286
|
+
return { kind: "local-component", export: chain.compoundName };
|
|
287
|
+
}
|
|
288
|
+
if (bound.kind === "react-namespace") {
|
|
289
|
+
// `import * as Foo from "pkg"` — strip the namespace alias and emit
|
|
290
|
+
// the tail as the package's actual export. `via.import` (set in the
|
|
291
|
+
// emission step) preserves the local namespace alias so consumers
|
|
292
|
+
// can still distinguish namespace-style access from named imports.
|
|
293
|
+
// Namespace imports don't trigger per-export resolver hits, so
|
|
294
|
+
// modulePath is unknown at this access point.
|
|
295
|
+
const tail = chain.compoundName.slice(chain.rootName.length + 1);
|
|
296
|
+
return {
|
|
297
|
+
kind: "react-direct",
|
|
298
|
+
package: bound.package,
|
|
299
|
+
export: tail,
|
|
300
|
+
specifier: bound.specifier,
|
|
301
|
+
importName: bound.importName,
|
|
302
|
+
modulePath: null,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
kind: "react-direct",
|
|
307
|
+
package: bound.package,
|
|
308
|
+
export: chain.compoundName,
|
|
309
|
+
specifier: bound.specifier,
|
|
310
|
+
importName: bound.importName,
|
|
311
|
+
modulePath: bound.modulePath,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// Root unbound. If the root matches an unmapped import, skip the
|
|
315
|
+
// diagnostic — the JSX-element opportunistic block will attempt to emit
|
|
316
|
+
// an opaque external (or local-source) occurrence using the unmapped
|
|
317
|
+
// binding. Symmetric with the single-identifier branch which is silent
|
|
318
|
+
// on unmapped misses.
|
|
319
|
+
if (!unmappedImports.has(chain.rootName)) {
|
|
320
|
+
opts.collector?.emit({
|
|
321
|
+
code: "jsx-shape-unsupported",
|
|
322
|
+
severity: "warning",
|
|
323
|
+
filePath: opts.file,
|
|
324
|
+
line: loc.line,
|
|
325
|
+
column: loc.column,
|
|
326
|
+
nameType: "JSXMemberExpression-unbound-root",
|
|
327
|
+
detail: `compound JSX with unbound root "${chain.rootName}"`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
if (nameNode.type !== "JSXIdentifier") {
|
|
333
|
+
opts.collector?.emit({
|
|
334
|
+
code: "jsx-shape-unsupported",
|
|
335
|
+
severity: "warning",
|
|
336
|
+
filePath: opts.file,
|
|
337
|
+
line: loc.line,
|
|
338
|
+
column: loc.column,
|
|
339
|
+
nameType: nameNode.type,
|
|
340
|
+
detail: `unhandled JSX element name type: ${nameNode.type}`,
|
|
341
|
+
});
|
|
108
342
|
return undefined;
|
|
343
|
+
}
|
|
109
344
|
const id = nameNode.name;
|
|
110
345
|
if (/^[a-z]/.test(id) && id.includes("-")) {
|
|
111
346
|
const indexed = opts.lookupByTag(asTagName(id));
|
|
@@ -131,128 +366,573 @@ export function walkJsx(opts) {
|
|
|
131
366
|
export: bound.export,
|
|
132
367
|
specifier: bound.specifier,
|
|
133
368
|
importName: bound.importName,
|
|
369
|
+
modulePath: bound.modulePath,
|
|
134
370
|
};
|
|
135
371
|
}
|
|
372
|
+
if (bound?.kind === "local-component") {
|
|
373
|
+
return { kind: "local-component", export: id };
|
|
374
|
+
}
|
|
136
375
|
return undefined;
|
|
137
376
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
377
|
+
// Render-prop guard: true iff the topmost render-prop frame was pushed
|
|
378
|
+
// AFTER its lexical parent JSX was entered. The parent JSX adds 1 to
|
|
379
|
+
// parentStack.length on enter; if the render-prop frame was pushed when
|
|
380
|
+
// parentStack.length was >= the current value, no JSX was emitted since
|
|
381
|
+
// — meaning the render-prop scope opened inside the parent's subtree.
|
|
382
|
+
// (When called, `parentNode` is the topmost parentStack entry.)
|
|
383
|
+
const isInRenderPropScopeOxc = (parentNode) => {
|
|
384
|
+
if (!parentNode)
|
|
385
|
+
return false;
|
|
386
|
+
const top = renderPropStack[renderPropStack.length - 1];
|
|
387
|
+
return top !== undefined && top.depthAtPush >= parentStack.length;
|
|
388
|
+
};
|
|
389
|
+
walk(program, {
|
|
390
|
+
enter(node, parent) {
|
|
391
|
+
// ── Owner-attribution frame push ──────────────────────────────────
|
|
392
|
+
if (isOwnerCandidateNode(node)) {
|
|
393
|
+
const grand = parent ? parentOf(parent) : null;
|
|
394
|
+
ownerStack.push({ node, parent: parent ?? null, grand });
|
|
395
|
+
}
|
|
396
|
+
// ── Render-prop scope push ─────────────────────────────────────────
|
|
397
|
+
// Function bodies and JSXAttribute values open a render-prop boundary.
|
|
398
|
+
// Tracked at enter so parent-stack maintenance can read them at JSX time.
|
|
399
|
+
if (node.type === "ArrowFunctionExpression" ||
|
|
400
|
+
node.type === "FunctionExpression" ||
|
|
401
|
+
node.type === "FunctionDeclaration" ||
|
|
402
|
+
node.type === "JSXAttribute") {
|
|
403
|
+
renderPropStack.push({ node, depthAtPush: parentStack.length });
|
|
404
|
+
}
|
|
405
|
+
if (node.type !== "JSXElement")
|
|
406
|
+
return;
|
|
407
|
+
const jsxNode = node;
|
|
408
|
+
const opening = jsxNode.openingElement;
|
|
409
|
+
const resolved = resolveOpening(opening);
|
|
410
|
+
let occurrence;
|
|
411
|
+
if (!resolved) {
|
|
412
|
+
// Opportunistic emission for unmapped imports (preserves Phase 1
|
|
413
|
+
// behaviour). Determine the unmapped root + compound tail (if any),
|
|
414
|
+
// then delegate to emitOpportunistic — single-id and compound paths
|
|
415
|
+
// share leafPackage → opaque-external and classifyImportOrigin →
|
|
416
|
+
// local-source resolution.
|
|
417
|
+
const nameNode = opening.name;
|
|
418
|
+
let rootId = null;
|
|
419
|
+
let tail = null;
|
|
420
|
+
if (nameNode.type === "JSXIdentifier") {
|
|
421
|
+
const id = nameNode.name;
|
|
422
|
+
if (!/^[a-z]/.test(id) || !id.includes("-"))
|
|
423
|
+
rootId = id;
|
|
191
424
|
}
|
|
192
|
-
else {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
|
|
198
|
-
loc,
|
|
199
|
-
via: { kind: "html-tag" },
|
|
200
|
-
props,
|
|
201
|
-
events,
|
|
202
|
-
};
|
|
425
|
+
else if (nameNode.type === "JSXMemberExpression") {
|
|
426
|
+
const chain = readMemberChain(nameNode);
|
|
427
|
+
if (chain && !/^[a-z]/.test(chain.rootName)) {
|
|
428
|
+
rootId = chain.rootName;
|
|
429
|
+
tail = chain.compoundName.slice(chain.rootName.length + 1);
|
|
203
430
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
props,
|
|
210
|
-
events,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
occurrence = {
|
|
215
|
-
componentId: { kind: "react-component", export: resolved.export, source: { type: "external", package: resolved.package } },
|
|
216
|
-
loc,
|
|
217
|
-
via: { kind: "direct-import", specifier: resolved.specifier, import: resolved.importName },
|
|
218
|
-
props,
|
|
219
|
-
events,
|
|
220
|
-
};
|
|
431
|
+
}
|
|
432
|
+
if (rootId !== null) {
|
|
433
|
+
const unmapped = unmappedImports.get(rootId);
|
|
434
|
+
if (unmapped) {
|
|
435
|
+
occurrence = emitOpportunistic(unmapped, tail, opening, opts, source);
|
|
221
436
|
}
|
|
222
437
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
occurrence
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
441
|
+
const loc = locOf(opening, opts.file, source);
|
|
442
|
+
if (resolved.kind === "wc-tag") {
|
|
443
|
+
occurrence = {
|
|
444
|
+
componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
|
|
445
|
+
loc,
|
|
446
|
+
via: { kind: "html-tag" },
|
|
447
|
+
props,
|
|
448
|
+
events,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
else if (resolved.kind === "wc-wrapper") {
|
|
452
|
+
occurrence = {
|
|
453
|
+
componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
|
|
454
|
+
loc,
|
|
455
|
+
via: { kind: "react-wrapper", specifier: resolved.specifier, import: resolved.importName },
|
|
456
|
+
props,
|
|
457
|
+
events,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
else if (resolved.kind === "local-context") {
|
|
461
|
+
occurrence = {
|
|
462
|
+
componentId: {
|
|
463
|
+
kind: "react-component",
|
|
464
|
+
export: resolved.export,
|
|
465
|
+
source: { type: "local", filePath: posixPath(opts.file) },
|
|
466
|
+
},
|
|
467
|
+
loc,
|
|
468
|
+
via: { kind: "context-provider" },
|
|
469
|
+
props,
|
|
470
|
+
events,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
else if (resolved.kind === "local-component") {
|
|
474
|
+
occurrence = {
|
|
475
|
+
componentId: {
|
|
476
|
+
kind: "react-component",
|
|
477
|
+
export: resolved.export,
|
|
478
|
+
source: { type: "local", filePath: posixPath(opts.file) },
|
|
479
|
+
},
|
|
480
|
+
loc,
|
|
481
|
+
via: { kind: "local-component" },
|
|
482
|
+
props,
|
|
483
|
+
events,
|
|
484
|
+
};
|
|
229
485
|
}
|
|
230
486
|
else {
|
|
231
|
-
|
|
232
|
-
|
|
487
|
+
occurrence = {
|
|
488
|
+
componentId: {
|
|
489
|
+
kind: "react-component",
|
|
490
|
+
export: resolved.export,
|
|
491
|
+
source: {
|
|
492
|
+
type: "external",
|
|
493
|
+
package: resolved.package,
|
|
494
|
+
...(resolved.modulePath !== null ? { modulePath: resolved.modulePath } : {}),
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
loc,
|
|
498
|
+
via: { kind: "direct-import", specifier: resolved.specifier, import: resolved.importName },
|
|
499
|
+
props,
|
|
500
|
+
events,
|
|
501
|
+
};
|
|
233
502
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
503
|
+
}
|
|
504
|
+
if (!occurrence)
|
|
505
|
+
return;
|
|
506
|
+
const parentEntry = parentStack[parentStack.length - 1];
|
|
507
|
+
if (parentEntry && !isInRenderPropScopeOxc(parentEntry.node)) {
|
|
508
|
+
occurrence.parentRef = parentEntry.idx;
|
|
509
|
+
occurrence.depth = parentStack.length;
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// Render-prop scope OR no parent: treated as fresh root in this lexical scope.
|
|
513
|
+
occurrence.depth = 0;
|
|
514
|
+
}
|
|
515
|
+
const owner = resolveOwnerFromStack(ownerStack, opts.localIndex, opts.file, opts.repoRoot);
|
|
516
|
+
if (owner !== undefined) {
|
|
517
|
+
occurrence.ownerComponentId = owner;
|
|
518
|
+
}
|
|
519
|
+
const idx = occurrences.length;
|
|
520
|
+
occurrences.push(occurrence);
|
|
521
|
+
parentStack.push({ idx, node: jsxNode });
|
|
522
|
+
},
|
|
523
|
+
leave(node) {
|
|
524
|
+
// Pop owner frame first (it's a subset of structural frames).
|
|
525
|
+
const topOwner = ownerStack[ownerStack.length - 1];
|
|
526
|
+
if (topOwner && topOwner.node === node)
|
|
527
|
+
ownerStack.pop();
|
|
528
|
+
// Pop render-prop frame for function/attribute boundaries we entered.
|
|
529
|
+
if (node.type === "ArrowFunctionExpression" ||
|
|
530
|
+
node.type === "FunctionExpression" ||
|
|
531
|
+
node.type === "FunctionDeclaration" ||
|
|
532
|
+
node.type === "JSXAttribute") {
|
|
533
|
+
const topRp = renderPropStack[renderPropStack.length - 1];
|
|
534
|
+
if (topRp && topRp.node === node)
|
|
535
|
+
renderPropStack.pop();
|
|
536
|
+
}
|
|
537
|
+
if (node.type !== "JSXElement")
|
|
538
|
+
return;
|
|
539
|
+
const top = parentStack[parentStack.length - 1];
|
|
540
|
+
if (top && top.node === node)
|
|
541
|
+
parentStack.pop();
|
|
243
542
|
},
|
|
244
543
|
});
|
|
245
544
|
return { occurrences, wrappers, imports, warnings };
|
|
246
545
|
}
|
|
247
|
-
|
|
546
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
547
|
+
function isOwnerCandidateNode(node) {
|
|
548
|
+
return (node.type === "FunctionDeclaration" ||
|
|
549
|
+
node.type === "FunctionExpression" ||
|
|
550
|
+
node.type === "ArrowFunctionExpression" ||
|
|
551
|
+
node.type === "ClassDeclaration" ||
|
|
552
|
+
node.type === "ClassExpression");
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Resolve the nearest enclosing component-binding from the owner frame
|
|
556
|
+
* stack. Walks innermost-to-outermost; first match wins. Filters via the
|
|
557
|
+
* local-index so non-exported component-shaped functions don't receive
|
|
558
|
+
* owner attribution (matches the babel walker's predicate behaviour, where
|
|
559
|
+
* `resolveOwnerComponentId` consults the local index's by-path entries).
|
|
560
|
+
*/
|
|
561
|
+
function resolveOwnerFromStack(ownerStack, localIndex, filePath, repoRoot) {
|
|
562
|
+
// localIndex.byPath uses repo-root-relative POSIX keys; filePath is absolute.
|
|
563
|
+
const relPath = isAbsolute(filePath) ? posixPath(relative(repoRoot, filePath)) : filePath;
|
|
564
|
+
const defs = localIndex.byPath.get(relPath) ?? [];
|
|
565
|
+
const knownExportNames = new Set(defs.map((d) => d.exportName));
|
|
566
|
+
const isKnown = (name) => knownExportNames.has(name);
|
|
567
|
+
for (let i = ownerStack.length - 1; i >= 0; i--) {
|
|
568
|
+
const frame = ownerStack[i];
|
|
569
|
+
if (!frame)
|
|
570
|
+
continue;
|
|
571
|
+
const binding = bindingForOwnerFrame(frame.node, frame.parent, frame.grand, isKnown);
|
|
572
|
+
if (binding) {
|
|
573
|
+
return {
|
|
574
|
+
kind: "react-component",
|
|
575
|
+
export: binding.name,
|
|
576
|
+
source: { type: "local", filePath: posixPath(filePath) },
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
function bindingForOwnerFrame(node, parent, grand, isKnown) {
|
|
583
|
+
// class Name { … }
|
|
584
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
|
|
585
|
+
const cls = node;
|
|
586
|
+
const id = cls.id;
|
|
587
|
+
if (id && isPascalCase(id.name)) {
|
|
588
|
+
if (isKnown(id.name)) {
|
|
589
|
+
return { name: id.name, isDefault: false };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (parent?.type === "ExportDefaultDeclaration") {
|
|
593
|
+
if (isKnown("default")) {
|
|
594
|
+
return { name: "default", isDefault: true };
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
// function Name() { … }
|
|
600
|
+
if (node.type === "FunctionDeclaration") {
|
|
601
|
+
const fn = node;
|
|
602
|
+
const id = fn.id;
|
|
603
|
+
if (parent?.type === "ExportDefaultDeclaration") {
|
|
604
|
+
const name = id && isPascalCase(id.name) ? id.name : "default";
|
|
605
|
+
if (isKnown(name)) {
|
|
606
|
+
return { name, isDefault: true };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (id && isPascalCase(id.name)) {
|
|
610
|
+
if (isKnown(id.name)) {
|
|
611
|
+
return { name: id.name, isDefault: false };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
// (...) => … / function () { … } (anonymous), examined by its context.
|
|
617
|
+
if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
|
|
618
|
+
return bindingFromAnonymousFunctionParent(parent, grand, isKnown);
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
function bindingFromAnonymousFunctionParent(parent, grand, isKnown) {
|
|
623
|
+
if (!parent)
|
|
624
|
+
return null;
|
|
625
|
+
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") {
|
|
626
|
+
return bindingFromVariableId(parent.id.name, isKnown);
|
|
627
|
+
}
|
|
628
|
+
if (parent.type === "CallExpression" && isHocCallOxc(parent)) {
|
|
629
|
+
if (!grand)
|
|
630
|
+
return null;
|
|
631
|
+
if (grand.type === "VariableDeclarator" && grand.id.type === "Identifier") {
|
|
632
|
+
return bindingFromVariableId(grand.id.name, isKnown);
|
|
633
|
+
}
|
|
634
|
+
if (grand.type === "ExportDefaultDeclaration") {
|
|
635
|
+
if (isKnown("default")) {
|
|
636
|
+
return { name: "default", isDefault: true };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
if (parent.type === "ExportDefaultDeclaration") {
|
|
642
|
+
if (isKnown("default")) {
|
|
643
|
+
return { name: "default", isDefault: true };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
function bindingFromVariableId(name, isKnown) {
|
|
649
|
+
if (!isPascalCase(name))
|
|
650
|
+
return null;
|
|
651
|
+
if (!isKnown(name))
|
|
652
|
+
return null;
|
|
653
|
+
return { name, isDefault: false };
|
|
654
|
+
}
|
|
655
|
+
function isHocCallOxc(call) {
|
|
656
|
+
if (call.type !== "CallExpression")
|
|
657
|
+
return false;
|
|
658
|
+
const callee = call.callee;
|
|
659
|
+
if (callee.type === "Identifier")
|
|
660
|
+
return HOC_NAMES.has(callee.name);
|
|
661
|
+
if (callee.type === "MemberExpression" &&
|
|
662
|
+
callee.object.type === "Identifier" &&
|
|
663
|
+
callee.object.name === "React" &&
|
|
664
|
+
callee.property.type === "Identifier") {
|
|
665
|
+
return HOC_NAMES.has(callee.property.name);
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
function isPascalCase(name) {
|
|
670
|
+
return /^[A-Z]/.test(name);
|
|
671
|
+
}
|
|
672
|
+
function collectLocalVarBinding(node, localBindings) {
|
|
673
|
+
// Detect var bindings whose initializer is a known React-API call:
|
|
674
|
+
// - `const X = createContext(...)` / `React.createContext(...)`
|
|
675
|
+
// - `const X = forwardRef(...)` / `memo(...)` / `lazy(...)` (and React.* variants)
|
|
676
|
+
// Scope-agnostic — any binding to a recognised callee gets tracked. The
|
|
677
|
+
// identity downstream uses `source: { type: "local", filePath }`, so two
|
|
678
|
+
// identically-named bindings in different files do not collide.
|
|
679
|
+
if (!node.init || node.init.type !== "CallExpression")
|
|
680
|
+
return;
|
|
681
|
+
if (node.id.type !== "Identifier")
|
|
682
|
+
return;
|
|
683
|
+
const call = node.init;
|
|
684
|
+
const callee = call.callee;
|
|
685
|
+
const localName = node.id.name;
|
|
686
|
+
const isCreateContext = (callee.type === "Identifier" && callee.name === "createContext") ||
|
|
687
|
+
(callee.type === "MemberExpression" &&
|
|
688
|
+
!callee.computed &&
|
|
689
|
+
callee.property.type === "Identifier" &&
|
|
690
|
+
callee.property.name === "createContext");
|
|
691
|
+
if (isCreateContext) {
|
|
692
|
+
localBindings.set(localName, { kind: "local-context", localName });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const factoryName = callee.type === "Identifier"
|
|
696
|
+
? callee.name
|
|
697
|
+
: callee.type === "MemberExpression" &&
|
|
698
|
+
!callee.computed &&
|
|
699
|
+
callee.property.type === "Identifier"
|
|
700
|
+
? callee.property.name
|
|
701
|
+
: null;
|
|
702
|
+
if (factoryName && LOCAL_COMPONENT_FACTORIES.has(factoryName)) {
|
|
703
|
+
localBindings.set(localName, { kind: "local-component", localName });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function collectImportBindings(decl, opts, localBindings, unmappedImports, imports, source) {
|
|
707
|
+
const specifier = decl.source.value;
|
|
708
|
+
if (decl.specifiers.length === 0) {
|
|
709
|
+
// Side-effect import: `import "pkg"`. Prime the resolver's leaf cache
|
|
710
|
+
// so later HTML / Lit-template tag references find the leaf's CEM
|
|
711
|
+
// even though no JS-side identifier was ever bound. The empty
|
|
712
|
+
// exportName won't match anything in matchInLeaf, but the call still
|
|
713
|
+
// walks into the leaf and populates byTag for tag-only consumers.
|
|
714
|
+
opts.resolveLazyManifest(opts.file, specifier, "");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
for (const spec of decl.specifiers) {
|
|
718
|
+
if (spec.type === "ImportNamespaceSpecifier") {
|
|
719
|
+
// Namespace imports skip the lazy resolver — there's no specific
|
|
720
|
+
// exportName to resolve until a compound JSX usage picks one out.
|
|
721
|
+
// Record the binding so the compound branch in `resolveOpening` can
|
|
722
|
+
// emit the tail as the export name. `package` is the user-written
|
|
723
|
+
// specifier; aggregator-following for namespace imports would
|
|
724
|
+
// require a separate resolver pass and is deferred.
|
|
725
|
+
const nsSpec = spec;
|
|
726
|
+
const local = nsSpec.local.name;
|
|
727
|
+
localBindings.set(local, {
|
|
728
|
+
kind: "react-namespace",
|
|
729
|
+
package: specifier,
|
|
730
|
+
specifier,
|
|
731
|
+
importName: local,
|
|
732
|
+
});
|
|
733
|
+
imports.push({ file: posixPath(opts.file), imported: local, source: specifier, loc: locOf(spec, opts.file, source) });
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier")
|
|
737
|
+
continue;
|
|
738
|
+
const importSpec = spec;
|
|
739
|
+
const imported = spec.type === "ImportSpecifier" ? importedName(spec.imported) : "default";
|
|
740
|
+
const local = importSpec.local.name;
|
|
741
|
+
const hit = opts.resolveLazyManifest(opts.file, specifier, imported);
|
|
742
|
+
if (hit?.matched?.kind === "custom-element") {
|
|
743
|
+
localBindings.set(local, {
|
|
744
|
+
kind: "wc-wrapper",
|
|
745
|
+
tagName: asTagName(hit.matched.tagName),
|
|
746
|
+
package: hit.leafPackage,
|
|
747
|
+
specifier,
|
|
748
|
+
importName: imported,
|
|
749
|
+
modulePath: hit.modulePath,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
else if (hit?.matched?.kind === "react-component") {
|
|
753
|
+
localBindings.set(local, {
|
|
754
|
+
kind: "react-direct",
|
|
755
|
+
package: hit.leafPackage,
|
|
756
|
+
export: hit.matched.export,
|
|
757
|
+
specifier,
|
|
758
|
+
importName: imported,
|
|
759
|
+
modulePath: hit.modulePath,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
else if (hit) {
|
|
763
|
+
// Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
|
|
764
|
+
unmappedImports.set(local, {
|
|
765
|
+
importedName: imported,
|
|
766
|
+
specifier,
|
|
767
|
+
leafPackage: hit.leafPackage,
|
|
768
|
+
modulePath: hit.modulePath,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
// Specifier did not resolve (could be local relative or unresolvable).
|
|
773
|
+
unmappedImports.set(local, {
|
|
774
|
+
importedName: imported,
|
|
775
|
+
specifier,
|
|
776
|
+
leafPackage: null,
|
|
777
|
+
modulePath: null,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
imports.push({ file: posixPath(opts.file), imported: local, source: specifier, loc: locOf(spec, opts.file, source) });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function wrapperCandidate(node, parent, program, parentOf) {
|
|
784
|
+
// Function / class declarations with a PascalCase id, or an
|
|
785
|
+
// arrow/function-expr assigned to a PascalCase var. Anonymous bindings
|
|
786
|
+
// outside those patterns aren't tracked — they can't have an exportable
|
|
787
|
+
// wrapper name.
|
|
788
|
+
let name = null;
|
|
789
|
+
if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
|
|
790
|
+
const id = node.id;
|
|
791
|
+
if (id && isPascalCase(id.name))
|
|
792
|
+
name = id.name;
|
|
793
|
+
}
|
|
794
|
+
else if ((node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") &&
|
|
795
|
+
parent?.type === "VariableDeclarator" &&
|
|
796
|
+
parent.id.type === "Identifier" &&
|
|
797
|
+
isPascalCase(parent.id.name)) {
|
|
798
|
+
name = parent.id.name;
|
|
799
|
+
}
|
|
800
|
+
if (name === null)
|
|
801
|
+
return null;
|
|
802
|
+
return {
|
|
803
|
+
node,
|
|
804
|
+
name,
|
|
805
|
+
exportName: detectExportName(parent, program, parentOf, name),
|
|
806
|
+
deps: [],
|
|
807
|
+
seen: new Set(),
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
function detectExportName(parent, program, parentOf, declName) {
|
|
811
|
+
// Direct containment: walk up via the pre-built ancestor map. oxc does
|
|
812
|
+
// not auto-populate node.parent, so we thread parentOf through here.
|
|
813
|
+
for (let p = parent; p; p = parentOf(p)) {
|
|
814
|
+
if (p.type === "ExportDefaultDeclaration")
|
|
815
|
+
return "default";
|
|
816
|
+
if (p.type === "ExportNamedDeclaration")
|
|
817
|
+
return declName;
|
|
818
|
+
}
|
|
819
|
+
// Indirect: a separate `export default Foo;` or `export { Foo as Bar };`
|
|
820
|
+
// references this declaration by name. Scan the top-level program body.
|
|
821
|
+
for (const stmt of program.body) {
|
|
822
|
+
if (stmt.type === "ExportDefaultDeclaration" &&
|
|
823
|
+
stmt.declaration.type === "Identifier" &&
|
|
824
|
+
stmt.declaration.name === declName) {
|
|
825
|
+
return "default";
|
|
826
|
+
}
|
|
827
|
+
if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
|
|
828
|
+
for (const spec of stmt.specifiers) {
|
|
829
|
+
if (spec.type !== "ExportSpecifier")
|
|
830
|
+
continue;
|
|
831
|
+
if (spec.local.type !== "Identifier" || spec.local.name !== declName)
|
|
832
|
+
continue;
|
|
833
|
+
if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
|
|
834
|
+
return "default";
|
|
835
|
+
}
|
|
836
|
+
if (spec.exported.type === "Identifier")
|
|
837
|
+
return spec.exported.name;
|
|
838
|
+
return declName;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return declName;
|
|
843
|
+
}
|
|
844
|
+
function collectWrapperDep(opening, localBindings, lookupByTag, frame) {
|
|
845
|
+
const n = opening.name;
|
|
846
|
+
function addDep(cid) {
|
|
847
|
+
const key = serialiseComponentId(cid);
|
|
848
|
+
if (!frame.seen.has(key)) {
|
|
849
|
+
frame.seen.add(key);
|
|
850
|
+
frame.deps.push(cid);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (n.type === "JSXMemberExpression") {
|
|
854
|
+
const chain = readMemberChain(n);
|
|
855
|
+
if (!chain)
|
|
856
|
+
return;
|
|
857
|
+
if (/^[a-z]/.test(chain.rootName))
|
|
858
|
+
return;
|
|
859
|
+
const bound = localBindings.get(chain.rootName);
|
|
860
|
+
if (!bound)
|
|
861
|
+
return;
|
|
862
|
+
// Locally-declared bindings (Context, var-bound components) are not
|
|
863
|
+
// external component deps the wrapper "wraps". Skip.
|
|
864
|
+
if (bound.kind === "local-context" || bound.kind === "local-component")
|
|
865
|
+
return;
|
|
866
|
+
// Namespace imports: tail is the package's actual export — strip the
|
|
867
|
+
// local namespace alias so the wrapper dep records the same export
|
|
868
|
+
// identity downstream consumers see for non-namespace access patterns.
|
|
869
|
+
// Namespace bindings have no per-export resolver hit, so modulePath stays
|
|
870
|
+
// unset on the wrapper-dep identity.
|
|
871
|
+
const exportName = bound.kind === "react-namespace"
|
|
872
|
+
? chain.compoundName.slice(chain.rootName.length + 1)
|
|
873
|
+
: chain.compoundName;
|
|
874
|
+
const modulePath = bound.kind === "react-namespace" ? null : bound.modulePath;
|
|
875
|
+
addDep({
|
|
876
|
+
kind: "react-component",
|
|
877
|
+
export: exportName,
|
|
878
|
+
source: {
|
|
879
|
+
type: "external",
|
|
880
|
+
package: bound.package,
|
|
881
|
+
...(modulePath !== null ? { modulePath } : {}),
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (n.type !== "JSXIdentifier")
|
|
887
|
+
return;
|
|
888
|
+
const id = n.name;
|
|
889
|
+
if (/^[a-z]/.test(id) && id.includes("-")) {
|
|
890
|
+
const indexed = lookupByTag(asTagName(id));
|
|
891
|
+
if (indexed) {
|
|
892
|
+
addDep({
|
|
893
|
+
kind: "custom-element",
|
|
894
|
+
tagName: asTagName(id),
|
|
895
|
+
source: { type: "external", package: externalPackageOf(indexed.source) },
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const bound = localBindings.get(id);
|
|
901
|
+
// Only WC-wrapper bindings count as wrapper component-deps; React-direct
|
|
902
|
+
// bindings refer to other React components, which are not WC-wrappers.
|
|
903
|
+
if (bound && bound.kind === "wc-wrapper") {
|
|
904
|
+
addDep({
|
|
905
|
+
kind: "custom-element",
|
|
906
|
+
tagName: bound.tagName,
|
|
907
|
+
source: { type: "external", package: bound.package },
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function wrapperId(name) {
|
|
912
|
+
const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
913
|
+
return asWrapperId(`wrap-${kebab}`);
|
|
914
|
+
}
|
|
915
|
+
// ── Position / attribute helpers ────────────────────────────────────────
|
|
916
|
+
function locOf(node, file, source) {
|
|
917
|
+
const start = node.start;
|
|
918
|
+
if (typeof start !== "number") {
|
|
919
|
+
return { file: posixPath(file), line: 1, column: 1 };
|
|
920
|
+
}
|
|
921
|
+
const pos = positionAt(source, start);
|
|
248
922
|
return {
|
|
249
923
|
file: posixPath(file),
|
|
250
|
-
line:
|
|
251
|
-
|
|
924
|
+
line: pos.line,
|
|
925
|
+
// babel emits 1-indexed columns at the call sites that read `loc.column`,
|
|
926
|
+
// matching the visible offset. positionAt returns 0-indexed columns; +1
|
|
927
|
+
// aligns with the historical babel output the artifact expects.
|
|
928
|
+
column: pos.column + 1,
|
|
252
929
|
};
|
|
253
930
|
}
|
|
254
|
-
function
|
|
255
|
-
|
|
931
|
+
function importedName(node) {
|
|
932
|
+
if (node.type === "Identifier")
|
|
933
|
+
return node.name ?? "";
|
|
934
|
+
// StringLiteral path for `import { "weird name" as X }`.
|
|
935
|
+
return node.value ?? "";
|
|
256
936
|
}
|
|
257
937
|
function readJsxAttrs(node, captureValues) {
|
|
258
938
|
const props = [];
|
|
@@ -269,7 +949,9 @@ function readJsxAttrs(node, captureValues) {
|
|
|
269
949
|
}
|
|
270
950
|
if (attr.type !== "JSXAttribute")
|
|
271
951
|
continue;
|
|
272
|
-
const
|
|
952
|
+
const jsxAttr = attr;
|
|
953
|
+
const nameNode = jsxAttr.name;
|
|
954
|
+
const name = nameNode.type === "JSXIdentifier" ? nameNode.name : "";
|
|
273
955
|
if (!name)
|
|
274
956
|
continue;
|
|
275
957
|
// React event handlers are conventionally `on<Event>` props on the JSX side.
|
|
@@ -277,7 +959,7 @@ function readJsxAttrs(node, captureValues) {
|
|
|
277
959
|
events.push(name.slice(2).toLowerCase());
|
|
278
960
|
continue;
|
|
279
961
|
}
|
|
280
|
-
if (!
|
|
962
|
+
if (!jsxAttr.value) {
|
|
281
963
|
// Boolean shorthand `<Foo bar />` → literal `true`.
|
|
282
964
|
const usage = { name, isDynamic: false };
|
|
283
965
|
if (captureValues)
|
|
@@ -285,177 +967,75 @@ function readJsxAttrs(node, captureValues) {
|
|
|
285
967
|
props.push(usage);
|
|
286
968
|
continue;
|
|
287
969
|
}
|
|
288
|
-
|
|
970
|
+
// oxc emits `Literal` for string/number/boolean (ESTree shape); babel
|
|
971
|
+
// emitted distinct `StringLiteral` etc. Discriminate by `value` typeof.
|
|
972
|
+
if (jsxAttr.value.type === "Literal" && typeof jsxAttr.value.value === "string") {
|
|
289
973
|
const usage = { name, isDynamic: false };
|
|
290
974
|
if (captureValues)
|
|
291
|
-
usage.literalValue =
|
|
975
|
+
usage.literalValue = jsxAttr.value.value;
|
|
292
976
|
props.push(usage);
|
|
293
977
|
continue;
|
|
294
978
|
}
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
979
|
+
if (jsxAttr.value.type === "JSXExpressionContainer") {
|
|
980
|
+
// JSXExpressionContainer.expression is JSXExpression = JSXEmptyExpression | Expression
|
|
981
|
+
// — widen to Node for the unwrap step so the JSXEmptyExpression branch
|
|
982
|
+
// below is reachable (oxc's Expression union excludes JSXEmptyExpression).
|
|
983
|
+
const expr = unwrapParens(jsxAttr.value.expression);
|
|
984
|
+
// Empty expression containers (`{}`) are ignored.
|
|
985
|
+
if (expr.type === "JSXEmptyExpression")
|
|
986
|
+
continue;
|
|
987
|
+
if (expr.type === "Literal") {
|
|
988
|
+
const litVal = expr.value;
|
|
989
|
+
const kind = typeof litVal;
|
|
990
|
+
if (kind === "string" || kind === "number" || kind === "boolean") {
|
|
991
|
+
const usage = { name, isDynamic: false };
|
|
992
|
+
if (captureValues)
|
|
993
|
+
usage.literalValue = litVal;
|
|
994
|
+
props.push(usage);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
// null / bigint / regex literals: treat as dynamic-expr.
|
|
998
|
+
props.push({ name, isDynamic: true, dynamicKind: "expr" });
|
|
999
|
+
continue;
|
|
314
1000
|
}
|
|
315
|
-
|
|
1001
|
+
if (expr.type === "Identifier") {
|
|
316
1002
|
props.push({ name, isDynamic: true, dynamicKind: "identifier" });
|
|
1003
|
+
continue;
|
|
317
1004
|
}
|
|
318
|
-
|
|
319
|
-
props.push({ name, isDynamic: true, dynamicKind: "expr" });
|
|
320
|
-
}
|
|
1005
|
+
props.push({ name, isDynamic: true, dynamicKind: "expr" });
|
|
321
1006
|
}
|
|
322
1007
|
}
|
|
323
1008
|
return { props, events };
|
|
324
1009
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
path.traverse({
|
|
341
|
-
JSXOpeningElement(inner) {
|
|
342
|
-
const n = inner.node.name;
|
|
343
|
-
if (n.type !== "JSXIdentifier")
|
|
344
|
-
return;
|
|
345
|
-
const id = n.name;
|
|
346
|
-
if (/^[a-z]/.test(id) && id.includes("-")) {
|
|
347
|
-
const indexed = lookupByTag(asTagName(id));
|
|
348
|
-
if (indexed) {
|
|
349
|
-
addDep({
|
|
350
|
-
kind: "custom-element",
|
|
351
|
-
tagName: asTagName(id),
|
|
352
|
-
source: { type: "external", package: externalPackageOf(indexed.source) },
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const bound = localBindings.get(id);
|
|
358
|
-
// Only WC-wrapper bindings count as wrapper component-deps; React-direct
|
|
359
|
-
// bindings refer to other React components, which are not WC-wrappers.
|
|
360
|
-
if (bound && bound.kind === "wc-wrapper") {
|
|
361
|
-
addDep({
|
|
362
|
-
kind: "custom-element",
|
|
363
|
-
tagName: bound.tagName,
|
|
364
|
-
source: { type: "external", package: bound.package },
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
if (deps.length === 0)
|
|
370
|
-
return;
|
|
371
|
-
const id = wrapperId(name);
|
|
372
|
-
wrappers.push({
|
|
373
|
-
id,
|
|
374
|
-
name,
|
|
375
|
-
file: posixPath(file),
|
|
376
|
-
exportName: detectExportName(path, name),
|
|
377
|
-
componentDeps: deps,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
function wrapperName(path) {
|
|
381
|
-
const node = path.node;
|
|
382
|
-
if (node.type === "FunctionDeclaration" && node.id)
|
|
383
|
-
return node.id.name;
|
|
384
|
-
if (node.type === "ClassDeclaration" && node.id)
|
|
385
|
-
return node.id.name;
|
|
386
|
-
if (path.parent.type === "VariableDeclarator" && path.parent.id.type === "Identifier") {
|
|
387
|
-
return path.parent.id.name;
|
|
388
|
-
}
|
|
389
|
-
return undefined;
|
|
390
|
-
}
|
|
391
|
-
function detectExportName(path, declName) {
|
|
392
|
-
// Direct containment: declaration is itself part of an export statement.
|
|
393
|
-
let p = path;
|
|
394
|
-
while (p) {
|
|
395
|
-
if (p.parent && p.parent.type === "ExportDefaultDeclaration")
|
|
396
|
-
return "default";
|
|
397
|
-
if (p.parent && p.parent.type === "ExportNamedDeclaration")
|
|
398
|
-
return declName;
|
|
399
|
-
p = p.parentPath;
|
|
400
|
-
}
|
|
401
|
-
// Indirect: a separate `export default Foo;` or `export { Foo as Bar };` references
|
|
402
|
-
// this declaration by name. Scan the top-level program body.
|
|
403
|
-
const program = path.findParent((parent) => parent.isProgram());
|
|
404
|
-
if (program?.isProgram()) {
|
|
405
|
-
for (const stmt of program.node.body) {
|
|
406
|
-
if (stmt.type === "ExportDefaultDeclaration" &&
|
|
407
|
-
stmt.declaration.type === "Identifier" &&
|
|
408
|
-
stmt.declaration.name === declName) {
|
|
409
|
-
return "default";
|
|
410
|
-
}
|
|
411
|
-
if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
|
|
412
|
-
for (const spec of stmt.specifiers) {
|
|
413
|
-
if (spec.type !== "ExportSpecifier")
|
|
414
|
-
continue;
|
|
415
|
-
if (spec.local.name !== declName)
|
|
416
|
-
continue;
|
|
417
|
-
if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
|
|
418
|
-
return "default";
|
|
419
|
-
}
|
|
420
|
-
return declName;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Returns true for specifiers that look like bare npm package names — neither
|
|
1012
|
+
* relative paths (`.`, `..`) nor absolute paths (`/`). Scoped packages
|
|
1013
|
+
* (`@scope/name`) are accepted only when the scope part is non-empty, to
|
|
1014
|
+
* exclude alias conventions like `@/foo` where `@` is not an npm scope.
|
|
1015
|
+
*/
|
|
1016
|
+
function isBarePackageSpecifier(specifier) {
|
|
1017
|
+
if (specifier.startsWith(".") || specifier.startsWith("/"))
|
|
1018
|
+
return false;
|
|
1019
|
+
if (specifier.startsWith("@")) {
|
|
1020
|
+
// Scoped package: require a non-empty scope name before the first "/".
|
|
1021
|
+
const slashIdx = specifier.indexOf("/");
|
|
1022
|
+
// No "/" at all (bare "@foo") or scope part is empty ("@/foo") → not a
|
|
1023
|
+
// valid npm scoped package specifier.
|
|
1024
|
+
return slashIdx > 1; // slashIdx 1 means "@/" — scope is empty
|
|
424
1025
|
}
|
|
425
|
-
return
|
|
426
|
-
}
|
|
427
|
-
function wrapperId(name) {
|
|
428
|
-
const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
429
|
-
return asWrapperId(`wrap-${kebab}`);
|
|
1026
|
+
return true;
|
|
430
1027
|
}
|
|
431
1028
|
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* `{items.map(i => <Card />)}`) or through a prop value (`<Modal title={<H/>}>`),
|
|
437
|
-
* so descendants in that scope must NOT inherit the host as their parent.
|
|
438
|
-
*
|
|
439
|
-
* JSXExpressionContainer alone is transparent — `{cond && <Card />}` and
|
|
440
|
-
* `{a ? <X /> : <Y />}` should keep lexical-parent attribution per
|
|
441
|
-
* §Failure modes' "conditional rendering visible" rule. The function/attribute
|
|
442
|
-
* boundary is what differentiates render-prop from a bare expression escape.
|
|
443
|
-
*
|
|
444
|
-
* (Vue scoped slots are lexical and handled differently in parser-vue.)
|
|
1029
|
+
* oxc preserves `ParenthesizedExpression` as a real AST node where babel
|
|
1030
|
+
* transparently strips them. Anywhere we inspect an expression's shape
|
|
1031
|
+
* (JSX-attribute values, return arguments, etc.) we must recurse through
|
|
1032
|
+
* these wrappers first.
|
|
445
1033
|
*/
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
while (p && p.node !== parentNode) {
|
|
451
|
-
if (p.isArrowFunctionExpression() ||
|
|
452
|
-
p.isFunctionExpression() ||
|
|
453
|
-
p.isFunctionDeclaration() ||
|
|
454
|
-
p.isJSXAttribute()) {
|
|
455
|
-
return true;
|
|
456
|
-
}
|
|
457
|
-
p = p.parentPath;
|
|
1034
|
+
function unwrapParens(node) {
|
|
1035
|
+
let cur = node;
|
|
1036
|
+
while (cur.type === "ParenthesizedExpression") {
|
|
1037
|
+
cur = cur.expression;
|
|
458
1038
|
}
|
|
459
|
-
return
|
|
1039
|
+
return cur;
|
|
460
1040
|
}
|
|
461
1041
|
//# sourceMappingURL=walk-jsx.js.map
|