@component-compass/parser-react 0.0.3 → 0.0.5
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 +7 -0
- package/dist/index.js.map +1 -1
- package/dist/walk-jsx.d.ts +2 -0
- package/dist/walk-jsx.js +808 -557
- package/dist/walk-jsx.js.map +1 -1
- package/package.json +6 -6
package/dist/walk-jsx.js
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { relative, isAbsolute } from "node:path";
|
|
2
|
+
import { walk } from "oxc-walker";
|
|
3
3
|
import { serialiseComponentId, externalPackageOf } from "@component-compass/plugin-core";
|
|
4
4
|
import { asTagName, asWrapperId } from "@component-compass/plugin-core";
|
|
5
|
-
import {
|
|
6
|
-
function resolveOwnerComponentId(path, filePath, localIndex, repoRoot) {
|
|
7
|
-
// localIndex.byPath uses repo-root-relative POSIX keys; filePath is absolute.
|
|
8
|
-
const relPath = isAbsolute(filePath) ? posixPath(relative(repoRoot, filePath)) : filePath;
|
|
9
|
-
const defs = localIndex.byPath.get(relPath) ?? [];
|
|
10
|
-
const knownExportNames = new Set(defs.map((d) => d.exportName));
|
|
11
|
-
const binding = findEnclosingComponentBinding(path, (name) => knownExportNames.has(name));
|
|
12
|
-
if (!binding)
|
|
13
|
-
return undefined;
|
|
14
|
-
return {
|
|
15
|
-
kind: "react-component",
|
|
16
|
-
export: binding.name,
|
|
17
|
-
source: { type: "local", filePath: posixPath(filePath) },
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
function withOwner(owner) {
|
|
21
|
-
return owner === undefined ? {} : { ownerComponentId: owner };
|
|
22
|
-
}
|
|
5
|
+
import { posixPath, classifyImportOrigin, positionAt } from "@component-compass/ast-utils";
|
|
23
6
|
// React-API factories whose return value is conventionally a component.
|
|
24
7
|
// Detected by callee identifier name OR `React.<name>` member-expression.
|
|
25
8
|
// Detection is callee-name only — we don't try to verify the factory's
|
|
26
9
|
// return shape.
|
|
27
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"]);
|
|
28
13
|
/**
|
|
29
14
|
* Walk a JSXMemberExpression chain to its root identifier, returning the
|
|
30
15
|
* flat dotted name (e.g. "Tabs.Trigger.Inner") and the root identifier name.
|
|
@@ -42,167 +27,218 @@ function readMemberChain(node) {
|
|
|
42
27
|
parts.unshift(current.name);
|
|
43
28
|
return { compoundName: parts.join("."), rootName: current.name };
|
|
44
29
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
58
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;
|
|
59
171
|
const occurrences = [];
|
|
60
172
|
const wrappers = [];
|
|
61
173
|
const imports = [];
|
|
62
174
|
const warnings = [];
|
|
63
|
-
const recovered = ast.errors ?? [];
|
|
64
|
-
for (const err of recovered) {
|
|
65
|
-
warnings.push({
|
|
66
|
-
code: "PARSE_FAILURE",
|
|
67
|
-
message: `Recovered parse error: ${err.message}`,
|
|
68
|
-
file: opts.file,
|
|
69
|
-
// line: 0 means "unknown" when Babel didn't supply a location.
|
|
70
|
-
line: typeof err.loc?.line === "number" ? err.loc.line : 0,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
175
|
const localBindings = new Map();
|
|
74
|
-
// Tracks imports that did NOT match a manifest entry: local → { importedName,
|
|
75
|
-
// `leafPackage` is non-null when the
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// 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.
|
|
79
184
|
const unmappedImports = new Map();
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// wrapper
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// - `const X = forwardRef(...)` / `memo(...)` / `lazy(...)` (and React.* variants)
|
|
89
|
-
// Scope-agnostic — any binding to a recognised callee gets tracked. The
|
|
90
|
-
// identity downstream uses `source: { type: "local", filePath }`, so two
|
|
91
|
-
// identically-named bindings in different files do not collide.
|
|
92
|
-
if (path.node.init?.type !== "CallExpression")
|
|
93
|
-
return;
|
|
94
|
-
if (path.node.id.type !== "Identifier")
|
|
95
|
-
return;
|
|
96
|
-
const callee = path.node.init.callee;
|
|
97
|
-
const localName = path.node.id.name;
|
|
98
|
-
const isCreateContext = (callee.type === "Identifier" && callee.name === "createContext") ||
|
|
99
|
-
(callee.type === "MemberExpression" &&
|
|
100
|
-
!callee.computed &&
|
|
101
|
-
callee.property.type === "Identifier" &&
|
|
102
|
-
callee.property.name === "createContext");
|
|
103
|
-
if (isCreateContext) {
|
|
104
|
-
localBindings.set(localName, { kind: "local-context", localName });
|
|
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);
|
|
105
193
|
return;
|
|
106
194
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
: callee.type === "MemberExpression" &&
|
|
110
|
-
!callee.computed &&
|
|
111
|
-
callee.property.type === "Identifier"
|
|
112
|
-
? callee.property.name
|
|
113
|
-
: null;
|
|
114
|
-
if (factoryName && LOCAL_COMPONENT_FACTORIES.has(factoryName)) {
|
|
115
|
-
localBindings.set(localName, { kind: "local-component", localName });
|
|
195
|
+
if (node.type === "ImportDeclaration") {
|
|
196
|
+
collectImportBindings(node, opts, localBindings, unmappedImports, imports, source);
|
|
116
197
|
}
|
|
117
198
|
},
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
199
|
+
});
|
|
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);
|
|
128
214
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
specifier: source,
|
|
142
|
-
importName: local,
|
|
143
|
-
});
|
|
144
|
-
imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier")
|
|
148
|
-
continue;
|
|
149
|
-
const imported = spec.type === "ImportSpecifier" ? identName(spec.imported) : "default";
|
|
150
|
-
const local = spec.local.name;
|
|
151
|
-
const hit = opts.resolveLazyManifest(opts.file, source, imported);
|
|
152
|
-
if (hit?.matched?.kind === "custom-element") {
|
|
153
|
-
localBindings.set(local, {
|
|
154
|
-
kind: "wc-wrapper",
|
|
155
|
-
tagName: asTagName(hit.matched.tagName),
|
|
156
|
-
package: hit.leafPackage,
|
|
157
|
-
specifier: source,
|
|
158
|
-
importName: imported,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
else if (hit?.matched?.kind === "react-component") {
|
|
162
|
-
localBindings.set(local, {
|
|
163
|
-
kind: "react-direct",
|
|
164
|
-
package: hit.leafPackage,
|
|
165
|
-
export: hit.matched.export,
|
|
166
|
-
specifier: source,
|
|
167
|
-
importName: imported,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
else if (hit) {
|
|
171
|
-
// Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
|
|
172
|
-
unmappedImports.set(local, {
|
|
173
|
-
importedName: imported,
|
|
174
|
-
specifier: source,
|
|
175
|
-
leafPackage: hit.leafPackage,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
// Specifier did not resolve (could be local relative or unresolvable).
|
|
180
|
-
unmappedImports.set(local, {
|
|
181
|
-
importedName: imported,
|
|
182
|
-
specifier: source,
|
|
183
|
-
leafPackage: null,
|
|
215
|
+
},
|
|
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,
|
|
184
227
|
});
|
|
185
228
|
}
|
|
186
|
-
imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
|
|
187
229
|
}
|
|
188
230
|
},
|
|
189
231
|
});
|
|
190
|
-
// Second pass: detect wrapper definitions.
|
|
191
|
-
traverse(ast, {
|
|
192
|
-
Function(path) {
|
|
193
|
-
detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
|
|
194
|
-
},
|
|
195
|
-
ClassDeclaration(path) {
|
|
196
|
-
detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
232
|
/** Topmost element is the current tracked parent. node is kept for matched
|
|
200
233
|
* pop on exit (depth-first traversal guarantees the topmost stack entry is
|
|
201
234
|
* the current node when its exit handler fires). */
|
|
202
235
|
const parentStack = [];
|
|
236
|
+
const renderPropStack = [];
|
|
237
|
+
const ownerStack = [];
|
|
203
238
|
function resolveOpening(opening) {
|
|
204
239
|
const nameNode = opening.name;
|
|
205
|
-
const
|
|
240
|
+
const start = opening.start;
|
|
241
|
+
const loc = typeof start === "number" ? positionAt(source, start) : { line: 1, column: 0 };
|
|
206
242
|
if (nameNode.type === "JSXMemberExpression") {
|
|
207
243
|
const chain = readMemberChain(nameNode);
|
|
208
244
|
if (!chain) {
|
|
@@ -210,8 +246,8 @@ export function walkJsx(opts) {
|
|
|
210
246
|
code: "jsx-shape-unsupported",
|
|
211
247
|
severity: "warning",
|
|
212
248
|
filePath: opts.file,
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
line: loc.line,
|
|
250
|
+
column: loc.column,
|
|
215
251
|
nameType: "JSXMemberExpression-malformed",
|
|
216
252
|
detail: "member chain did not bottom out at JSXIdentifier",
|
|
217
253
|
});
|
|
@@ -222,8 +258,8 @@ export function walkJsx(opts) {
|
|
|
222
258
|
code: "jsx-shape-unsupported",
|
|
223
259
|
severity: "warning",
|
|
224
260
|
filePath: opts.file,
|
|
225
|
-
|
|
226
|
-
|
|
261
|
+
line: loc.line,
|
|
262
|
+
column: loc.column,
|
|
227
263
|
nameType: "JSXMemberExpression-lowercase-root",
|
|
228
264
|
detail: `lowercase root identifier "${chain.rootName}" cannot be a React component`,
|
|
229
265
|
});
|
|
@@ -254,6 +290,8 @@ export function walkJsx(opts) {
|
|
|
254
290
|
// the tail as the package's actual export. `via.import` (set in the
|
|
255
291
|
// emission step) preserves the local namespace alias so consumers
|
|
256
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.
|
|
257
295
|
const tail = chain.compoundName.slice(chain.rootName.length + 1);
|
|
258
296
|
return {
|
|
259
297
|
kind: "react-direct",
|
|
@@ -261,6 +299,7 @@ export function walkJsx(opts) {
|
|
|
261
299
|
export: tail,
|
|
262
300
|
specifier: bound.specifier,
|
|
263
301
|
importName: bound.importName,
|
|
302
|
+
modulePath: null,
|
|
264
303
|
};
|
|
265
304
|
}
|
|
266
305
|
return {
|
|
@@ -269,6 +308,7 @@ export function walkJsx(opts) {
|
|
|
269
308
|
export: chain.compoundName,
|
|
270
309
|
specifier: bound.specifier,
|
|
271
310
|
importName: bound.importName,
|
|
311
|
+
modulePath: bound.modulePath,
|
|
272
312
|
};
|
|
273
313
|
}
|
|
274
314
|
// Root unbound. If the root matches an unmapped import, skip the
|
|
@@ -281,8 +321,8 @@ export function walkJsx(opts) {
|
|
|
281
321
|
code: "jsx-shape-unsupported",
|
|
282
322
|
severity: "warning",
|
|
283
323
|
filePath: opts.file,
|
|
284
|
-
|
|
285
|
-
|
|
324
|
+
line: loc.line,
|
|
325
|
+
column: loc.column,
|
|
286
326
|
nameType: "JSXMemberExpression-unbound-root",
|
|
287
327
|
detail: `compound JSX with unbound root "${chain.rootName}"`,
|
|
288
328
|
});
|
|
@@ -294,8 +334,8 @@ export function walkJsx(opts) {
|
|
|
294
334
|
code: "jsx-shape-unsupported",
|
|
295
335
|
severity: "warning",
|
|
296
336
|
filePath: opts.file,
|
|
297
|
-
|
|
298
|
-
|
|
337
|
+
line: loc.line,
|
|
338
|
+
column: loc.column,
|
|
299
339
|
nameType: nameNode.type,
|
|
300
340
|
detail: `unhandled JSX element name type: ${nameNode.type}`,
|
|
301
341
|
});
|
|
@@ -326,6 +366,7 @@ export function walkJsx(opts) {
|
|
|
326
366
|
export: bound.export,
|
|
327
367
|
specifier: bound.specifier,
|
|
328
368
|
importName: bound.importName,
|
|
369
|
+
modulePath: bound.modulePath,
|
|
329
370
|
};
|
|
330
371
|
}
|
|
331
372
|
if (bound?.kind === "local-component") {
|
|
@@ -333,229 +374,565 @@ export function walkJsx(opts) {
|
|
|
333
374
|
}
|
|
334
375
|
return undefined;
|
|
335
376
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
else if (nameNode.type === "JSXMemberExpression") {
|
|
390
|
-
// Compound JSX rooted in an unmapped named import (e.g. Base UI primitives,
|
|
391
|
-
// local workspace packages without a CCM, or aliased imports). Walks the
|
|
392
|
-
// member chain, then mirrors the single-identifier path: leafPackage →
|
|
393
|
-
// opaque external; else classifyImportOrigin → local-source match.
|
|
394
|
-
const chain = readMemberChain(nameNode);
|
|
395
|
-
if (chain && !/^[a-z]/.test(chain.rootName)) {
|
|
396
|
-
const unmapped = unmappedImports.get(chain.rootName);
|
|
397
|
-
if (unmapped) {
|
|
398
|
-
// Tail = the chain after the root identifier ("Root" for
|
|
399
|
-
// <ComboboxPrimitive.Root />, "Foo.Bar" for <Outer.Foo.Bar />).
|
|
400
|
-
const tail = chain.compoundName.slice(chain.rootName.length + 1);
|
|
401
|
-
// Package-side compound name: importedName + tail. For renamed
|
|
402
|
-
// imports (`import { Combobox as ComboboxPrimitive }`), this
|
|
403
|
-
// normalises away the local alias so two consumers using
|
|
404
|
-
// different aliases roll up under the same identity.
|
|
405
|
-
//
|
|
406
|
-
// Default-import roots (`import React from "react"` then
|
|
407
|
-
// `<React.Fragment>`) skip the prefix entirely — `default` is
|
|
408
|
-
// the import-spec sentinel, not a meaningful name component.
|
|
409
|
-
// The compound tail (`Fragment`) IS the package's named
|
|
410
|
-
// export. Treats `<React.Fragment>` and `import { Fragment }`
|
|
411
|
-
// as the same identity.
|
|
412
|
-
const compoundExport = unmapped.importedName === "default"
|
|
413
|
-
? tail
|
|
414
|
-
: `${unmapped.importedName}.${tail}`;
|
|
415
|
-
if (unmapped.leafPackage) {
|
|
416
|
-
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
417
|
-
occurrence = {
|
|
418
|
-
componentId: {
|
|
419
|
-
kind: "react-component",
|
|
420
|
-
export: compoundExport,
|
|
421
|
-
source: { type: "external", package: unmapped.leafPackage },
|
|
422
|
-
},
|
|
423
|
-
loc: locOf(opening, opts.file),
|
|
424
|
-
via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
|
|
425
|
-
props,
|
|
426
|
-
events,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
else {
|
|
430
|
-
const origin = classifyImportOrigin(unmapped.specifier, {
|
|
431
|
-
repoRoot: opts.repoRoot,
|
|
432
|
-
fromFile: opts.file,
|
|
433
|
-
});
|
|
434
|
-
if (origin.type === "local") {
|
|
435
|
-
const localDefs = opts.localIndex.byPath.get(origin.filePath);
|
|
436
|
-
const matched = localDefs?.find((d) => unmapped.importedName === "default"
|
|
437
|
-
? d.isDefault
|
|
438
|
-
: d.exportName === unmapped.importedName && !d.isDefault);
|
|
439
|
-
if (matched &&
|
|
440
|
-
matched.componentId.kind === "react-component" &&
|
|
441
|
-
matched.componentId.source.type === "local") {
|
|
442
|
-
const { props, events } = readJsxAttrs(opening, opts.captureValues);
|
|
443
|
-
// Same default-prefix strip as the leafPackage path:
|
|
444
|
-
// a default-exported local accessed as `<Foo.Bar />`
|
|
445
|
-
// emits `Bar`, not `default.Bar`.
|
|
446
|
-
const localCompoundExport = matched.exportName === "default"
|
|
447
|
-
? tail
|
|
448
|
-
: `${matched.exportName}.${tail}`;
|
|
449
|
-
occurrence = {
|
|
450
|
-
componentId: {
|
|
451
|
-
kind: "react-component",
|
|
452
|
-
export: localCompoundExport,
|
|
453
|
-
source: matched.componentId.source,
|
|
454
|
-
},
|
|
455
|
-
loc: locOf(opening, opts.file),
|
|
456
|
-
via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
|
|
457
|
-
props,
|
|
458
|
-
events,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
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;
|
|
466
424
|
}
|
|
467
|
-
else {
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
|
|
473
|
-
loc,
|
|
474
|
-
via: { kind: "html-tag" },
|
|
475
|
-
props,
|
|
476
|
-
events,
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
else if (resolved.kind === "wc-wrapper") {
|
|
480
|
-
occurrence = {
|
|
481
|
-
componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
|
|
482
|
-
loc,
|
|
483
|
-
via: { kind: "react-wrapper", specifier: resolved.specifier, import: resolved.importName },
|
|
484
|
-
props,
|
|
485
|
-
events,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
else if (resolved.kind === "local-context") {
|
|
489
|
-
occurrence = {
|
|
490
|
-
componentId: {
|
|
491
|
-
kind: "react-component",
|
|
492
|
-
export: resolved.export,
|
|
493
|
-
source: { type: "local", filePath: posixPath(opts.file) },
|
|
494
|
-
},
|
|
495
|
-
loc,
|
|
496
|
-
via: { kind: "context-provider" },
|
|
497
|
-
props,
|
|
498
|
-
events,
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
else if (resolved.kind === "local-component") {
|
|
502
|
-
occurrence = {
|
|
503
|
-
componentId: {
|
|
504
|
-
kind: "react-component",
|
|
505
|
-
export: resolved.export,
|
|
506
|
-
source: { type: "local", filePath: posixPath(opts.file) },
|
|
507
|
-
},
|
|
508
|
-
loc,
|
|
509
|
-
via: { kind: "local-component" },
|
|
510
|
-
props,
|
|
511
|
-
events,
|
|
512
|
-
};
|
|
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);
|
|
513
430
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
props,
|
|
520
|
-
events,
|
|
521
|
-
};
|
|
431
|
+
}
|
|
432
|
+
if (rootId !== null) {
|
|
433
|
+
const unmapped = unmappedImports.get(rootId);
|
|
434
|
+
if (unmapped) {
|
|
435
|
+
occurrence = emitOpportunistic(unmapped, tail, opening, opts, source);
|
|
522
436
|
}
|
|
523
437
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
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
|
+
};
|
|
530
485
|
}
|
|
531
486
|
else {
|
|
532
|
-
|
|
533
|
-
|
|
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
|
+
};
|
|
534
502
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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();
|
|
546
542
|
},
|
|
547
543
|
});
|
|
548
544
|
return { occurrences, wrappers, imports, warnings };
|
|
549
545
|
}
|
|
550
|
-
|
|
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);
|
|
551
922
|
return {
|
|
552
923
|
file: posixPath(file),
|
|
553
|
-
line:
|
|
554
|
-
|
|
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,
|
|
555
929
|
};
|
|
556
930
|
}
|
|
557
|
-
function
|
|
558
|
-
|
|
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 ?? "";
|
|
559
936
|
}
|
|
560
937
|
function readJsxAttrs(node, captureValues) {
|
|
561
938
|
const props = [];
|
|
@@ -572,7 +949,9 @@ function readJsxAttrs(node, captureValues) {
|
|
|
572
949
|
}
|
|
573
950
|
if (attr.type !== "JSXAttribute")
|
|
574
951
|
continue;
|
|
575
|
-
const
|
|
952
|
+
const jsxAttr = attr;
|
|
953
|
+
const nameNode = jsxAttr.name;
|
|
954
|
+
const name = nameNode.type === "JSXIdentifier" ? nameNode.name : "";
|
|
576
955
|
if (!name)
|
|
577
956
|
continue;
|
|
578
957
|
// React event handlers are conventionally `on<Event>` props on the JSX side.
|
|
@@ -580,7 +959,7 @@ function readJsxAttrs(node, captureValues) {
|
|
|
580
959
|
events.push(name.slice(2).toLowerCase());
|
|
581
960
|
continue;
|
|
582
961
|
}
|
|
583
|
-
if (!
|
|
962
|
+
if (!jsxAttr.value) {
|
|
584
963
|
// Boolean shorthand `<Foo bar />` → literal `true`.
|
|
585
964
|
const usage = { name, isDynamic: false };
|
|
586
965
|
if (captureValues)
|
|
@@ -588,203 +967,75 @@ function readJsxAttrs(node, captureValues) {
|
|
|
588
967
|
props.push(usage);
|
|
589
968
|
continue;
|
|
590
969
|
}
|
|
591
|
-
|
|
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") {
|
|
592
973
|
const usage = { name, isDynamic: false };
|
|
593
974
|
if (captureValues)
|
|
594
|
-
usage.literalValue =
|
|
975
|
+
usage.literalValue = jsxAttr.value.value;
|
|
595
976
|
props.push(usage);
|
|
596
977
|
continue;
|
|
597
978
|
}
|
|
598
|
-
if (
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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;
|
|
617
1000
|
}
|
|
618
|
-
|
|
1001
|
+
if (expr.type === "Identifier") {
|
|
619
1002
|
props.push({ name, isDynamic: true, dynamicKind: "identifier" });
|
|
1003
|
+
continue;
|
|
620
1004
|
}
|
|
621
|
-
|
|
622
|
-
props.push({ name, isDynamic: true, dynamicKind: "expr" });
|
|
623
|
-
}
|
|
1005
|
+
props.push({ name, isDynamic: true, dynamicKind: "expr" });
|
|
624
1006
|
}
|
|
625
1007
|
}
|
|
626
1008
|
return { props, events };
|
|
627
1009
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
path.traverse({
|
|
644
|
-
JSXOpeningElement(inner) {
|
|
645
|
-
const n = inner.node.name;
|
|
646
|
-
if (n.type === "JSXMemberExpression") {
|
|
647
|
-
const chain = readMemberChain(n);
|
|
648
|
-
if (!chain)
|
|
649
|
-
return;
|
|
650
|
-
if (/^[a-z]/.test(chain.rootName))
|
|
651
|
-
return;
|
|
652
|
-
const bound = localBindings.get(chain.rootName);
|
|
653
|
-
if (!bound)
|
|
654
|
-
return;
|
|
655
|
-
// Locally-declared bindings (Context, var-bound components) are not
|
|
656
|
-
// external component deps the wrapper "wraps". Skip.
|
|
657
|
-
if (bound.kind === "local-context" || bound.kind === "local-component")
|
|
658
|
-
return;
|
|
659
|
-
// Namespace imports: tail is the package's actual export — strip the
|
|
660
|
-
// local namespace alias so the wrapper dep records the same export
|
|
661
|
-
// identity downstream consumers see for non-namespace access patterns.
|
|
662
|
-
const exportName = bound.kind === "react-namespace"
|
|
663
|
-
? chain.compoundName.slice(chain.rootName.length + 1)
|
|
664
|
-
: chain.compoundName;
|
|
665
|
-
addDep({
|
|
666
|
-
kind: "react-component",
|
|
667
|
-
export: exportName,
|
|
668
|
-
source: { type: "external", package: bound.package },
|
|
669
|
-
});
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
if (n.type !== "JSXIdentifier")
|
|
673
|
-
return;
|
|
674
|
-
const id = n.name;
|
|
675
|
-
if (/^[a-z]/.test(id) && id.includes("-")) {
|
|
676
|
-
const indexed = lookupByTag(asTagName(id));
|
|
677
|
-
if (indexed) {
|
|
678
|
-
addDep({
|
|
679
|
-
kind: "custom-element",
|
|
680
|
-
tagName: asTagName(id),
|
|
681
|
-
source: { type: "external", package: externalPackageOf(indexed.source) },
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
const bound = localBindings.get(id);
|
|
687
|
-
// Only WC-wrapper bindings count as wrapper component-deps; React-direct
|
|
688
|
-
// bindings refer to other React components, which are not WC-wrappers.
|
|
689
|
-
if (bound && bound.kind === "wc-wrapper") {
|
|
690
|
-
addDep({
|
|
691
|
-
kind: "custom-element",
|
|
692
|
-
tagName: bound.tagName,
|
|
693
|
-
source: { type: "external", package: bound.package },
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
},
|
|
697
|
-
});
|
|
698
|
-
if (deps.length === 0)
|
|
699
|
-
return;
|
|
700
|
-
const id = wrapperId(name);
|
|
701
|
-
wrappers.push({
|
|
702
|
-
id,
|
|
703
|
-
name,
|
|
704
|
-
file: posixPath(file),
|
|
705
|
-
exportName: detectExportName(path, name),
|
|
706
|
-
componentDeps: deps,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
function wrapperName(path) {
|
|
710
|
-
const node = path.node;
|
|
711
|
-
if (node.type === "FunctionDeclaration" && node.id)
|
|
712
|
-
return node.id.name;
|
|
713
|
-
if (node.type === "ClassDeclaration" && node.id)
|
|
714
|
-
return node.id.name;
|
|
715
|
-
if (path.parent.type === "VariableDeclarator" && path.parent.id.type === "Identifier") {
|
|
716
|
-
return path.parent.id.name;
|
|
717
|
-
}
|
|
718
|
-
return undefined;
|
|
719
|
-
}
|
|
720
|
-
function detectExportName(path, declName) {
|
|
721
|
-
// Direct containment: declaration is itself part of an export statement.
|
|
722
|
-
let p = path;
|
|
723
|
-
while (p) {
|
|
724
|
-
if (p.parent && p.parent.type === "ExportDefaultDeclaration")
|
|
725
|
-
return "default";
|
|
726
|
-
if (p.parent && p.parent.type === "ExportNamedDeclaration")
|
|
727
|
-
return declName;
|
|
728
|
-
p = p.parentPath;
|
|
729
|
-
}
|
|
730
|
-
// Indirect: a separate `export default Foo;` or `export { Foo as Bar };` references
|
|
731
|
-
// this declaration by name. Scan the top-level program body.
|
|
732
|
-
const program = path.findParent((parent) => parent.isProgram());
|
|
733
|
-
if (program?.isProgram()) {
|
|
734
|
-
for (const stmt of program.node.body) {
|
|
735
|
-
if (stmt.type === "ExportDefaultDeclaration" &&
|
|
736
|
-
stmt.declaration.type === "Identifier" &&
|
|
737
|
-
stmt.declaration.name === declName) {
|
|
738
|
-
return "default";
|
|
739
|
-
}
|
|
740
|
-
if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
|
|
741
|
-
for (const spec of stmt.specifiers) {
|
|
742
|
-
if (spec.type !== "ExportSpecifier")
|
|
743
|
-
continue;
|
|
744
|
-
if (spec.local.name !== declName)
|
|
745
|
-
continue;
|
|
746
|
-
if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
|
|
747
|
-
return "default";
|
|
748
|
-
}
|
|
749
|
-
return declName;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
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
|
|
753
1025
|
}
|
|
754
|
-
return
|
|
755
|
-
}
|
|
756
|
-
function wrapperId(name) {
|
|
757
|
-
const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
758
|
-
return asWrapperId(`wrap-${kebab}`);
|
|
1026
|
+
return true;
|
|
759
1027
|
}
|
|
760
1028
|
/**
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
* `{items.map(i => <Card />)}`) or through a prop value (`<Modal title={<H/>}>`),
|
|
766
|
-
* so descendants in that scope must NOT inherit the host as their parent.
|
|
767
|
-
*
|
|
768
|
-
* JSXExpressionContainer alone is transparent — `{cond && <Card />}` and
|
|
769
|
-
* `{a ? <X /> : <Y />}` should keep lexical-parent attribution per
|
|
770
|
-
* §Failure modes' "conditional rendering visible" rule. The function/attribute
|
|
771
|
-
* boundary is what differentiates render-prop from a bare expression escape.
|
|
772
|
-
*
|
|
773
|
-
* (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.
|
|
774
1033
|
*/
|
|
775
|
-
function
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
while (p && p.node !== parentNode) {
|
|
780
|
-
if (p.isArrowFunctionExpression() ||
|
|
781
|
-
p.isFunctionExpression() ||
|
|
782
|
-
p.isFunctionDeclaration() ||
|
|
783
|
-
p.isJSXAttribute()) {
|
|
784
|
-
return true;
|
|
785
|
-
}
|
|
786
|
-
p = p.parentPath;
|
|
1034
|
+
function unwrapParens(node) {
|
|
1035
|
+
let cur = node;
|
|
1036
|
+
while (cur.type === "ParenthesizedExpression") {
|
|
1037
|
+
cur = cur.expression;
|
|
787
1038
|
}
|
|
788
|
-
return
|
|
1039
|
+
return cur;
|
|
789
1040
|
}
|
|
790
1041
|
//# sourceMappingURL=walk-jsx.js.map
|