@component-compass/parser-react 0.0.4 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/walk-jsx.js DELETED
@@ -1,1041 +0,0 @@
1
- import { relative, isAbsolute } from "node:path";
2
- import { walk } from "oxc-walker";
3
- import { serialiseComponentId, externalPackageOf } from "@component-compass/plugin-core";
4
- import { asTagName, asWrapperId } from "@component-compass/plugin-core";
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,
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;
171
- const occurrences = [];
172
- const wrappers = [];
173
- const imports = [];
174
- const warnings = [];
175
- const localBindings = new Map();
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.
184
- const unmappedImports = new Map();
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);
193
- return;
194
- }
195
- if (node.type === "ImportDeclaration") {
196
- collectImportBindings(node, opts, localBindings, unmappedImports, imports, source);
197
- }
198
- },
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);
214
- }
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,
227
- });
228
- }
229
- }
230
- },
231
- });
232
- /** Topmost element is the current tracked parent. node is kept for matched
233
- * pop on exit (depth-first traversal guarantees the topmost stack entry is
234
- * the current node when its exit handler fires). */
235
- const parentStack = [];
236
- const renderPropStack = [];
237
- const ownerStack = [];
238
- function resolveOpening(opening) {
239
- const nameNode = opening.name;
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
- });
342
- return undefined;
343
- }
344
- const id = nameNode.name;
345
- if (/^[a-z]/.test(id) && id.includes("-")) {
346
- const indexed = opts.lookupByTag(asTagName(id));
347
- if (indexed) {
348
- return { kind: "wc-tag", tagName: asTagName(id), package: externalPackageOf(indexed.source) };
349
- }
350
- return undefined;
351
- }
352
- const bound = localBindings.get(id);
353
- if (bound?.kind === "wc-wrapper") {
354
- return {
355
- kind: "wc-wrapper",
356
- tagName: bound.tagName,
357
- package: bound.package,
358
- specifier: bound.specifier,
359
- importName: bound.importName,
360
- };
361
- }
362
- if (bound?.kind === "react-direct") {
363
- return {
364
- kind: "react-direct",
365
- package: bound.package,
366
- export: bound.export,
367
- specifier: bound.specifier,
368
- importName: bound.importName,
369
- modulePath: bound.modulePath,
370
- };
371
- }
372
- if (bound?.kind === "local-component") {
373
- return { kind: "local-component", export: id };
374
- }
375
- return undefined;
376
- }
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;
424
- }
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);
430
- }
431
- }
432
- if (rootId !== null) {
433
- const unmapped = unmappedImports.get(rootId);
434
- if (unmapped) {
435
- occurrence = emitOpportunistic(unmapped, tail, opening, opts, source);
436
- }
437
- }
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
- };
485
- }
486
- else {
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
- };
502
- }
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();
542
- },
543
- });
544
- return { occurrences, wrappers, imports, warnings };
545
- }
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);
922
- return {
923
- file: posixPath(file),
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,
929
- };
930
- }
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 ?? "";
936
- }
937
- function readJsxAttrs(node, captureValues) {
938
- const props = [];
939
- const events = [];
940
- for (const attr of node.attributes) {
941
- // Spread attributes (`{...rest}`) are recorded as a single sentinel prop
942
- // so consumers can distinguish "we don't know what props were passed" from
943
- // "no props passed". Spec §3.4 — three-state prop tracking.
944
- if (attr.type === "JSXSpreadAttribute") {
945
- if (props.some((p) => p.name === "...rest"))
946
- continue;
947
- props.push({ name: "...rest", isDynamic: true, dynamicKind: "spread" });
948
- continue;
949
- }
950
- if (attr.type !== "JSXAttribute")
951
- continue;
952
- const jsxAttr = attr;
953
- const nameNode = jsxAttr.name;
954
- const name = nameNode.type === "JSXIdentifier" ? nameNode.name : "";
955
- if (!name)
956
- continue;
957
- // React event handlers are conventionally `on<Event>` props on the JSX side.
958
- if (name.startsWith("on") && name.length > 2 && /[A-Z]/.test(name.charAt(2))) {
959
- events.push(name.slice(2).toLowerCase());
960
- continue;
961
- }
962
- if (!jsxAttr.value) {
963
- // Boolean shorthand `<Foo bar />` → literal `true`.
964
- const usage = { name, isDynamic: false };
965
- if (captureValues)
966
- usage.literalValue = true;
967
- props.push(usage);
968
- continue;
969
- }
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") {
973
- const usage = { name, isDynamic: false };
974
- if (captureValues)
975
- usage.literalValue = jsxAttr.value.value;
976
- props.push(usage);
977
- continue;
978
- }
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;
1000
- }
1001
- if (expr.type === "Identifier") {
1002
- props.push({ name, isDynamic: true, dynamicKind: "identifier" });
1003
- continue;
1004
- }
1005
- props.push({ name, isDynamic: true, dynamicKind: "expr" });
1006
- }
1007
- }
1008
- return { props, events };
1009
- }
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
1025
- }
1026
- return true;
1027
- }
1028
- /**
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.
1033
- */
1034
- function unwrapParens(node) {
1035
- let cur = node;
1036
- while (cur.type === "ParenthesizedExpression") {
1037
- cur = cur.expression;
1038
- }
1039
- return cur;
1040
- }
1041
- //# sourceMappingURL=walk-jsx.js.map