@dcoder-x/plugin-shared 0.1.4 → 0.1.6
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/extractors/ComponentExtractor.js +70 -11
- package/dist/extractors/FlowInferrer.d.ts +19 -0
- package/dist/extractors/FlowInferrer.js +156 -18
- package/dist/extractors/InteractionGraphExtractor.d.ts +7 -13
- package/dist/extractors/InteractionGraphExtractor.js +143 -55
- package/dist/extractors/SelectorGenerator.d.ts +2 -6
- package/dist/extractors/SelectorGenerator.js +11 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/injection/ClippyIdInjector.d.ts +6 -1
- package/dist/injection/ClippyIdInjector.js +100 -19
- package/dist/injection/IdStrategy.d.ts +35 -1
- package/dist/injection/IdStrategy.js +112 -2
- package/dist/types.d.ts +7 -1
- package/dist/upload/PackageBuilder.d.ts +13 -1
- package/dist/upload/PackageBuilder.js +146 -38
- package/package.json +1 -1
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.InteractionGraphExtractor = void 0;
|
|
4
4
|
const ComponentContextResolver_1 = require("./ComponentContextResolver");
|
|
5
|
+
const MODAL_STATE_PATTERN = /^(is|show|open|visible)/i;
|
|
5
6
|
/**
|
|
6
7
|
* Extracts interaction graphs from React component ASTs.
|
|
7
|
-
* Maps event handlers -> state mutations -> conditional renders and effects.
|
|
8
|
+
* Maps event handlers -> state mutations -> conditional renders, navigation, and async effects.
|
|
8
9
|
*/
|
|
9
10
|
class InteractionGraphExtractor {
|
|
10
11
|
constructor() {
|
|
@@ -16,21 +17,54 @@ class InteractionGraphExtractor {
|
|
|
16
17
|
extractInteractions(componentPath, componentName) {
|
|
17
18
|
const context = this.contextResolver.extractContext(componentPath, componentName);
|
|
18
19
|
const interactions = [];
|
|
19
|
-
// Walk all JSX elements looking for event handlers
|
|
20
20
|
componentPath.traverse({
|
|
21
21
|
JSXOpeningElement: (jsxPath) => {
|
|
22
22
|
const attributes = jsxPath.node.attributes || [];
|
|
23
23
|
for (const attr of attributes) {
|
|
24
|
-
if (attr.type
|
|
25
|
-
attr.name?.name
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const interaction = this.
|
|
24
|
+
if (attr.type !== 'JSXAttribute' ||
|
|
25
|
+
!attr.name?.name?.startsWith('on'))
|
|
26
|
+
continue;
|
|
27
|
+
const eventName = attr.name.name;
|
|
28
|
+
// Path A: handler calls a known state setter → find conditional effect
|
|
29
|
+
const settersCalledByEvent = this.resolveStateSettersForAttribute(attr, context);
|
|
30
|
+
if (settersCalledByEvent.length > 0) {
|
|
31
|
+
const interaction = this.buildInteractionFromSetters(jsxPath, componentPath, eventName, context, settersCalledByEvent);
|
|
32
32
|
if (interaction) {
|
|
33
33
|
interactions.push(interaction);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Path B: handler contains router.push / router.replace → navigation effect
|
|
38
|
+
const navTarget = this.resolveRouterNavigation(attr, context);
|
|
39
|
+
if (navTarget) {
|
|
40
|
+
const tagName = jsxPath.node.name?.name || 'unknown';
|
|
41
|
+
interactions.push({
|
|
42
|
+
trigger: { event: eventName, element: tagName },
|
|
43
|
+
effect: {
|
|
44
|
+
type: 'asyncEffect',
|
|
45
|
+
waitStrategy: 'domSettle',
|
|
46
|
+
settleMs: 0,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Path C: handler calls a setter with a modal/open-like name → domSettle fallback
|
|
52
|
+
if (settersCalledByEvent.length > 0) {
|
|
53
|
+
const hasModalSetter = settersCalledByEvent.some((s) => MODAL_STATE_PATTERN.test(s));
|
|
54
|
+
if (hasModalSetter) {
|
|
55
|
+
const tagName = jsxPath.node.name?.name || 'unknown';
|
|
56
|
+
interactions.push({
|
|
57
|
+
trigger: {
|
|
58
|
+
event: eventName,
|
|
59
|
+
element: tagName,
|
|
60
|
+
setsState: settersCalledByEvent[0],
|
|
61
|
+
},
|
|
62
|
+
effect: {
|
|
63
|
+
type: 'conditionalRender',
|
|
64
|
+
waitStrategy: 'domSettle',
|
|
65
|
+
settleMs: 150,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
34
68
|
}
|
|
35
69
|
}
|
|
36
70
|
}
|
|
@@ -38,23 +72,96 @@ class InteractionGraphExtractor {
|
|
|
38
72
|
});
|
|
39
73
|
return interactions;
|
|
40
74
|
}
|
|
41
|
-
|
|
42
|
-
* Extract trigger/effect pair from a JSX element with an event handler.
|
|
43
|
-
*/
|
|
44
|
-
extractTriggerAndEffect(jsxPath, componentPath, eventName, context, settersCalledByEvent) {
|
|
75
|
+
buildInteractionFromSetters(jsxPath, componentPath, eventName, context, settersCalledByEvent) {
|
|
45
76
|
const tagName = jsxPath.node.name?.name || 'unknown';
|
|
46
|
-
const stateVar = settersCalledByEvent[0];
|
|
77
|
+
const stateVar = settersCalledByEvent[0];
|
|
47
78
|
const trigger = {
|
|
48
79
|
event: eventName,
|
|
49
80
|
element: tagName,
|
|
50
81
|
setsState: stateVar,
|
|
51
82
|
};
|
|
52
|
-
//
|
|
83
|
+
// Try to find a conditional render driven by this state variable
|
|
53
84
|
const effect = this.findConditionalEffect(jsxPath, componentPath, stateVar);
|
|
54
|
-
if (
|
|
85
|
+
if (effect) {
|
|
86
|
+
return { trigger, effect };
|
|
87
|
+
}
|
|
88
|
+
// Fallback: if setter name looks modal-like, emit a domSettle effect
|
|
89
|
+
if (MODAL_STATE_PATTERN.test(stateVar)) {
|
|
90
|
+
return {
|
|
91
|
+
trigger,
|
|
92
|
+
effect: {
|
|
93
|
+
type: 'conditionalRender',
|
|
94
|
+
waitStrategy: 'domSettle',
|
|
95
|
+
settleMs: 150,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Generic async/loading pattern: setter name contains loading, pending, fetching
|
|
100
|
+
if (/loading|pending|fetching|submitting/i.test(stateVar)) {
|
|
101
|
+
return {
|
|
102
|
+
trigger,
|
|
103
|
+
effect: {
|
|
104
|
+
type: 'asyncEffect',
|
|
105
|
+
waitStrategy: 'domSettle',
|
|
106
|
+
settleMs: 300,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Detect router.push() / router.replace() / navigate() calls inside a handler.
|
|
114
|
+
* Returns the destination route string if found, otherwise null.
|
|
115
|
+
*/
|
|
116
|
+
resolveRouterNavigation(attr, context) {
|
|
117
|
+
if (attr.type !== 'JSXAttribute' || !attr.value || attr.value.type !== 'JSXExpressionContainer') {
|
|
55
118
|
return null;
|
|
56
119
|
}
|
|
57
|
-
|
|
120
|
+
const expression = attr.value.expression;
|
|
121
|
+
if (!expression)
|
|
122
|
+
return null;
|
|
123
|
+
const body = expression.type === 'ArrowFunctionExpression' || expression.type === 'FunctionExpression'
|
|
124
|
+
? expression.body
|
|
125
|
+
: expression.type === 'Identifier'
|
|
126
|
+
? this.findHandlerBody(expression.name, context)
|
|
127
|
+
: null;
|
|
128
|
+
if (!body)
|
|
129
|
+
return null;
|
|
130
|
+
return this.extractRouterCallFromNode(body);
|
|
131
|
+
}
|
|
132
|
+
findHandlerBody(handlerName, context) {
|
|
133
|
+
const handler = context.eventHandlers.find((h) => h.name === handlerName);
|
|
134
|
+
return handler?._body ?? null;
|
|
135
|
+
}
|
|
136
|
+
extractRouterCallFromNode(node) {
|
|
137
|
+
if (!node || typeof node !== 'object')
|
|
138
|
+
return null;
|
|
139
|
+
if (node.type === 'CallExpression') {
|
|
140
|
+
const callee = node.callee;
|
|
141
|
+
const isRouterCall = (callee?.type === 'MemberExpression' &&
|
|
142
|
+
(callee.object?.name === 'router' || callee.object?.name === 'navigate') &&
|
|
143
|
+
(callee.property?.name === 'push' || callee.property?.name === 'replace')) ||
|
|
144
|
+
(callee?.type === 'Identifier' && callee.name === 'navigate');
|
|
145
|
+
if (isRouterCall && node.arguments?.[0]?.type === 'StringLiteral') {
|
|
146
|
+
return node.arguments[0].value;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const key of Object.keys(node)) {
|
|
150
|
+
const value = node[key];
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
for (const child of value) {
|
|
153
|
+
const found = this.extractRouterCallFromNode(child);
|
|
154
|
+
if (found)
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (value && typeof value === 'object') {
|
|
159
|
+
const found = this.extractRouterCallFromNode(value);
|
|
160
|
+
if (found)
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
58
165
|
}
|
|
59
166
|
resolveStateSettersForAttribute(attr, context) {
|
|
60
167
|
if (attr.type !== 'JSXAttribute' || !attr.value || attr.value.type !== 'JSXExpressionContainer') {
|
|
@@ -99,32 +206,23 @@ class InteractionGraphExtractor {
|
|
|
99
206
|
for (const key of Object.keys(node)) {
|
|
100
207
|
const value = node[key];
|
|
101
208
|
if (Array.isArray(value)) {
|
|
102
|
-
for (const child of value)
|
|
209
|
+
for (const child of value)
|
|
103
210
|
this.collectSettersFromNode(child, setterMap, result);
|
|
104
|
-
}
|
|
105
211
|
}
|
|
106
212
|
else if (value && typeof value === 'object') {
|
|
107
213
|
this.collectSettersFromNode(value, setterMap, result);
|
|
108
214
|
}
|
|
109
215
|
}
|
|
110
216
|
}
|
|
111
|
-
/**
|
|
112
|
-
* Find conditional renders/effects that depend on a state variable.
|
|
113
|
-
* Looks for logical expressions and ternaries using the state var.
|
|
114
|
-
*/
|
|
115
217
|
findConditionalEffect(jsxPath, componentPath, stateVar) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return directEffect;
|
|
119
|
-
}
|
|
120
|
-
return this.findConditionalEffectInComponent(componentPath, stateVar);
|
|
218
|
+
return (this.findConditionalEffectInAncestors(jsxPath, stateVar) ||
|
|
219
|
+
this.findConditionalEffectInComponent(componentPath, stateVar));
|
|
121
220
|
}
|
|
122
221
|
findConditionalEffectInAncestors(jsxPath, stateVar) {
|
|
123
222
|
let effectFound = null;
|
|
124
223
|
let current = jsxPath.parentPath;
|
|
125
224
|
while (current && !effectFound) {
|
|
126
225
|
const node = current.node;
|
|
127
|
-
// Check for logical AND/OR: condition && <Component />
|
|
128
226
|
if (node.type === 'LogicalExpression') {
|
|
129
227
|
if (this.nodeReferencesState(node.left, stateVar)) {
|
|
130
228
|
const rendersWhenTrue = this.extractComponentNameFromNode(node.right);
|
|
@@ -138,7 +236,6 @@ class InteractionGraphExtractor {
|
|
|
138
236
|
}
|
|
139
237
|
}
|
|
140
238
|
}
|
|
141
|
-
// Check for ternary: condition ? <Component /> : null
|
|
142
239
|
if (node.type === 'ConditionalExpression') {
|
|
143
240
|
if (this.nodeReferencesState(node.test, stateVar)) {
|
|
144
241
|
const rendersWhenTrue = this.extractComponentNameFromNode(node.consequent);
|
|
@@ -149,7 +246,9 @@ class InteractionGraphExtractor {
|
|
|
149
246
|
rendersWhenTrue: rendersWhenTrue ?? undefined,
|
|
150
247
|
rendersWhenFalse: rendersWhenFalse ?? undefined,
|
|
151
248
|
waitStrategy: 'elementAppears',
|
|
152
|
-
selector: rendersWhenTrue
|
|
249
|
+
selector: rendersWhenTrue
|
|
250
|
+
? `[data-clippy-component='${rendersWhenTrue}']`
|
|
251
|
+
: undefined,
|
|
153
252
|
};
|
|
154
253
|
}
|
|
155
254
|
}
|
|
@@ -190,7 +289,9 @@ class InteractionGraphExtractor {
|
|
|
190
289
|
rendersWhenTrue: rendersWhenTrue ?? undefined,
|
|
191
290
|
rendersWhenFalse: rendersWhenFalse ?? undefined,
|
|
192
291
|
waitStrategy: 'elementAppears',
|
|
193
|
-
selector: rendersWhenTrue
|
|
292
|
+
selector: rendersWhenTrue
|
|
293
|
+
? `[data-clippy-component='${rendersWhenTrue}']`
|
|
294
|
+
: undefined,
|
|
194
295
|
};
|
|
195
296
|
}
|
|
196
297
|
}
|
|
@@ -198,47 +299,34 @@ class InteractionGraphExtractor {
|
|
|
198
299
|
});
|
|
199
300
|
return effectFound;
|
|
200
301
|
}
|
|
201
|
-
/**
|
|
202
|
-
* Check if a node references a specific state variable by name.
|
|
203
|
-
*/
|
|
204
302
|
nodeReferencesState(node, stateVar) {
|
|
205
|
-
if (node.type === 'Identifier' && node.name === stateVar)
|
|
303
|
+
if (node.type === 'Identifier' && node.name === stateVar)
|
|
206
304
|
return true;
|
|
207
|
-
|
|
208
|
-
if (node.type === 'MemberExpression') {
|
|
305
|
+
if (node.type === 'MemberExpression')
|
|
209
306
|
return this.nodeReferencesState(node.object, stateVar);
|
|
210
|
-
|
|
211
|
-
if (node.type === 'CallExpression') {
|
|
307
|
+
if (node.type === 'CallExpression')
|
|
212
308
|
return this.nodeReferencesState(node.callee, stateVar);
|
|
213
|
-
}
|
|
214
309
|
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
|
|
215
|
-
return this.nodeReferencesState(node.left, stateVar) ||
|
|
310
|
+
return (this.nodeReferencesState(node.left, stateVar) ||
|
|
311
|
+
this.nodeReferencesState(node.right, stateVar));
|
|
216
312
|
}
|
|
217
|
-
if (node.type === 'UnaryExpression')
|
|
313
|
+
if (node.type === 'UnaryExpression')
|
|
218
314
|
return this.nodeReferencesState(node.argument, stateVar);
|
|
219
|
-
}
|
|
220
315
|
return false;
|
|
221
316
|
}
|
|
222
|
-
/**
|
|
223
|
-
* Extract component name from a JSX element or identifier.
|
|
224
|
-
*/
|
|
225
317
|
extractComponentNameFromNode(node) {
|
|
226
318
|
if (!node)
|
|
227
319
|
return null;
|
|
228
320
|
if (node.type === 'JSXElement' && node.openingElement?.name?.name) {
|
|
229
321
|
const name = node.openingElement.name.name;
|
|
230
|
-
// Only return if it looks like a component (PascalCase)
|
|
231
322
|
return /^[A-Z]/.test(name) ? name : null;
|
|
232
323
|
}
|
|
233
|
-
if (node.type === 'JSXFragment')
|
|
324
|
+
if (node.type === 'JSXFragment')
|
|
234
325
|
return null;
|
|
235
|
-
|
|
236
|
-
if (node.type === 'Identifier' && /^[A-Z]/.test(node.name)) {
|
|
326
|
+
if (node.type === 'Identifier' && /^[A-Z]/.test(node.name))
|
|
237
327
|
return node.name;
|
|
238
|
-
|
|
239
|
-
if (node.type === 'MemberExpression' && node.property?.name) {
|
|
328
|
+
if (node.type === 'MemberExpression' && node.property?.name)
|
|
240
329
|
return node.property.name;
|
|
241
|
-
}
|
|
242
330
|
return null;
|
|
243
331
|
}
|
|
244
332
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { DiscoveredElement, ElementWithSelectors } from '../types';
|
|
2
|
+
import type { InjectedEntry } from '../injection/ClippyIdInjector';
|
|
2
3
|
export declare class SelectorGenerator {
|
|
3
|
-
generate(elements: DiscoveredElement[], injectedMap?: Record<string,
|
|
4
|
-
clippyId: string;
|
|
5
|
-
component: string;
|
|
6
|
-
tag: string;
|
|
7
|
-
line: number;
|
|
8
|
-
}>>): ElementWithSelectors[];
|
|
4
|
+
generate(elements: DiscoveredElement[], injectedMap?: Record<string, InjectedEntry[]>): ElementWithSelectors[];
|
|
9
5
|
private generateForElement;
|
|
10
6
|
}
|
|
@@ -16,14 +16,19 @@ class SelectorGenerator {
|
|
|
16
16
|
if (injectedMap && el.filePath) {
|
|
17
17
|
const entries = injectedMap[el.filePath];
|
|
18
18
|
if (entries && entries.length) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const elLine = el.loc?.line;
|
|
20
|
+
const elLabel = el.label;
|
|
21
|
+
// 1. Exact line match — most reliable
|
|
22
|
+
let match = elLine ? entries.find((e) => e.line === elLine) : null;
|
|
23
|
+
// 2. Label + tag match — handles multiple same-tag elements in one file
|
|
24
|
+
// (e.g. two <button> tags where line lookup fails due to AST offset)
|
|
25
|
+
if (!match && elLabel) {
|
|
26
|
+
match = entries.find((e) => e.tag === el.tag && e.label === elLabel) ?? null;
|
|
23
27
|
}
|
|
24
|
-
//
|
|
28
|
+
// 3. Tag-only fallback — last resort, may pick the wrong element when
|
|
29
|
+
// multiple same-tag elements exist but is better than no clippy_id at all
|
|
25
30
|
if (!match) {
|
|
26
|
-
match = entries.find((e) => e.tag === el.tag);
|
|
31
|
+
match = entries.find((e) => e.tag === el.tag) ?? null;
|
|
27
32
|
}
|
|
28
33
|
if (match) {
|
|
29
34
|
candidates.push({
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./types"), exports);
|
|
18
18
|
__exportStar(require("./injection/ClippyIdInjector"), exports);
|
|
19
|
+
__exportStar(require("./injection/IdStrategy"), exports);
|
|
19
20
|
__exportStar(require("./buildId"), exports);
|
|
20
21
|
__exportStar(require("./upload/Adapter"), exports);
|
|
21
22
|
__exportStar(require("./upload/BackendAdapter"), exports);
|
|
@@ -3,10 +3,15 @@ export interface InjectedEntry {
|
|
|
3
3
|
component: string;
|
|
4
4
|
tag: string;
|
|
5
5
|
line: number;
|
|
6
|
+
label?: string;
|
|
6
7
|
}
|
|
7
8
|
export interface InjectResult {
|
|
8
9
|
source: string;
|
|
9
10
|
injectedCount: number;
|
|
10
11
|
injected: InjectedEntry[];
|
|
11
12
|
}
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Inject data-clippy-id and data-clippy-component attributes into intrinsic HTML elements.
|
|
15
|
+
* IDs incorporate the route-derived component name and visible label text for readability.
|
|
16
|
+
*/
|
|
17
|
+
export declare function injectClippyIds(source: string, filePath: string, routePath?: string): InjectResult;
|
|
@@ -8,7 +8,11 @@ const parser_1 = require("@babel/parser");
|
|
|
8
8
|
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
9
9
|
const HtmlTagGuards_1 = require("./HtmlTagGuards");
|
|
10
10
|
const IdStrategy_1 = require("./IdStrategy");
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Inject data-clippy-id and data-clippy-component attributes into intrinsic HTML elements.
|
|
13
|
+
* IDs incorporate the route-derived component name and visible label text for readability.
|
|
14
|
+
*/
|
|
15
|
+
function injectClippyIds(source, filePath, routePath) {
|
|
12
16
|
let ast;
|
|
13
17
|
try {
|
|
14
18
|
ast = (0, parser_1.parse)(source, {
|
|
@@ -31,9 +35,22 @@ function injectClippyIds(source, filePath) {
|
|
|
31
35
|
const attrs = opening.attributes;
|
|
32
36
|
if ((0, HtmlTagGuards_1.hasAttribute)(attrs, 'data-clippy-id'))
|
|
33
37
|
return;
|
|
34
|
-
|
|
38
|
+
// Resolve component name: prefer enclosing name, fall back to route-derived or filename
|
|
39
|
+
const enclosing = (0, IdStrategy_1.findEnclosingComponentName)(path);
|
|
40
|
+
let componentName;
|
|
41
|
+
if (enclosing && !IdStrategy_1.GENERIC_COMPONENT_NAMES.has(enclosing)) {
|
|
42
|
+
componentName = enclosing;
|
|
43
|
+
}
|
|
44
|
+
else if (routePath) {
|
|
45
|
+
componentName = (0, IdStrategy_1.deriveRouteComponentName)(routePath);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
componentName = (0, IdStrategy_1.deriveComponentName)(filePath);
|
|
49
|
+
}
|
|
35
50
|
const line = opening.loc?.start.line ?? 0;
|
|
36
|
-
|
|
51
|
+
// Extract visible label text from element children for ID enrichment
|
|
52
|
+
const labelText = extractLabelFromOpeningElement(path, attrs, tagName);
|
|
53
|
+
const clippyId = (0, IdStrategy_1.deriveClippyId)(componentName, tagName, line, routePath, labelText ?? undefined);
|
|
37
54
|
const componentAttr = (0, HtmlTagGuards_1.hasAttribute)(attrs, 'data-clippy-component')
|
|
38
55
|
? ''
|
|
39
56
|
: ` data-clippy-component="${componentName}"`;
|
|
@@ -42,7 +59,13 @@ function injectClippyIds(source, filePath) {
|
|
|
42
59
|
if (insertIndex === null)
|
|
43
60
|
return;
|
|
44
61
|
edits.push({ index: insertIndex, text: insertText });
|
|
45
|
-
injected.push({
|
|
62
|
+
injected.push({
|
|
63
|
+
clippyId,
|
|
64
|
+
component: componentName,
|
|
65
|
+
tag: tagName,
|
|
66
|
+
line,
|
|
67
|
+
label: labelText ?? undefined,
|
|
68
|
+
});
|
|
46
69
|
},
|
|
47
70
|
});
|
|
48
71
|
if (edits.length === 0) {
|
|
@@ -51,26 +74,84 @@ function injectClippyIds(source, filePath) {
|
|
|
51
74
|
const transformed = applyEdits(source, edits);
|
|
52
75
|
return { source: transformed, injectedCount: edits.length, injected };
|
|
53
76
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Extract a usable label from the element's attributes and children.
|
|
79
|
+
* Priority: aria-label > title > visible text content > placeholder > name.
|
|
80
|
+
* For form elements: only aria-label or the last short text (submit button),
|
|
81
|
+
* never the full concatenation of all child field labels.
|
|
82
|
+
*/
|
|
83
|
+
function extractLabelFromOpeningElement(path, attrs, tagName) {
|
|
84
|
+
// Check attributes first — fastest path
|
|
85
|
+
for (const attr of attrs) {
|
|
86
|
+
if (attr.type !== 'JSXAttribute')
|
|
87
|
+
continue;
|
|
88
|
+
const name = attr.name?.name;
|
|
89
|
+
if (name !== 'aria-label' && name !== 'title')
|
|
90
|
+
continue;
|
|
91
|
+
if (attr.value?.type === 'StringLiteral')
|
|
92
|
+
return attr.value.value;
|
|
93
|
+
if (attr.value?.type === 'JSXExpressionContainer' &&
|
|
94
|
+
attr.value.expression?.type === 'StringLiteral')
|
|
95
|
+
return attr.value.expression.value;
|
|
96
|
+
}
|
|
97
|
+
// Extract visible text from children
|
|
98
|
+
const parent = path.parentPath?.node;
|
|
99
|
+
if (parent?.children) {
|
|
100
|
+
const text = extractTextFromChildren(parent.children, 0);
|
|
101
|
+
if (tagName === 'form') {
|
|
102
|
+
// Forms: only use the last short text node (submit button label), not the full
|
|
103
|
+
// concatenation of every field label inside the form.
|
|
104
|
+
const lastShort = [...text].reverse().find((t) => t.length >= 2 && t.length <= 30);
|
|
105
|
+
return lastShort || null;
|
|
59
106
|
}
|
|
60
|
-
|
|
61
|
-
|
|
107
|
+
const joined = text.join(' ').trim();
|
|
108
|
+
if (joined && joined.length <= 60) {
|
|
109
|
+
const sanitized = (0, IdStrategy_1.sanitizeLabelForId)(joined);
|
|
110
|
+
if (sanitized)
|
|
111
|
+
return joined;
|
|
62
112
|
}
|
|
63
|
-
|
|
64
|
-
|
|
113
|
+
}
|
|
114
|
+
// Fallback to placeholder / name for inputs
|
|
115
|
+
for (const attr of attrs) {
|
|
116
|
+
if (attr.type !== 'JSXAttribute')
|
|
117
|
+
continue;
|
|
118
|
+
const name = attr.name?.name;
|
|
119
|
+
if (name !== 'placeholder' && name !== 'name')
|
|
120
|
+
continue;
|
|
121
|
+
if (attr.value?.type === 'StringLiteral')
|
|
122
|
+
return attr.value.value;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function extractTextFromChildren(children, depth) {
|
|
127
|
+
if (depth > 3)
|
|
128
|
+
return [];
|
|
129
|
+
const texts = [];
|
|
130
|
+
for (const child of children) {
|
|
131
|
+
if (child.type === 'JSXText') {
|
|
132
|
+
const t = child.value.trim();
|
|
133
|
+
if (t)
|
|
134
|
+
texts.push(t);
|
|
135
|
+
}
|
|
136
|
+
else if (child.type === 'JSXExpressionContainer') {
|
|
137
|
+
if (child.expression?.type === 'StringLiteral') {
|
|
138
|
+
texts.push(child.expression.value);
|
|
139
|
+
}
|
|
140
|
+
else if (child.expression?.type === 'TemplateLiteral' &&
|
|
141
|
+
child.expression.quasis?.length === 1) {
|
|
142
|
+
const raw = child.expression.quasis[0].value.raw.trim();
|
|
143
|
+
if (raw)
|
|
144
|
+
texts.push(raw);
|
|
145
|
+
}
|
|
65
146
|
}
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
147
|
+
else if (child.type === 'JSXElement') {
|
|
148
|
+
const childTag = child.openingElement?.name?.name || '';
|
|
149
|
+
if (!/^(Icon|Svg|Loader|Spinner|Arrow|Check|Plus|Minus|Close|X)[A-Z]?/i.test(childTag)) {
|
|
150
|
+
texts.push(...extractTextFromChildren(child.children || [], depth + 1));
|
|
151
|
+
}
|
|
70
152
|
}
|
|
71
|
-
current = current.parentPath;
|
|
72
153
|
}
|
|
73
|
-
return
|
|
154
|
+
return texts.filter(Boolean);
|
|
74
155
|
}
|
|
75
156
|
function findAttributeInsertIndex(source, range) {
|
|
76
157
|
if (!range)
|
|
@@ -1,2 +1,36 @@
|
|
|
1
|
+
import type { NodePath } from '@babel/traverse';
|
|
1
2
|
export declare function deriveComponentName(filePath: string, fallback?: string): string;
|
|
2
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Generic Next.js component names that should be replaced by a route-derived name.
|
|
5
|
+
*/
|
|
6
|
+
export declare const GENERIC_COMPONENT_NAMES: Set<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Infer the route path from an absolute file path.
|
|
9
|
+
* Handles both App Router (app/…/page.tsx) and Pages Router (pages/….tsx).
|
|
10
|
+
* Returns null if the file is not a route file.
|
|
11
|
+
*/
|
|
12
|
+
export declare function inferRouteFromFilePath(filePath: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Convert a route path to a PascalCase component name.
|
|
15
|
+
* /admin/transactions → AdminTransactions
|
|
16
|
+
* /dashboard/forms/[id] → DashboardForms
|
|
17
|
+
* /auth/forgot-password → AuthForgotPassword
|
|
18
|
+
*/
|
|
19
|
+
export declare function deriveRouteComponentName(routePath: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Compute a short route hash for use in stable IDs.
|
|
22
|
+
* Takes a route path (e.g., "/admin/transactions") and produces a 4-char hash.
|
|
23
|
+
*/
|
|
24
|
+
export declare function hashRoutePath(routePath: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Sanitize a label string for embedding in a CSS/HTML attribute ID.
|
|
27
|
+
* TitleCase, max 2 words, alphanumeric only, minimum 2 chars.
|
|
28
|
+
* Returns null if no usable text can be extracted.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sanitizeLabelForId(label: string): string | null;
|
|
31
|
+
export declare function deriveClippyId(componentName: string, tagName: string, lineNumber: number, routePath?: string, labelText?: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Walk the AST upward to find the enclosing React component name.
|
|
34
|
+
* Exported here so both the injector and the extractor use the same logic.
|
|
35
|
+
*/
|
|
36
|
+
export declare function findEnclosingComponentName(path: NodePath<any>): string | null;
|