@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.
@@ -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
- const isInteractive = ['button', 'input', 'select', 'textarea', 'a', 'form'].includes(tagName) ||
261
- nodePath.node.attributes.some((attr) => attr.type === 'JSXAttribute' && attr.name?.name === 'onClick');
262
- if (!isInteractive)
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
- for (const child of parent.children) {
338
- if (child.type === 'JSXText') {
339
- const text = child.value.trim();
340
- if (text)
341
- texts.push(text);
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] || '';
@@ -9,4 +9,5 @@ export declare class FlowInferrer {
9
9
  private detectLinearFlows;
10
10
  private detectInteractionFlows;
11
11
  private buildChain;
12
+ private generateIntentPatterns;
12
13
  }
@@ -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 (el.tag === 'a' && href && routePaths.has(href)) {
22
- edges.push({
23
- from: el.route,
24
- to: href,
25
- trigger: el.staticProps['aria-label'] || el.nearbyText[0] || 'link',
26
- confidence: 0.85,
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: edges.filter((e) => {
54
- const s = new Set(chain);
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
- const routePath = component.route || '/';
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: `${routePath} ${interaction.trigger.event}`,
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
- * Extract trigger/effect pair from a JSX element with an event handler.
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 extractTriggerAndEffect;
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 === 'JSXAttribute' &&
25
- attr.name?.name &&
26
- attr.name.name.startsWith('on')) {
27
- const eventName = attr.name.name;
28
- const settersCalledByEvent = this.resolveStateSettersForAttribute(attr, context);
29
- if (settersCalledByEvent.length === 0)
30
- continue;
31
- const interaction = this.extractTriggerAndEffect(jsxPath, componentPath, eventName, context, settersCalledByEvent);
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]; // Take first setter as primary
77
+ const stateVar = settersCalledByEvent[0];
47
78
  const trigger = {
48
79
  event: eventName,
49
80
  element: tagName,
50
81
  setsState: stateVar,
51
82
  };
52
- // Look for conditional effects that depend on the modified state
83
+ // Try to find a conditional render driven by this state variable
53
84
  const effect = this.findConditionalEffect(jsxPath, componentPath, stateVar);
54
- if (!effect) {
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
- return { trigger, effect };
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
- const directEffect = this.findConditionalEffectInAncestors(jsxPath, stateVar);
117
- if (directEffect) {
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 ? `[data-clippy-component='${rendersWhenTrue}']` : undefined,
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 ? `[data-clippy-component='${rendersWhenTrue}']` : undefined,
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) || this.nodeReferencesState(node.right, 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
@@ -1,5 +1,6 @@
1
1
  export * from './types';
2
2
  export * from './injection/ClippyIdInjector';
3
+ export * from './injection/IdStrategy';
3
4
  export * from './buildId';
4
5
  export * from './upload/Adapter';
5
6
  export * from './upload/BackendAdapter';
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
- export declare function injectClippyIds(source: string, filePath: string): InjectResult;
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
- function injectClippyIds(source, filePath) {
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
- const componentName = findEnclosingComponentName(path) || (0, IdStrategy_1.deriveComponentName)(filePath);
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
- const clippyId = (0, IdStrategy_1.deriveClippyId)(componentName, tagName, line);
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({ clippyId, component: componentName, tag: tagName, line });
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
- function findEnclosingComponentName(path) {
55
- let current = path;
56
- while (current) {
57
- if (current.isFunctionDeclaration() && current.node.id?.name) {
58
- return current.node.id.name;
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
- if (current.isClassDeclaration() && current.node.id?.name) {
61
- return current.node.id.name;
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 (current.isVariableDeclarator() && current.node.id?.type === 'Identifier') {
64
- return current.node.id.name;
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 ((current.isArrowFunctionExpression() || current.isFunctionExpression()) &&
67
- current.parentPath?.isVariableDeclarator() &&
68
- current.parentPath.node.id?.type === 'Identifier') {
69
- return current.parentPath.node.id.name;
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 null;
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
- export declare function deriveClippyId(componentName: string, tagName: string, lineNumber: number): string;
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
- function deriveClippyId(componentName, tagName, lineNumber) {
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
- return `${sanitizedComponent}-${normalizedTag}-${stableLine}`;
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
- route: string;
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 policySelectors = this.toPolicySelectors(data.selectors);
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: data.routes,
55
+ routes: normalizedRoutes,
49
56
  selectors: policySelectors,
50
57
  components: data.components ?? this.toPolicyComponents(data.selectors),
51
- flows: this.toPolicyFlows(data.flows, data.routes, policySelectors),
58
+ flows: this.toPolicyFlows(data.flows, normalizedRoutes, policySelectors),
52
59
  };
53
60
  }
54
61
  buildSelectorManifest(data) {
55
- const selectors = data.selectors.map((entry) => ({
56
- id: entry.clippyId,
57
- selector: entry.selector,
58
- component: entry.component,
59
- tag: entry.tag,
60
- route: entry.route,
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
- toPolicySelectors(selectors) {
77
- return selectors.map((element, index) => {
78
- const component = (0, IdStrategy_1.deriveComponentName)(element.filePath);
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
- const componentName = this.deriveComponentName(filePath);
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((route) => route.path === firstRoute)?.path || firstRoute;
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
- const target = selectors.find((entry) => entry.route === routePath)?.selector ||
126
- `[data-clippy-route='${routePath}']`;
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: stepIndex === 0
130
- ? hasInteraction
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: [flow.name.toLowerCase()],
217
+ intentPatterns,
141
218
  steps,
142
219
  };
143
220
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcoder-x/plugin-shared",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",