react-spot 0.0.1
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/README.md +115 -0
- package/package.json +42 -0
- package/src/core/chain-transformer.ts +89 -0
- package/src/core/fiber-utils.ts +684 -0
- package/src/core/source-location-resolver.test.ts +415 -0
- package/src/core/source-location-resolver.ts +801 -0
- package/src/core/types.ts +79 -0
- package/src/index.ts +26 -0
- package/src/react/ReactSpot.tsx +1058 -0
- package/src/react/components/ui/index.ts +1 -0
- package/src/react/components/ui/popover.tsx +24 -0
- package/src/transformers/formatted-message.ts +159 -0
- package/src/transformers/transformer-rule.ts +386 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Popover, PopoverTrigger, PopoverContent } from './popover';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
const Popover = PopoverPrimitive.Root;
|
|
5
|
+
|
|
6
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
7
|
+
|
|
8
|
+
const PopoverContent = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
11
|
+
>(({ align = 'center', sideOffset = 4, style, ...props }, ref) => (
|
|
12
|
+
<PopoverPrimitive.Portal>
|
|
13
|
+
<PopoverPrimitive.Content
|
|
14
|
+
ref={ref}
|
|
15
|
+
align={align}
|
|
16
|
+
sideOffset={sideOffset}
|
|
17
|
+
style={{ outline: 'none', ...style }}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
</PopoverPrimitive.Portal>
|
|
21
|
+
));
|
|
22
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
23
|
+
|
|
24
|
+
export { Popover, PopoverTrigger, PopoverContent };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import type { ChainTransformer } from '../core/chain-transformer';
|
|
3
|
+
import type { Fiber } from '../core/types';
|
|
4
|
+
import { TRANSFORMER_PRESETS, createRuleBasedTransformer } from './transformer-rule';
|
|
5
|
+
|
|
6
|
+
// Minimal AST node shape — avoids a hard dependency on @babel/types.
|
|
7
|
+
interface AstNode {
|
|
8
|
+
type: string;
|
|
9
|
+
loc?: { start: { line: number; column: number }; end: { line: number; column: number } };
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Keys that are metadata, not child nodes — skip during traversal. */
|
|
14
|
+
const SKIP_KEYS = new Set([
|
|
15
|
+
'type',
|
|
16
|
+
'loc',
|
|
17
|
+
'start',
|
|
18
|
+
'end',
|
|
19
|
+
'leadingComments',
|
|
20
|
+
'trailingComments',
|
|
21
|
+
'innerComments',
|
|
22
|
+
'extra',
|
|
23
|
+
'range',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Extracts the element name from a JSX name node (`JSXIdentifier` or `JSXMemberExpression`). */
|
|
27
|
+
function getJsxName(node: AstNode): string {
|
|
28
|
+
if (node.type === 'JSXIdentifier') return node.name as string;
|
|
29
|
+
if (node.type === 'JSXMemberExpression') {
|
|
30
|
+
return `${getJsxName(node.object as AstNode)}.${(node.property as AstNode).name}`;
|
|
31
|
+
}
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Locates a JSX prop's value expression in source code using a full
|
|
37
|
+
* TypeScript+JSX AST parse via `@babel/parser`.
|
|
38
|
+
*
|
|
39
|
+
* Returns the 1-based line and 0-based column of the value node
|
|
40
|
+
* (e.g. the opening `"` of a string literal, or `{` of an expression container).
|
|
41
|
+
*
|
|
42
|
+
* When multiple elements with the same name exist in a file, the one closest
|
|
43
|
+
* to `nearLine`/`nearColumn` is chosen.
|
|
44
|
+
*/
|
|
45
|
+
export function findJsxPropValueLocation(
|
|
46
|
+
source: string,
|
|
47
|
+
nearLine: number,
|
|
48
|
+
nearColumn: number,
|
|
49
|
+
elementName: string,
|
|
50
|
+
propName: string
|
|
51
|
+
): { line: number; column: number } | null {
|
|
52
|
+
let ast: AstNode;
|
|
53
|
+
try {
|
|
54
|
+
ast = parse(source, {
|
|
55
|
+
sourceType: 'module',
|
|
56
|
+
plugins: ['jsx', 'typescript', 'decorators'],
|
|
57
|
+
errorRecovery: true,
|
|
58
|
+
}) as unknown as AstNode;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let bestMatch: { line: number; column: number } | null = null;
|
|
64
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
65
|
+
|
|
66
|
+
function visit(node: unknown): void {
|
|
67
|
+
if (!node || typeof node !== 'object') return;
|
|
68
|
+
const n = node as AstNode;
|
|
69
|
+
|
|
70
|
+
if (n.type === 'JSXOpeningElement' && n.loc) {
|
|
71
|
+
const name = getJsxName(n.name as AstNode);
|
|
72
|
+
if (name === elementName) {
|
|
73
|
+
const lineDist = Math.abs(n.loc.start.line - nearLine);
|
|
74
|
+
const colDist = Math.abs(n.loc.start.column - nearColumn);
|
|
75
|
+
const distance = lineDist * 10_000 + colDist;
|
|
76
|
+
|
|
77
|
+
if (distance < bestDistance) {
|
|
78
|
+
const attrs = n.attributes as AstNode[] | undefined;
|
|
79
|
+
if (attrs) {
|
|
80
|
+
for (const attr of attrs) {
|
|
81
|
+
if (
|
|
82
|
+
attr.type === 'JSXAttribute' &&
|
|
83
|
+
(attr.name as AstNode)?.type === 'JSXIdentifier' &&
|
|
84
|
+
(attr.name as AstNode).name === propName &&
|
|
85
|
+
attr.value
|
|
86
|
+
) {
|
|
87
|
+
const valNode = attr.value as AstNode;
|
|
88
|
+
if (valNode.loc) {
|
|
89
|
+
bestMatch = {
|
|
90
|
+
line: valNode.loc.start.line,
|
|
91
|
+
column: valNode.loc.start.column,
|
|
92
|
+
};
|
|
93
|
+
bestDistance = distance;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const key of Object.keys(n)) {
|
|
103
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
104
|
+
const child = n[key];
|
|
105
|
+
if (Array.isArray(child)) {
|
|
106
|
+
for (const item of child) {
|
|
107
|
+
if (item && typeof item === 'object' && (item as AstNode).type) {
|
|
108
|
+
visit(item);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else if (child && typeof child === 'object' && (child as AstNode).type) {
|
|
112
|
+
visit(child);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
visit(ast);
|
|
118
|
+
return bestMatch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Shallow DFS through a fiber's child tree (up to `maxDepth` levels)
|
|
123
|
+
* looking for a fiber that satisfies `predicate`.
|
|
124
|
+
*/
|
|
125
|
+
export function findChildFiber(
|
|
126
|
+
fiber: Fiber,
|
|
127
|
+
predicate: (f: Fiber) => boolean,
|
|
128
|
+
maxDepth = 3
|
|
129
|
+
): Fiber | null {
|
|
130
|
+
const search = (f: Fiber | null | undefined, depth: number): Fiber | null => {
|
|
131
|
+
if (!f || depth > maxDepth) return null;
|
|
132
|
+
if (predicate(f)) return f;
|
|
133
|
+
|
|
134
|
+
const childResult = search(f.child, depth + 1);
|
|
135
|
+
if (childResult) return childResult;
|
|
136
|
+
|
|
137
|
+
return search(f.sibling, depth);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return search(fiber.child, 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Creates a {@link ChainTransformer} that detects `react-intl`'s
|
|
145
|
+
* `<FormattedMessage>` pattern and collapses it into a readable entry.
|
|
146
|
+
*
|
|
147
|
+
* This is a convenience wrapper around {@link createRuleBasedTransformer}
|
|
148
|
+
* using the built-in `react-intl` preset.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```tsx
|
|
152
|
+
* <ReactSpot
|
|
153
|
+
* chainTransformer={createFormattedMessageTransformer()}
|
|
154
|
+
* />
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function createFormattedMessageTransformer(): ChainTransformer {
|
|
158
|
+
return createRuleBasedTransformer([TRANSFORMER_PRESETS['react-intl']]);
|
|
159
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChainTransformContext,
|
|
3
|
+
ChainTransformer,
|
|
4
|
+
TransformedEntry,
|
|
5
|
+
} from '../core/chain-transformer';
|
|
6
|
+
import type { ClickToNodeInfo, Fiber } from '../core/types';
|
|
7
|
+
import { findChildFiber, findJsxPropValueLocation } from './formatted-message';
|
|
8
|
+
|
|
9
|
+
const LOG_PREFIX = '[show-component]';
|
|
10
|
+
|
|
11
|
+
// ─── Rule definition ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface TransformerRule {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
/** Component name to match (e.g. "FormattedMessage", "Trans"). */
|
|
17
|
+
componentName: string;
|
|
18
|
+
/** Prop whose runtime value becomes the display label. */
|
|
19
|
+
labelProp: string;
|
|
20
|
+
/**
|
|
21
|
+
* Prop whose *source-code location* is resolved for editor navigation.
|
|
22
|
+
* Usually the same as {@link labelProp}.
|
|
23
|
+
*/
|
|
24
|
+
navigateToProp: string;
|
|
25
|
+
/**
|
|
26
|
+
* - `childFiber` — search child fibers of native DOM elements for
|
|
27
|
+
* `componentName` (the FormattedMessage / i18n pattern).
|
|
28
|
+
* - `direct` — match entries in the owner chain whose component name
|
|
29
|
+
* equals `componentName`, then relabel + override navigation.
|
|
30
|
+
*/
|
|
31
|
+
matchStrategy: 'childFiber' | 'direct';
|
|
32
|
+
/** Max depth for child-fiber DFS (default 3). Only used with `childFiber`. */
|
|
33
|
+
maxSearchDepth?: number;
|
|
34
|
+
/** Wrap the label string in quotes (default true). */
|
|
35
|
+
labelQuoted?: boolean;
|
|
36
|
+
/** Truncate labels longer than this (default 60). */
|
|
37
|
+
labelMaxLength?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Built-in presets ────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export const TRANSFORMER_PRESETS: Record<string, TransformerRule> = {
|
|
43
|
+
'react-intl': {
|
|
44
|
+
id: 'react-intl',
|
|
45
|
+
name: 'react-intl (FormattedMessage)',
|
|
46
|
+
componentName: 'FormattedMessage',
|
|
47
|
+
labelProp: 'defaultMessage',
|
|
48
|
+
navigateToProp: 'defaultMessage',
|
|
49
|
+
matchStrategy: 'childFiber',
|
|
50
|
+
},
|
|
51
|
+
'react-i18next': {
|
|
52
|
+
id: 'react-i18next',
|
|
53
|
+
name: 'react-i18next (Trans)',
|
|
54
|
+
componentName: 'Trans',
|
|
55
|
+
labelProp: 'defaults',
|
|
56
|
+
navigateToProp: 'defaults',
|
|
57
|
+
matchStrategy: 'childFiber',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─── Name matching helpers ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const WRAPPER_RE = /^(?:Memo|ForwardRef)\((.+)\)$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strips React wrapper prefixes (`Memo(...)`, `ForwardRef(...)`) to recover
|
|
67
|
+
* the base component name as it appears in JSX source code.
|
|
68
|
+
*/
|
|
69
|
+
function unwrapComponentName(name: string): string {
|
|
70
|
+
let n = name;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const m = WRAPPER_RE.exec(n);
|
|
73
|
+
if (!m) return n;
|
|
74
|
+
n = m[1];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns `true` when `actualName` refers to the same component as
|
|
80
|
+
* `targetName`, ignoring `Memo(…)` / `ForwardRef(…)` wrappers on either side.
|
|
81
|
+
*/
|
|
82
|
+
function componentNameMatches(actualName: string, targetName: string): boolean {
|
|
83
|
+
if (actualName === targetName) return true;
|
|
84
|
+
return unwrapComponentName(actualName) === unwrapComponentName(targetName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Generic rule-based engine ───────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Extract a displayable string from a prop value.
|
|
91
|
+
* Handles plain strings and compiled ICU message ASTs
|
|
92
|
+
* (e.g. `[{ type: 0, value: "Hello" }, { type: 1, value: "name" }]`
|
|
93
|
+
* produced by `babel-plugin-formatjs` / `@formatjs/ts-transformer`).
|
|
94
|
+
*/
|
|
95
|
+
function extractStringValue(value: unknown): string | null {
|
|
96
|
+
if (typeof value === 'string') return value.length > 0 ? value : null;
|
|
97
|
+
|
|
98
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
99
|
+
|
|
100
|
+
// Compiled ICU AST: array of parts where type 0 = literal, others = variables
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
for (const part of value) {
|
|
103
|
+
if (part && typeof part === 'object' && 'value' in part && typeof part.value === 'string') {
|
|
104
|
+
// type 0 = literal text, type 1 = argument (variable)
|
|
105
|
+
parts.push(part.type === 0 ? part.value : `{${part.value}}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return parts.length > 0 ? parts.join('') : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildLabel(value: unknown, rule: TransformerRule): string {
|
|
112
|
+
const maxLen = rule.labelMaxLength ?? 60;
|
|
113
|
+
const quoted = rule.labelQuoted ?? true;
|
|
114
|
+
|
|
115
|
+
const text = extractStringValue(value);
|
|
116
|
+
if (!text) return rule.componentName;
|
|
117
|
+
|
|
118
|
+
const display = text.length > maxLen ? `${text.slice(0, maxLen - 3)}...` : text;
|
|
119
|
+
return quoted ? `"${display}"` : display;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildResolveLocation(
|
|
123
|
+
stackFrame: string | undefined,
|
|
124
|
+
rule: TransformerRule,
|
|
125
|
+
ctx: ChainTransformContext
|
|
126
|
+
): (() => Promise<{ source: string; line: number; column: number } | null>) | undefined {
|
|
127
|
+
if (!stackFrame) {
|
|
128
|
+
if (ctx.debug) {
|
|
129
|
+
console.warn(LOG_PREFIX, `no stackFrame for rule "${rule.name}" — resolveLocation disabled`);
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return async () => {
|
|
135
|
+
const resolved = await ctx.resolveLocation(stackFrame);
|
|
136
|
+
if (!resolved) {
|
|
137
|
+
if (ctx.debug) {
|
|
138
|
+
console.warn(LOG_PREFIX, `source resolution returned null for rule "${rule.name}"`, {
|
|
139
|
+
stackFrame,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (resolved.sourceContent) {
|
|
146
|
+
const jsxName = unwrapComponentName(rule.componentName);
|
|
147
|
+
if (ctx.debug) {
|
|
148
|
+
console.log(
|
|
149
|
+
LOG_PREFIX,
|
|
150
|
+
`AST searching for <${jsxName}> prop "${rule.navigateToProp}"`,
|
|
151
|
+
`near ${resolved.source}:${resolved.line}:${resolved.column}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const propLoc = findJsxPropValueLocation(
|
|
155
|
+
resolved.sourceContent,
|
|
156
|
+
resolved.line,
|
|
157
|
+
resolved.column,
|
|
158
|
+
jsxName,
|
|
159
|
+
rule.navigateToProp
|
|
160
|
+
);
|
|
161
|
+
if (propLoc) {
|
|
162
|
+
if (ctx.debug) {
|
|
163
|
+
console.log(
|
|
164
|
+
LOG_PREFIX,
|
|
165
|
+
`found prop value at ${resolved.source}:${propLoc.line}:${propLoc.column}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { source: resolved.source, line: propLoc.line, column: propLoc.column };
|
|
169
|
+
}
|
|
170
|
+
if (ctx.debug) {
|
|
171
|
+
console.warn(
|
|
172
|
+
LOG_PREFIX,
|
|
173
|
+
`prop "${rule.navigateToProp}" not found in AST, falling back to component location`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
} else if (ctx.debug) {
|
|
177
|
+
console.warn(LOG_PREFIX, 'no sourceContent in resolved result — cannot do AST prop lookup');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { source: resolved.source, line: resolved.line, column: resolved.column };
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Try to match a chain entry against a single rule using the `childFiber`
|
|
186
|
+
* strategy. Returns a {@link TransformedEntry} on match, or `null`.
|
|
187
|
+
*/
|
|
188
|
+
function matchChildFiber(
|
|
189
|
+
entry: ClickToNodeInfo,
|
|
190
|
+
rule: TransformerRule,
|
|
191
|
+
ctx: ChainTransformContext
|
|
192
|
+
): TransformedEntry | null {
|
|
193
|
+
if (typeof entry.fiber.type !== 'string') return null;
|
|
194
|
+
|
|
195
|
+
const matched = findChildFiber(
|
|
196
|
+
entry.fiber,
|
|
197
|
+
(f: Fiber) => componentNameMatches(ctx.getComponentName(f), rule.componentName),
|
|
198
|
+
rule.maxSearchDepth ?? 3
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (!matched) {
|
|
202
|
+
if (ctx.debug) {
|
|
203
|
+
console.log(
|
|
204
|
+
LOG_PREFIX,
|
|
205
|
+
`childFiber: no "${rule.componentName}" found under <${entry.fiber.type}>`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const matchedName = ctx.getComponentName(matched);
|
|
212
|
+
const props = matched.memoizedProps as Record<string, unknown> | undefined;
|
|
213
|
+
const labelValue = props?.[rule.labelProp];
|
|
214
|
+
const childFrame = ctx.getStackFrame(matched);
|
|
215
|
+
const stackFrame = childFrame ?? entry.stackFrame;
|
|
216
|
+
|
|
217
|
+
if (ctx.debug) {
|
|
218
|
+
console.log(LOG_PREFIX, `childFiber: matched "${matchedName}" under <${entry.fiber.type}>`, {
|
|
219
|
+
fiber: matched,
|
|
220
|
+
labelProp: rule.labelProp,
|
|
221
|
+
labelValue: labelValue ?? '(missing)',
|
|
222
|
+
stackFrame: childFrame ? 'from child fiber' : 'fallback to parent entry',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const info: ClickToNodeInfo = {
|
|
227
|
+
componentName: rule.componentName,
|
|
228
|
+
stackFrame,
|
|
229
|
+
fiber: matched,
|
|
230
|
+
props,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
label: buildLabel(labelValue, rule),
|
|
235
|
+
sourceEntry: info,
|
|
236
|
+
props,
|
|
237
|
+
resolveLocation: buildResolveLocation(stackFrame, rule, ctx),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Walk the fiber's `_debugOwner` chain looking for a usable stack frame.
|
|
243
|
+
* Stops after `maxDepth` hops to avoid traversing the entire tree.
|
|
244
|
+
*/
|
|
245
|
+
function findOwnerStackFrame(
|
|
246
|
+
fiber: Fiber,
|
|
247
|
+
ctx: ChainTransformContext,
|
|
248
|
+
maxDepth = 3
|
|
249
|
+
): string | undefined {
|
|
250
|
+
let owner = fiber._debugOwner;
|
|
251
|
+
for (let i = 0; i < maxDepth && owner; i++) {
|
|
252
|
+
const frame = ctx.getStackFrame(owner);
|
|
253
|
+
if (frame) {
|
|
254
|
+
if (ctx.debug) {
|
|
255
|
+
console.log(
|
|
256
|
+
LOG_PREFIX,
|
|
257
|
+
`direct: borrowed stackFrame from owner "${ctx.getComponentName(owner)}" (depth ${i + 1})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return frame;
|
|
261
|
+
}
|
|
262
|
+
owner = owner._debugOwner;
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Try to match a chain entry against a single rule using the `direct`
|
|
269
|
+
* strategy. Returns a {@link TransformedEntry} on match, or `null`.
|
|
270
|
+
*/
|
|
271
|
+
function matchDirect(
|
|
272
|
+
entry: ClickToNodeInfo,
|
|
273
|
+
rule: TransformerRule,
|
|
274
|
+
ctx: ChainTransformContext
|
|
275
|
+
): TransformedEntry | null {
|
|
276
|
+
if (!componentNameMatches(entry.componentName, rule.componentName)) return null;
|
|
277
|
+
|
|
278
|
+
const props = entry.props;
|
|
279
|
+
const labelValue = props?.[rule.labelProp];
|
|
280
|
+
const stackFrame = entry.stackFrame ?? findOwnerStackFrame(entry.fiber, ctx);
|
|
281
|
+
|
|
282
|
+
if (ctx.debug) {
|
|
283
|
+
console.log(LOG_PREFIX, `direct: matched "${entry.componentName}" via rule "${rule.name}"`, {
|
|
284
|
+
fiber: entry.fiber,
|
|
285
|
+
labelProp: rule.labelProp,
|
|
286
|
+
labelValue: labelValue ?? '(missing)',
|
|
287
|
+
stackFrame: entry.stackFrame ? 'own' : stackFrame ? 'from owner' : '(none)',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
label: buildLabel(labelValue, rule),
|
|
293
|
+
sourceEntry: { ...entry, stackFrame },
|
|
294
|
+
props,
|
|
295
|
+
resolveLocation: buildResolveLocation(stackFrame, rule, ctx),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Try to match an entry against a rule using both strategies.
|
|
301
|
+
* `childFiber` is attempted first (only fires on native elements),
|
|
302
|
+
* then `direct` (only fires on matching component names).
|
|
303
|
+
*/
|
|
304
|
+
function matchEntry(
|
|
305
|
+
entry: ClickToNodeInfo,
|
|
306
|
+
rule: TransformerRule,
|
|
307
|
+
ctx: ChainTransformContext
|
|
308
|
+
): TransformedEntry | null {
|
|
309
|
+
return matchChildFiber(entry, rule, ctx) ?? matchDirect(entry, rule, ctx);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Creates a {@link ChainTransformer} driven by an array of declarative
|
|
314
|
+
* {@link TransformerRule}s. Each chain entry is tested against every rule
|
|
315
|
+
* using both `childFiber` and `direct` strategies (first match wins).
|
|
316
|
+
*
|
|
317
|
+
* Consecutive entries that transform to the same label are collapsed
|
|
318
|
+
* (e.g. `FormattedMessage` + `Memo(FormattedMessage)` → single entry).
|
|
319
|
+
* When collapsing, the entry with a `resolveLocation` callback is preferred.
|
|
320
|
+
*/
|
|
321
|
+
export function createRuleBasedTransformer(rules: TransformerRule[]): ChainTransformer {
|
|
322
|
+
if (rules.length === 0) {
|
|
323
|
+
return (chain) =>
|
|
324
|
+
chain.map((entry) => ({
|
|
325
|
+
label: entry.componentName,
|
|
326
|
+
sourceEntry: entry,
|
|
327
|
+
props: entry.props,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return (chain: ClickToNodeInfo[], ctx: ChainTransformContext): TransformedEntry[] => {
|
|
332
|
+
const result: TransformedEntry[] = [];
|
|
333
|
+
let prevWasDefaultNative = false;
|
|
334
|
+
|
|
335
|
+
for (const entry of chain) {
|
|
336
|
+
let transformed: TransformedEntry | null = null;
|
|
337
|
+
|
|
338
|
+
for (const rule of rules) {
|
|
339
|
+
transformed = matchEntry(entry, rule, ctx);
|
|
340
|
+
if (transformed) break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const output = transformed ?? {
|
|
344
|
+
label: entry.componentName,
|
|
345
|
+
sourceEntry: entry,
|
|
346
|
+
props: entry.props,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const prev = result[result.length - 1];
|
|
350
|
+
|
|
351
|
+
// Collapse consecutive entries with the same transformed label
|
|
352
|
+
// (e.g. FormattedMessage + Memo(FormattedMessage) both become "Timeline").
|
|
353
|
+
// Keep the one with a resolveLocation callback, or the later one.
|
|
354
|
+
if (prev && transformed && prev.label === output.label) {
|
|
355
|
+
if (ctx.debug) {
|
|
356
|
+
console.log(LOG_PREFIX, `dedup: collapsing consecutive "${output.label}"`);
|
|
357
|
+
}
|
|
358
|
+
if (!prev.resolveLocation && output.resolveLocation) {
|
|
359
|
+
result[result.length - 1] = output;
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// A rule-matched entry directly following an unmatched native DOM element
|
|
365
|
+
// means the native element is the rendered output of the matched component
|
|
366
|
+
// (e.g. <FormattedMessage> renders a <span>). Absorb the native element
|
|
367
|
+
// so the user sees the readable label instead.
|
|
368
|
+
if (prev && transformed && prevWasDefaultNative) {
|
|
369
|
+
if (ctx.debug) {
|
|
370
|
+
console.log(
|
|
371
|
+
LOG_PREFIX,
|
|
372
|
+
`absorb: replacing native "${prev.label}" with "${output.label}"`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
result[result.length - 1] = output;
|
|
376
|
+
prevWasDefaultNative = false;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
result.push(output);
|
|
381
|
+
prevWasDefaultNative = !transformed && typeof entry.fiber.type === 'string';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return result;
|
|
385
|
+
};
|
|
386
|
+
}
|