@dcoder-x/plugin-shared 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extractors/ComponentExtractor.js +61 -11
- package/dist/extractors/FlowInferrer.d.ts +1 -0
- package/dist/extractors/FlowInferrer.js +87 -16
- package/dist/extractors/InteractionGraphExtractor.d.ts +7 -13
- package/dist/extractors/InteractionGraphExtractor.js +143 -55
- 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 +92 -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 +2 -0
- package/dist/upload/PackageBuilder.js +102 -25
- package/package.json +1 -1
|
@@ -10,6 +10,8 @@ const parser_1 = require("@babel/parser");
|
|
|
10
10
|
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
11
11
|
const InteractionGraphExtractor_1 = require("./InteractionGraphExtractor");
|
|
12
12
|
const ComponentContextResolver_1 = require("./ComponentContextResolver");
|
|
13
|
+
const HtmlTagGuards_1 = require("../injection/HtmlTagGuards");
|
|
14
|
+
const IdStrategy_1 = require("../injection/IdStrategy");
|
|
13
15
|
class ComponentExtractor {
|
|
14
16
|
constructor(source, routes) {
|
|
15
17
|
this.source = source;
|
|
@@ -257,21 +259,32 @@ class ComponentExtractor {
|
|
|
257
259
|
const tagName = nodePath.node.name?.name;
|
|
258
260
|
if (!tagName)
|
|
259
261
|
return;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
262
|
+
// Navigation components (Link, NavLink) are included for flow graph building
|
|
263
|
+
// but flagged so the selector manifest skips them
|
|
264
|
+
const isNavigationComponent = (tagName === 'Link' || tagName === 'NavLink' || tagName === 'RouterLink') &&
|
|
265
|
+
nodePath.node.attributes.some((a) => a.name?.name === 'href' || a.name?.name === 'to');
|
|
266
|
+
// Only intrinsic HTML tags get data-clippy-id injected; skip React components
|
|
267
|
+
if (!(0, HtmlTagGuards_1.isIntrinsicTag)(tagName) && !isNavigationComponent)
|
|
268
|
+
return;
|
|
269
|
+
const INTERACTIVE_TAGS = new Set(['button', 'input', 'select', 'textarea', 'a', 'form', 'label']);
|
|
270
|
+
if (!isNavigationComponent && !INTERACTIVE_TAGS.has(tagName))
|
|
263
271
|
return;
|
|
264
272
|
const staticProps = extractStaticProps(nodePath.node.attributes);
|
|
265
273
|
const nearbyText = extractNearbyText(nodePath);
|
|
274
|
+
const label = deriveLabel(staticProps, nearbyText) ?? undefined;
|
|
275
|
+
const componentName = (0, IdStrategy_1.findEnclosingComponentName)(nodePath) ?? undefined;
|
|
266
276
|
elements.push({
|
|
267
277
|
tag: tagName,
|
|
268
278
|
filePath,
|
|
269
279
|
route,
|
|
270
280
|
staticProps,
|
|
271
281
|
nearbyText,
|
|
282
|
+
label,
|
|
283
|
+
component: componentName,
|
|
272
284
|
semanticRole: inferSemanticRole(tagName, staticProps, nearbyText),
|
|
273
285
|
interactions: inferInteractions(tagName, staticProps),
|
|
274
286
|
loc: { line: nodePath.node.loc?.start?.line ?? 0 },
|
|
287
|
+
isNavigationLink: isNavigationComponent,
|
|
275
288
|
});
|
|
276
289
|
},
|
|
277
290
|
});
|
|
@@ -331,18 +344,55 @@ function extractStaticProps(attributes) {
|
|
|
331
344
|
return props;
|
|
332
345
|
}
|
|
333
346
|
function extractNearbyText(nodePath) {
|
|
334
|
-
const texts = [];
|
|
335
347
|
const parent = nodePath.parentPath?.node;
|
|
336
|
-
if (parent?.children)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
348
|
+
if (!parent?.children)
|
|
349
|
+
return [];
|
|
350
|
+
return extractTextFromChildren(parent.children, 0);
|
|
351
|
+
}
|
|
352
|
+
function extractTextFromChildren(children, depth) {
|
|
353
|
+
if (depth > 3)
|
|
354
|
+
return [];
|
|
355
|
+
const texts = [];
|
|
356
|
+
for (const child of children) {
|
|
357
|
+
if (child.type === 'JSXText') {
|
|
358
|
+
const t = child.value.trim();
|
|
359
|
+
if (t)
|
|
360
|
+
texts.push(t);
|
|
361
|
+
}
|
|
362
|
+
else if (child.type === 'JSXExpressionContainer') {
|
|
363
|
+
if (child.expression?.type === 'StringLiteral') {
|
|
364
|
+
texts.push(child.expression.value);
|
|
365
|
+
}
|
|
366
|
+
else if (child.expression?.type === 'TemplateLiteral' &&
|
|
367
|
+
child.expression.quasis?.length === 1) {
|
|
368
|
+
const raw = child.expression.quasis[0].value.raw.trim();
|
|
369
|
+
if (raw)
|
|
370
|
+
texts.push(raw);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else if (child.type === 'JSXElement') {
|
|
374
|
+
const childTag = child.openingElement?.name?.name || '';
|
|
375
|
+
// Skip icon-like leaf components — they contribute no readable text
|
|
376
|
+
if (!/^(Icon|Svg|Loader|Spinner|Arrow|Check|Plus|Minus|Close|X)[A-Z]?/i.test(childTag)) {
|
|
377
|
+
texts.push(...extractTextFromChildren(child.children || [], depth + 1));
|
|
342
378
|
}
|
|
343
379
|
}
|
|
344
380
|
}
|
|
345
|
-
return texts;
|
|
381
|
+
return texts.filter(Boolean);
|
|
382
|
+
}
|
|
383
|
+
function deriveLabel(props, nearbyText) {
|
|
384
|
+
if (props['aria-label'])
|
|
385
|
+
return props['aria-label'];
|
|
386
|
+
if (props['title'])
|
|
387
|
+
return props['title'];
|
|
388
|
+
const joined = nearbyText.join(' ').trim();
|
|
389
|
+
if (joined && joined.length <= 60)
|
|
390
|
+
return joined;
|
|
391
|
+
if (props['placeholder'])
|
|
392
|
+
return props['placeholder'];
|
|
393
|
+
if (props['name'])
|
|
394
|
+
return props['name'];
|
|
395
|
+
return null;
|
|
346
396
|
}
|
|
347
397
|
function inferSemanticRole(tag, props, nearbyText) {
|
|
348
398
|
const label = props['aria-label'] || props['placeholder'] || nearbyText[0] || '';
|
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.FlowInferrer = void 0;
|
|
4
|
+
const SEMANTIC_INTENT_PATTERNS = {
|
|
5
|
+
'login': ['log in', 'sign in', 'login'],
|
|
6
|
+
'signup': ['sign up', 'create account', 'register', 'get started'],
|
|
7
|
+
'register': ['register', 'sign up', 'create account'],
|
|
8
|
+
'forgot-password': ['forgot password', 'reset password', 'recover account'],
|
|
9
|
+
'reset-password': ['reset my password', 'set new password', 'change password'],
|
|
10
|
+
'verify': ['verify email', 'verify account', 'confirm email'],
|
|
11
|
+
'otp': ['enter code', 'verify code', 'enter otp'],
|
|
12
|
+
'billing': ['upgrade plan', 'manage billing', 'change subscription', 'view billing'],
|
|
13
|
+
'payment-method': ['add payment method', 'update card', 'change payment'],
|
|
14
|
+
'settings': ['settings', 'account settings', 'preferences'],
|
|
15
|
+
'team': ['invite team member', 'manage team', 'add team member'],
|
|
16
|
+
'forms': ['view forms', 'manage forms', 'create form', 'my forms'],
|
|
17
|
+
'builder': ['build form', 'form builder', 'edit form'],
|
|
18
|
+
'submissions': ['view submissions', 'form submissions', 'see responses'],
|
|
19
|
+
'analytics': ['view analytics', 'form analytics', 'see stats'],
|
|
20
|
+
'templates': ['view templates', 'browse templates', 'use template'],
|
|
21
|
+
'studio': ['template studio', 'create template'],
|
|
22
|
+
'transactions': ['view transactions', 'transaction history', 'payment history'],
|
|
23
|
+
'documents': ['view documents', 'open document', 'my documents'],
|
|
24
|
+
'integrations': ['integrations', 'connect integration', 'manage integrations'],
|
|
25
|
+
'developer': ['developer settings', 'api keys', 'developer dashboard'],
|
|
26
|
+
'automations': ['automations', 'manage automations', 'view automations'],
|
|
27
|
+
'campaigns': ['campaigns', 'view campaigns', 'manage campaigns'],
|
|
28
|
+
'website': ['website', 'manage website'],
|
|
29
|
+
'workflows': ['workflows', 'manage workflows'],
|
|
30
|
+
'invite': ['accept invite', 'join team'],
|
|
31
|
+
};
|
|
4
32
|
class FlowInferrer {
|
|
5
33
|
constructor(routes, elements, components) {
|
|
6
34
|
this.routes = routes;
|
|
@@ -17,15 +45,25 @@ class FlowInferrer {
|
|
|
17
45
|
const edges = [];
|
|
18
46
|
const routePaths = new Set(this.routes.map((r) => r.path));
|
|
19
47
|
for (const el of this.elements) {
|
|
48
|
+
// Include both native <a> tags and navigation components (Link, NavLink)
|
|
49
|
+
const isNav = el.tag === 'a' ||
|
|
50
|
+
el.isNavigationLink === true;
|
|
20
51
|
const href = el.staticProps['href'] || el.staticProps['to'];
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
if (!isNav || !href)
|
|
53
|
+
continue;
|
|
54
|
+
// Only build edges to known internal routes
|
|
55
|
+
if (!routePaths.has(href))
|
|
56
|
+
continue;
|
|
57
|
+
const triggerText = el.staticProps['aria-label'] ||
|
|
58
|
+
el.label ||
|
|
59
|
+
el.nearbyText[0] ||
|
|
60
|
+
'link';
|
|
61
|
+
edges.push({
|
|
62
|
+
from: el.route,
|
|
63
|
+
to: href,
|
|
64
|
+
trigger: triggerText,
|
|
65
|
+
confidence: 0.85,
|
|
66
|
+
});
|
|
29
67
|
}
|
|
30
68
|
return edges;
|
|
31
69
|
}
|
|
@@ -43,6 +81,11 @@ class FlowInferrer {
|
|
|
43
81
|
continue;
|
|
44
82
|
const chain = this.buildChain(route.path, adjacency, new Set());
|
|
45
83
|
if (chain.length >= 2) {
|
|
84
|
+
const chainEdges = edges.filter((e) => {
|
|
85
|
+
const s = new Set(chain);
|
|
86
|
+
return s.has(e.from) && s.has(e.to);
|
|
87
|
+
});
|
|
88
|
+
const intentPatterns = this.generateIntentPatterns(chain, chainEdges);
|
|
46
89
|
flows.push({
|
|
47
90
|
id: `flow_${flows.length + 1}`,
|
|
48
91
|
name: chain
|
|
@@ -50,10 +93,8 @@ class FlowInferrer {
|
|
|
50
93
|
.join(' -> '),
|
|
51
94
|
page: chain[0],
|
|
52
95
|
steps: chain,
|
|
53
|
-
edges:
|
|
54
|
-
|
|
55
|
-
return s.has(e.from) && s.has(e.to);
|
|
56
|
-
}),
|
|
96
|
+
edges: chainEdges,
|
|
97
|
+
intentPatterns,
|
|
57
98
|
});
|
|
58
99
|
chain.forEach((p) => visited.add(p));
|
|
59
100
|
}
|
|
@@ -61,18 +102,26 @@ class FlowInferrer {
|
|
|
61
102
|
return flows;
|
|
62
103
|
}
|
|
63
104
|
detectInteractionFlows() {
|
|
64
|
-
if (!this.components || this.components.length === 0)
|
|
105
|
+
if (!this.components || this.components.length === 0)
|
|
65
106
|
return [];
|
|
66
|
-
}
|
|
67
107
|
const flows = [];
|
|
68
108
|
for (const component of this.components) {
|
|
69
109
|
for (const interaction of component.interactions || []) {
|
|
70
|
-
|
|
110
|
+
// Only emit flows for interactions with a meaningful wait strategy
|
|
71
111
|
if (!interaction.trigger?.event)
|
|
72
112
|
continue;
|
|
113
|
+
if (!interaction.effect || interaction.effect.waitStrategy === 'none')
|
|
114
|
+
continue;
|
|
115
|
+
const routePath = component.route || '/';
|
|
116
|
+
const effectTarget = interaction.effect.selector ||
|
|
117
|
+
(interaction.effect.rendersWhenTrue
|
|
118
|
+
? `[data-clippy-component='${interaction.effect.rendersWhenTrue}']`
|
|
119
|
+
: null);
|
|
120
|
+
if (!effectTarget)
|
|
121
|
+
continue;
|
|
73
122
|
flows.push({
|
|
74
123
|
id: `flow_${flows.length + 1}`,
|
|
75
|
-
name:
|
|
124
|
+
name: interaction.effect.rendersWhenTrue || interaction.trigger.event,
|
|
76
125
|
page: routePath,
|
|
77
126
|
steps: [routePath],
|
|
78
127
|
edges: [
|
|
@@ -83,6 +132,7 @@ class FlowInferrer {
|
|
|
83
132
|
confidence: 0.65,
|
|
84
133
|
},
|
|
85
134
|
],
|
|
135
|
+
intentPatterns: [],
|
|
86
136
|
});
|
|
87
137
|
}
|
|
88
138
|
}
|
|
@@ -98,5 +148,26 @@ class FlowInferrer {
|
|
|
98
148
|
const best = [...next].sort((a, b) => b.confidence - a.confidence)[0];
|
|
99
149
|
return [start, ...this.buildChain(best.to, adjacency, seen)];
|
|
100
150
|
}
|
|
151
|
+
generateIntentPatterns(chain, edges) {
|
|
152
|
+
const patterns = new Set();
|
|
153
|
+
// 1. Route-segment semantic lookup
|
|
154
|
+
for (const routePath of chain) {
|
|
155
|
+
const segments = routePath.split('/').filter(Boolean);
|
|
156
|
+
for (const segment of segments) {
|
|
157
|
+
const clean = segment.replace(/[\[\]$:]/g, '').toLowerCase();
|
|
158
|
+
const known = SEMANTIC_INTENT_PATTERNS[clean];
|
|
159
|
+
if (known)
|
|
160
|
+
known.forEach((p) => patterns.add(p));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 2. Link/button trigger text from the edges themselves
|
|
164
|
+
for (const edge of edges) {
|
|
165
|
+
const trigger = edge.trigger?.trim();
|
|
166
|
+
if (trigger && trigger !== 'link' && trigger.length > 2) {
|
|
167
|
+
patterns.add(trigger.toLowerCase());
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return Array.from(patterns).filter(Boolean);
|
|
171
|
+
}
|
|
101
172
|
}
|
|
102
173
|
exports.FlowInferrer = FlowInferrer;
|
|
@@ -2,7 +2,7 @@ import type { NodePath } from '@babel/traverse';
|
|
|
2
2
|
import type { ComponentInteraction } from '../types';
|
|
3
3
|
/**
|
|
4
4
|
* Extracts interaction graphs from React component ASTs.
|
|
5
|
-
* Maps event handlers -> state mutations -> conditional renders and effects.
|
|
5
|
+
* Maps event handlers -> state mutations -> conditional renders, navigation, and async effects.
|
|
6
6
|
*/
|
|
7
7
|
export declare class InteractionGraphExtractor {
|
|
8
8
|
private contextResolver;
|
|
@@ -10,25 +10,19 @@ export declare class InteractionGraphExtractor {
|
|
|
10
10
|
* Extract all interactions (trigger -> effect mappings) from a component.
|
|
11
11
|
*/
|
|
12
12
|
extractInteractions(componentPath: NodePath<any>, componentName: string): ComponentInteraction[];
|
|
13
|
+
private buildInteractionFromSetters;
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
+
* Detect router.push() / router.replace() / navigate() calls inside a handler.
|
|
16
|
+
* Returns the destination route string if found, otherwise null.
|
|
15
17
|
*/
|
|
16
|
-
private
|
|
18
|
+
private resolveRouterNavigation;
|
|
19
|
+
private findHandlerBody;
|
|
20
|
+
private extractRouterCallFromNode;
|
|
17
21
|
private resolveStateSettersForAttribute;
|
|
18
22
|
private collectSettersFromNode;
|
|
19
|
-
/**
|
|
20
|
-
* Find conditional renders/effects that depend on a state variable.
|
|
21
|
-
* Looks for logical expressions and ternaries using the state var.
|
|
22
|
-
*/
|
|
23
23
|
private findConditionalEffect;
|
|
24
24
|
private findConditionalEffectInAncestors;
|
|
25
25
|
private findConditionalEffectInComponent;
|
|
26
|
-
/**
|
|
27
|
-
* Check if a node references a specific state variable by name.
|
|
28
|
-
*/
|
|
29
26
|
private nodeReferencesState;
|
|
30
|
-
/**
|
|
31
|
-
* Extract component name from a JSX element or identifier.
|
|
32
|
-
*/
|
|
33
27
|
private extractComponentNameFromNode;
|
|
34
28
|
}
|
|
@@ -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
|
}
|
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);
|
|
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,76 @@ 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
|
+
*/
|
|
81
|
+
function extractLabelFromOpeningElement(path, attrs) {
|
|
82
|
+
// Check attributes first — fastest path
|
|
83
|
+
for (const attr of attrs) {
|
|
84
|
+
if (attr.type !== 'JSXAttribute')
|
|
85
|
+
continue;
|
|
86
|
+
const name = attr.name?.name;
|
|
87
|
+
if (name !== 'aria-label' && name !== 'title')
|
|
88
|
+
continue;
|
|
89
|
+
if (attr.value?.type === 'StringLiteral')
|
|
90
|
+
return attr.value.value;
|
|
91
|
+
if (attr.value?.type === 'JSXExpressionContainer' &&
|
|
92
|
+
attr.value.expression?.type === 'StringLiteral')
|
|
93
|
+
return attr.value.expression.value;
|
|
94
|
+
}
|
|
95
|
+
// Extract visible text from children
|
|
96
|
+
const parent = path.parentPath?.node;
|
|
97
|
+
if (parent?.children) {
|
|
98
|
+
const text = extractTextFromChildren(parent.children, 0);
|
|
99
|
+
const joined = text.join(' ').trim();
|
|
100
|
+
if (joined && joined.length <= 60) {
|
|
101
|
+
const sanitized = (0, IdStrategy_1.sanitizeLabelForId)(joined);
|
|
102
|
+
if (sanitized)
|
|
103
|
+
return joined; // return raw joined text; sanitization happens in deriveClippyId
|
|
59
104
|
}
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
}
|
|
106
|
+
// Fallback to placeholder / name for inputs
|
|
107
|
+
for (const attr of attrs) {
|
|
108
|
+
if (attr.type !== 'JSXAttribute')
|
|
109
|
+
continue;
|
|
110
|
+
const name = attr.name?.name;
|
|
111
|
+
if (name !== 'placeholder' && name !== 'name')
|
|
112
|
+
continue;
|
|
113
|
+
if (attr.value?.type === 'StringLiteral')
|
|
114
|
+
return attr.value.value;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
function extractTextFromChildren(children, depth) {
|
|
119
|
+
if (depth > 3)
|
|
120
|
+
return [];
|
|
121
|
+
const texts = [];
|
|
122
|
+
for (const child of children) {
|
|
123
|
+
if (child.type === 'JSXText') {
|
|
124
|
+
const t = child.value.trim();
|
|
125
|
+
if (t)
|
|
126
|
+
texts.push(t);
|
|
62
127
|
}
|
|
63
|
-
if (
|
|
64
|
-
|
|
128
|
+
else if (child.type === 'JSXExpressionContainer') {
|
|
129
|
+
if (child.expression?.type === 'StringLiteral') {
|
|
130
|
+
texts.push(child.expression.value);
|
|
131
|
+
}
|
|
132
|
+
else if (child.expression?.type === 'TemplateLiteral' &&
|
|
133
|
+
child.expression.quasis?.length === 1) {
|
|
134
|
+
const raw = child.expression.quasis[0].value.raw.trim();
|
|
135
|
+
if (raw)
|
|
136
|
+
texts.push(raw);
|
|
137
|
+
}
|
|
65
138
|
}
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
139
|
+
else if (child.type === 'JSXElement') {
|
|
140
|
+
const childTag = child.openingElement?.name?.name || '';
|
|
141
|
+
if (!/^(Icon|Svg|Loader|Spinner|Arrow|Check|Plus|Minus|Close|X)[A-Z]?/i.test(childTag)) {
|
|
142
|
+
texts.push(...extractTextFromChildren(child.children || [], depth + 1));
|
|
143
|
+
}
|
|
70
144
|
}
|
|
71
|
-
current = current.parentPath;
|
|
72
145
|
}
|
|
73
|
-
return
|
|
146
|
+
return texts.filter(Boolean);
|
|
74
147
|
}
|
|
75
148
|
function findAttributeInsertIndex(source, range) {
|
|
76
149
|
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;
|
|
@@ -3,8 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GENERIC_COMPONENT_NAMES = void 0;
|
|
6
7
|
exports.deriveComponentName = deriveComponentName;
|
|
8
|
+
exports.inferRouteFromFilePath = inferRouteFromFilePath;
|
|
9
|
+
exports.deriveRouteComponentName = deriveRouteComponentName;
|
|
10
|
+
exports.hashRoutePath = hashRoutePath;
|
|
11
|
+
exports.sanitizeLabelForId = sanitizeLabelForId;
|
|
7
12
|
exports.deriveClippyId = deriveClippyId;
|
|
13
|
+
exports.findEnclosingComponentName = findEnclosingComponentName;
|
|
8
14
|
const path_1 = __importDefault(require("path"));
|
|
9
15
|
function deriveComponentName(filePath, fallback) {
|
|
10
16
|
if (fallback)
|
|
@@ -15,11 +21,115 @@ function deriveComponentName(filePath, fallback) {
|
|
|
15
21
|
}
|
|
16
22
|
return normalizeComponentName(base);
|
|
17
23
|
}
|
|
18
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Generic Next.js component names that should be replaced by a route-derived name.
|
|
26
|
+
*/
|
|
27
|
+
exports.GENERIC_COMPONENT_NAMES = new Set([
|
|
28
|
+
'Page', 'Layout', 'App', 'Index', 'Component', 'Default',
|
|
29
|
+
'Loading', 'Error', 'NotFound', 'Template', 'Root',
|
|
30
|
+
]);
|
|
31
|
+
/**
|
|
32
|
+
* Infer the route path from an absolute file path.
|
|
33
|
+
* Handles both App Router (app/…/page.tsx) and Pages Router (pages/….tsx).
|
|
34
|
+
* Returns null if the file is not a route file.
|
|
35
|
+
*/
|
|
36
|
+
function inferRouteFromFilePath(filePath) {
|
|
37
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
38
|
+
const appMatch = normalized.match(/\/app(\/.*?)\/page\.[jt]sx?$/);
|
|
39
|
+
if (appMatch) {
|
|
40
|
+
const segment = appMatch[1]
|
|
41
|
+
.replace(/\/\([^)]+\)/g, ''); // strip route groups like (auth)
|
|
42
|
+
return segment || '/';
|
|
43
|
+
}
|
|
44
|
+
const pagesMatch = normalized.match(/\/pages(\/.*?)\.[jt]sx?$/);
|
|
45
|
+
if (pagesMatch) {
|
|
46
|
+
const segment = pagesMatch[1].replace(/\/index$/, '') || '/';
|
|
47
|
+
return segment;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Convert a route path to a PascalCase component name.
|
|
53
|
+
* /admin/transactions → AdminTransactions
|
|
54
|
+
* /dashboard/forms/[id] → DashboardForms
|
|
55
|
+
* /auth/forgot-password → AuthForgotPassword
|
|
56
|
+
*/
|
|
57
|
+
function deriveRouteComponentName(routePath) {
|
|
58
|
+
const name = routePath
|
|
59
|
+
.split('/')
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.filter(s => !s.startsWith('[') && !s.startsWith(':') && !s.startsWith('$'))
|
|
62
|
+
.map(s => s
|
|
63
|
+
.split('-')
|
|
64
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
65
|
+
.join(''))
|
|
66
|
+
.join('');
|
|
67
|
+
return name || 'Page';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Compute a short route hash for use in stable IDs.
|
|
71
|
+
* Takes a route path (e.g., "/admin/transactions") and produces a 4-char hash.
|
|
72
|
+
*/
|
|
73
|
+
function hashRoutePath(routePath) {
|
|
74
|
+
let hash = 0;
|
|
75
|
+
for (let i = 0; i < routePath.length; i++) {
|
|
76
|
+
hash = (hash << 5) - hash + routePath.charCodeAt(i);
|
|
77
|
+
hash = hash & hash;
|
|
78
|
+
}
|
|
79
|
+
return Math.abs(hash).toString(36).slice(0, 4);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Sanitize a label string for embedding in a CSS/HTML attribute ID.
|
|
83
|
+
* TitleCase, max 2 words, alphanumeric only, minimum 2 chars.
|
|
84
|
+
* Returns null if no usable text can be extracted.
|
|
85
|
+
*/
|
|
86
|
+
function sanitizeLabelForId(label) {
|
|
87
|
+
const words = label
|
|
88
|
+
.replace(/[^A-Za-z0-9\s]/g, ' ')
|
|
89
|
+
.trim()
|
|
90
|
+
.split(/\s+/)
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.slice(0, 2)
|
|
93
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
|
|
94
|
+
const result = words.join('');
|
|
95
|
+
return result.length >= 2 ? result : null;
|
|
96
|
+
}
|
|
97
|
+
function deriveClippyId(componentName, tagName, lineNumber, routePath, labelText) {
|
|
19
98
|
const sanitizedComponent = normalizeComponentName(componentName);
|
|
20
99
|
const normalizedTag = tagName.replace(/[^a-z0-9]/gi, '') || 'element';
|
|
100
|
+
const sanitizedLabel = labelText ? sanitizeLabelForId(labelText) : null;
|
|
21
101
|
const stableLine = lineNumber > 0 ? lineNumber : 0;
|
|
22
|
-
|
|
102
|
+
const parts = [sanitizedComponent];
|
|
103
|
+
if (sanitizedLabel)
|
|
104
|
+
parts.push(sanitizedLabel);
|
|
105
|
+
parts.push(normalizedTag);
|
|
106
|
+
parts.push(String(stableLine));
|
|
107
|
+
return parts.join('-');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Walk the AST upward to find the enclosing React component name.
|
|
111
|
+
* Exported here so both the injector and the extractor use the same logic.
|
|
112
|
+
*/
|
|
113
|
+
function findEnclosingComponentName(path) {
|
|
114
|
+
let current = path;
|
|
115
|
+
while (current) {
|
|
116
|
+
if (current.isFunctionDeclaration() && current.node.id?.name) {
|
|
117
|
+
return current.node.id.name;
|
|
118
|
+
}
|
|
119
|
+
if (current.isClassDeclaration() && current.node.id?.name) {
|
|
120
|
+
return current.node.id.name;
|
|
121
|
+
}
|
|
122
|
+
if (current.isVariableDeclarator() && current.node.id?.type === 'Identifier') {
|
|
123
|
+
return current.node.id.name;
|
|
124
|
+
}
|
|
125
|
+
if ((current.isArrowFunctionExpression() || current.isFunctionExpression()) &&
|
|
126
|
+
current.parentPath?.isVariableDeclarator() &&
|
|
127
|
+
current.parentPath.node.id?.type === 'Identifier') {
|
|
128
|
+
return current.parentPath.node.id.name;
|
|
129
|
+
}
|
|
130
|
+
current = current.parentPath;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
23
133
|
}
|
|
24
134
|
function normalizeComponentName(value) {
|
|
25
135
|
const sanitized = value
|
package/dist/types.d.ts
CHANGED
|
@@ -38,6 +38,9 @@ export interface DiscoveredElement {
|
|
|
38
38
|
nearbyText: string[];
|
|
39
39
|
semanticRole: string;
|
|
40
40
|
interactions: string[];
|
|
41
|
+
label?: string;
|
|
42
|
+
component?: string;
|
|
43
|
+
isNavigationLink?: boolean;
|
|
41
44
|
loc?: {
|
|
42
45
|
line: number;
|
|
43
46
|
};
|
|
@@ -62,6 +65,7 @@ export interface InferredFlow {
|
|
|
62
65
|
page: string;
|
|
63
66
|
steps: string[];
|
|
64
67
|
edges: FlowEdge[];
|
|
68
|
+
intentPatterns?: string[];
|
|
65
69
|
}
|
|
66
70
|
export interface ArtifactMetadata {
|
|
67
71
|
version: string;
|
|
@@ -91,6 +95,7 @@ export interface PolicySelectorEntry {
|
|
|
91
95
|
selector: string;
|
|
92
96
|
tag: string;
|
|
93
97
|
component: string;
|
|
98
|
+
label?: string;
|
|
94
99
|
route: string;
|
|
95
100
|
filePath: string;
|
|
96
101
|
attributes: Array<{
|
|
@@ -136,7 +141,8 @@ export interface SelectorManifestEntry {
|
|
|
136
141
|
selector: string;
|
|
137
142
|
component: string;
|
|
138
143
|
tag: string;
|
|
139
|
-
|
|
144
|
+
label?: string;
|
|
145
|
+
routes: string[];
|
|
140
146
|
}
|
|
141
147
|
export interface SelectorManifest {
|
|
142
148
|
version: string;
|
|
@@ -26,6 +26,8 @@ export declare class PackageBuilder {
|
|
|
26
26
|
}): SelectorManifest;
|
|
27
27
|
build(data: Omit<KnowledgePackage, 'generatedAt'>): Buffer;
|
|
28
28
|
buildArtifactsBuffer(artifacts: PolicyArtifacts): Buffer;
|
|
29
|
+
private inferProjectRoot;
|
|
30
|
+
private normalizePath;
|
|
29
31
|
private toPolicySelectors;
|
|
30
32
|
private toPolicyComponents;
|
|
31
33
|
private toPolicyFlows;
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.PackageBuilder = void 0;
|
|
7
7
|
const zlib_1 = __importDefault(require("zlib"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
8
9
|
const IdStrategy_1 = require("../injection/IdStrategy");
|
|
9
10
|
class PackageBuilder {
|
|
10
11
|
constructor() {
|
|
@@ -39,31 +40,51 @@ class PackageBuilder {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
buildPolicyDocument(data) {
|
|
42
|
-
const
|
|
43
|
+
const projectRoot = this.inferProjectRoot(data.routes);
|
|
44
|
+
const normalizedRoutes = data.routes.map((r) => ({
|
|
45
|
+
...r,
|
|
46
|
+
filePath: this.normalizePath(r.filePath, projectRoot),
|
|
47
|
+
layout: r.layout ? this.normalizePath(r.layout, projectRoot) : null,
|
|
48
|
+
}));
|
|
49
|
+
const policySelectors = this.toPolicySelectors(data.selectors, projectRoot);
|
|
43
50
|
return {
|
|
44
51
|
version: this.version,
|
|
45
52
|
buildId: data.buildId,
|
|
46
53
|
generatedAt: data.generatedAt,
|
|
47
54
|
bundler: data.bundler,
|
|
48
|
-
routes:
|
|
55
|
+
routes: normalizedRoutes,
|
|
49
56
|
selectors: policySelectors,
|
|
50
57
|
components: data.components ?? this.toPolicyComponents(data.selectors),
|
|
51
|
-
flows: this.toPolicyFlows(data.flows,
|
|
58
|
+
flows: this.toPolicyFlows(data.flows, normalizedRoutes, policySelectors),
|
|
52
59
|
};
|
|
53
60
|
}
|
|
54
61
|
buildSelectorManifest(data) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Deduplicate by clippyId — shared components used on multiple routes appear once
|
|
63
|
+
// with a routes[] array listing every route where they appear.
|
|
64
|
+
const byId = new Map();
|
|
65
|
+
for (const entry of data.selectors) {
|
|
66
|
+
if (byId.has(entry.clippyId)) {
|
|
67
|
+
const existing = byId.get(entry.clippyId);
|
|
68
|
+
if (!existing.routes.includes(entry.route)) {
|
|
69
|
+
existing.routes.push(entry.route);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
byId.set(entry.clippyId, {
|
|
74
|
+
id: entry.clippyId,
|
|
75
|
+
selector: entry.selector,
|
|
76
|
+
component: entry.component,
|
|
77
|
+
tag: entry.tag,
|
|
78
|
+
label: entry.label,
|
|
79
|
+
routes: [entry.route],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
return {
|
|
63
84
|
version: this.version,
|
|
64
85
|
buildId: data.buildId,
|
|
65
86
|
generatedAt: data.generatedAt,
|
|
66
|
-
selectors,
|
|
87
|
+
selectors: Array.from(byId.values()),
|
|
67
88
|
};
|
|
68
89
|
}
|
|
69
90
|
build(data) {
|
|
@@ -73,9 +94,37 @@ class PackageBuilder {
|
|
|
73
94
|
buildArtifactsBuffer(artifacts) {
|
|
74
95
|
return zlib_1.default.gzipSync(JSON.stringify(artifacts));
|
|
75
96
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
97
|
+
inferProjectRoot(routes) {
|
|
98
|
+
const filePaths = routes.map((r) => r.filePath).filter(Boolean);
|
|
99
|
+
if (filePaths.length === 0)
|
|
100
|
+
return process.cwd();
|
|
101
|
+
// Find the longest common directory prefix across all route file paths
|
|
102
|
+
const parts = filePaths[0].replace(/\\/g, '/').split('/');
|
|
103
|
+
let commonParts = parts;
|
|
104
|
+
for (const fp of filePaths.slice(1)) {
|
|
105
|
+
const fpParts = fp.replace(/\\/g, '/').split('/');
|
|
106
|
+
const len = Math.min(commonParts.length, fpParts.length);
|
|
107
|
+
let i = 0;
|
|
108
|
+
while (i < len && commonParts[i] === fpParts[i])
|
|
109
|
+
i++;
|
|
110
|
+
commonParts = commonParts.slice(0, i);
|
|
111
|
+
}
|
|
112
|
+
return commonParts.join('/') || process.cwd();
|
|
113
|
+
}
|
|
114
|
+
normalizePath(filePath, projectRoot) {
|
|
115
|
+
try {
|
|
116
|
+
const rel = path_1.default.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
117
|
+
return rel.startsWith('..') ? filePath.replace(/\\/g, '/') : rel;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return filePath.replace(/\\/g, '/');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
toPolicySelectors(selectors, projectRoot) {
|
|
124
|
+
return selectors
|
|
125
|
+
.filter((element) => !element.isNavigationLink)
|
|
126
|
+
.map((element, index) => {
|
|
127
|
+
const component = element.component || (0, IdStrategy_1.deriveComponentName)(element.filePath);
|
|
79
128
|
const preferred = element.selectors.find((candidate) => candidate.type === 'clippy_id') ||
|
|
80
129
|
element.selectors[0];
|
|
81
130
|
const stableLine = element.loc?.line ?? index;
|
|
@@ -90,8 +139,9 @@ class PackageBuilder {
|
|
|
90
139
|
selector,
|
|
91
140
|
tag: element.tag,
|
|
92
141
|
component,
|
|
142
|
+
label: element.label,
|
|
93
143
|
route: element.route,
|
|
94
|
-
filePath: element.filePath,
|
|
144
|
+
filePath: projectRoot ? this.normalizePath(element.filePath, projectRoot) : element.filePath.replace(/\\/g, '/'),
|
|
95
145
|
attributes: Object.entries(element.staticProps).map(([name, value]) => ({ name, value })),
|
|
96
146
|
candidates: element.selectors,
|
|
97
147
|
};
|
|
@@ -100,12 +150,16 @@ class PackageBuilder {
|
|
|
100
150
|
toPolicyComponents(selectors) {
|
|
101
151
|
const byFile = new Map();
|
|
102
152
|
for (const element of selectors) {
|
|
153
|
+
if (element.isNavigationLink)
|
|
154
|
+
continue;
|
|
103
155
|
if (!byFile.has(element.filePath))
|
|
104
156
|
byFile.set(element.filePath, []);
|
|
105
157
|
byFile.get(element.filePath).push(element);
|
|
106
158
|
}
|
|
107
159
|
return Array.from(byFile.entries()).map(([filePath, items]) => {
|
|
108
|
-
|
|
160
|
+
// Prefer the component name captured during extraction over the filename
|
|
161
|
+
const componentName = items.find((i) => i.component)?.component ||
|
|
162
|
+
this.deriveComponentName(filePath);
|
|
109
163
|
const route = items[0]?.route || '/';
|
|
110
164
|
return {
|
|
111
165
|
name: componentName,
|
|
@@ -119,25 +173,48 @@ class PackageBuilder {
|
|
|
119
173
|
toPolicyFlows(flows, routes, selectors) {
|
|
120
174
|
return flows.map((flow, flowIndex) => {
|
|
121
175
|
const firstRoute = flow.steps[0] || '/';
|
|
122
|
-
const page = flow.page || routes.find((
|
|
123
|
-
const hasInteraction = flow.edges.some((edge) => edge.from === edge.to && edge.trigger?.startsWith('on'));
|
|
176
|
+
const page = flow.page || routes.find((r) => r.path === firstRoute)?.path || firstRoute;
|
|
124
177
|
const steps = flow.steps.map((routePath, stepIndex) => {
|
|
125
|
-
|
|
126
|
-
|
|
178
|
+
// For transition steps (not the first), find the edge that leads to this route
|
|
179
|
+
// and try to match its trigger text to a specific selector
|
|
180
|
+
const incomingEdge = stepIndex > 0
|
|
181
|
+
? flow.edges.find((e) => e.to === routePath)
|
|
182
|
+
: null;
|
|
183
|
+
let target;
|
|
184
|
+
if (incomingEdge?.trigger && incomingEdge.trigger !== 'link') {
|
|
185
|
+
// Try to find the specific triggering element by label match
|
|
186
|
+
const fromRoute = incomingEdge.from;
|
|
187
|
+
const byLabel = selectors.find((s) => s.route === fromRoute &&
|
|
188
|
+
s.label?.toLowerCase() === incomingEdge.trigger.toLowerCase());
|
|
189
|
+
const byAttr = !byLabel
|
|
190
|
+
? selectors.find((s) => s.route === fromRoute &&
|
|
191
|
+
s.attributes.some((a) => a.value.toLowerCase() === incomingEdge.trigger.toLowerCase()))
|
|
192
|
+
: null;
|
|
193
|
+
target = (byLabel || byAttr)?.selector;
|
|
194
|
+
}
|
|
195
|
+
// Fallback: first selector on the destination route
|
|
196
|
+
if (!target) {
|
|
197
|
+
target =
|
|
198
|
+
selectors.find((s) => s.route === routePath)?.selector ||
|
|
199
|
+
`[data-clippy-route='${routePath}']`;
|
|
200
|
+
}
|
|
201
|
+
const isFirst = stepIndex === 0;
|
|
202
|
+
const isInteractionStep = flow.edges.some((e) => e.from === e.to && e.trigger?.startsWith('on'));
|
|
127
203
|
return {
|
|
128
204
|
step: stepIndex + 1,
|
|
129
|
-
action:
|
|
130
|
-
?
|
|
131
|
-
? 'interact'
|
|
132
|
-
: 'navigate'
|
|
205
|
+
action: isFirst
|
|
206
|
+
? isInteractionStep ? 'interact' : 'navigate'
|
|
133
207
|
: 'transition',
|
|
134
208
|
target,
|
|
135
209
|
};
|
|
136
210
|
});
|
|
211
|
+
const intentPatterns = flow.intentPatterns && flow.intentPatterns.length > 0
|
|
212
|
+
? flow.intentPatterns
|
|
213
|
+
: [flow.name.toLowerCase()];
|
|
137
214
|
return {
|
|
138
215
|
flowId: flow.id || `flow_${flowIndex + 1}`,
|
|
139
216
|
page,
|
|
140
|
-
intentPatterns
|
|
217
|
+
intentPatterns,
|
|
141
218
|
steps,
|
|
142
219
|
};
|
|
143
220
|
});
|