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.
@@ -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
+ }