@dcoder-x/plugin-shared 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extractors/ComponentExtractor.js +11 -2
- package/dist/extractors/FlowInferrer.d.ts +18 -0
- package/dist/extractors/FlowInferrer.js +84 -17
- package/dist/extractors/SelectorGenerator.d.ts +2 -6
- package/dist/extractors/SelectorGenerator.js +11 -6
- package/dist/injection/ClippyIdInjector.js +11 -3
- package/dist/upload/PackageBuilder.d.ts +11 -1
- package/dist/upload/PackageBuilder.js +60 -29
- package/package.json +1 -1
|
@@ -271,7 +271,7 @@ class ComponentExtractor {
|
|
|
271
271
|
return;
|
|
272
272
|
const staticProps = extractStaticProps(nodePath.node.attributes);
|
|
273
273
|
const nearbyText = extractNearbyText(nodePath);
|
|
274
|
-
const label = deriveLabel(staticProps, nearbyText) ?? undefined;
|
|
274
|
+
const label = deriveLabel(tagName, staticProps, nearbyText) ?? undefined;
|
|
275
275
|
const componentName = (0, IdStrategy_1.findEnclosingComponentName)(nodePath) ?? undefined;
|
|
276
276
|
elements.push({
|
|
277
277
|
tag: tagName,
|
|
@@ -380,7 +380,16 @@ function extractTextFromChildren(children, depth) {
|
|
|
380
380
|
}
|
|
381
381
|
return texts.filter(Boolean);
|
|
382
382
|
}
|
|
383
|
-
function deriveLabel(props, nearbyText) {
|
|
383
|
+
function deriveLabel(tag, props, nearbyText) {
|
|
384
|
+
// Forms: child text is a concatenation of every field label + button inside the form.
|
|
385
|
+
// That is not a useful description. Use aria-label if present, otherwise take only
|
|
386
|
+
// the last short text node (typically the submit button label).
|
|
387
|
+
if (tag === 'form') {
|
|
388
|
+
if (props['aria-label'])
|
|
389
|
+
return props['aria-label'];
|
|
390
|
+
const lastShort = [...nearbyText].reverse().find((t) => t.length >= 2 && t.length <= 30);
|
|
391
|
+
return lastShort || null;
|
|
392
|
+
}
|
|
384
393
|
if (props['aria-label'])
|
|
385
394
|
return props['aria-label'];
|
|
386
395
|
if (props['title'])
|
|
@@ -7,7 +7,25 @@ export declare class FlowInferrer {
|
|
|
7
7
|
infer(): InferredFlow[];
|
|
8
8
|
private buildEdgeGraph;
|
|
9
9
|
private detectLinearFlows;
|
|
10
|
+
/**
|
|
11
|
+
* Fix 3b: A coherent chain stays within the same top-level route domain.
|
|
12
|
+
* A chain that jumps from /auth/* to / or from /dashboard/* to /auth/signup
|
|
13
|
+
* is an artifact of footer/layout navigation links, not a real user flow.
|
|
14
|
+
*/
|
|
15
|
+
private isCoherentChain;
|
|
10
16
|
private detectInteractionFlows;
|
|
11
17
|
private buildChain;
|
|
12
18
|
private generateIntentPatterns;
|
|
19
|
+
/**
|
|
20
|
+
* Returns true if a string is a usable user intent phrase.
|
|
21
|
+
* Rejects blocklisted words, PascalCase component names, very short strings,
|
|
22
|
+
* and URL-like strings.
|
|
23
|
+
*/
|
|
24
|
+
private isValidIntentPattern;
|
|
25
|
+
/**
|
|
26
|
+
* Fix 4: Remove intent patterns that appear across more than 30% of all flows.
|
|
27
|
+
* These are structural navigation phrases (sidebar, footer links) that ended up
|
|
28
|
+
* attached to many unrelated flows rather than genuine user intents.
|
|
29
|
+
*/
|
|
30
|
+
private deduplicateCrossFlowNoise;
|
|
13
31
|
}
|
|
@@ -29,6 +29,13 @@ const SEMANTIC_INTENT_PATTERNS = {
|
|
|
29
29
|
'workflows': ['workflows', 'manage workflows'],
|
|
30
30
|
'invite': ['accept invite', 'join team'],
|
|
31
31
|
};
|
|
32
|
+
// Fix 4: words that are never meaningful user intent phrases
|
|
33
|
+
const INTENT_BLOCKLIST = new Set([
|
|
34
|
+
'back', 'next', 'continue', 'click', 'button', 'here', 'more',
|
|
35
|
+
'loader2', 'loader', 'spinner', 'close', 'x', 'menu', 'open',
|
|
36
|
+
'cancel', 'ok', 'yes', 'no', 'loading', 'view all', 'learn more',
|
|
37
|
+
'read more', 'see more', 'link', 'skip', 'done', 'go',
|
|
38
|
+
]);
|
|
32
39
|
class FlowInferrer {
|
|
33
40
|
constructor(routes, elements, components) {
|
|
34
41
|
this.routes = routes;
|
|
@@ -39,34 +46,39 @@ class FlowInferrer {
|
|
|
39
46
|
const edges = this.buildEdgeGraph();
|
|
40
47
|
const routeFlows = this.detectLinearFlows(edges);
|
|
41
48
|
const interactionFlows = this.detectInteractionFlows();
|
|
42
|
-
|
|
49
|
+
const combined = [...routeFlows, ...interactionFlows];
|
|
50
|
+
// Fix 4: remove patterns that appear in too many flows — they are structural
|
|
51
|
+
// navigation chrome (sidebars, footers) rather than user intent signals
|
|
52
|
+
return this.deduplicateCrossFlowNoise(combined);
|
|
43
53
|
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Edge graph
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
44
57
|
buildEdgeGraph() {
|
|
45
58
|
const edges = [];
|
|
46
59
|
const routePaths = new Set(this.routes.map((r) => r.path));
|
|
47
60
|
for (const el of this.elements) {
|
|
48
|
-
|
|
49
|
-
const isNav = el.tag === 'a' ||
|
|
50
|
-
el.isNavigationLink === true;
|
|
61
|
+
const isNav = el.tag === 'a' || el.isNavigationLink === true;
|
|
51
62
|
const href = el.staticProps['href'] || el.staticProps['to'];
|
|
52
63
|
if (!isNav || !href)
|
|
53
64
|
continue;
|
|
54
|
-
// Only build edges to known internal routes
|
|
55
65
|
if (!routePaths.has(href))
|
|
56
66
|
continue;
|
|
57
67
|
const triggerText = el.staticProps['aria-label'] ||
|
|
58
68
|
el.label ||
|
|
59
69
|
el.nearbyText[0] ||
|
|
60
70
|
'link';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
71
|
+
// Fix 3a: demote confidence for elements from layout/footer/header files.
|
|
72
|
+
// These links appear on every page and corrupt flow chains when followed.
|
|
73
|
+
const isLayoutSource = /footer|layout|header|navbar|sidebar|navigation/i.test(el.filePath || '');
|
|
74
|
+
const confidence = isLayoutSource ? 0.3 : 0.85;
|
|
75
|
+
edges.push({ from: el.route, to: href, trigger: triggerText, confidence });
|
|
67
76
|
}
|
|
68
77
|
return edges;
|
|
69
78
|
}
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Linear flow detection
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
70
82
|
detectLinearFlows(edges) {
|
|
71
83
|
const adjacency = new Map();
|
|
72
84
|
for (const edge of edges) {
|
|
@@ -80,12 +92,11 @@ class FlowInferrer {
|
|
|
80
92
|
if (visited.has(route.path))
|
|
81
93
|
continue;
|
|
82
94
|
const chain = this.buildChain(route.path, adjacency, new Set());
|
|
83
|
-
if (chain.length >= 2) {
|
|
95
|
+
if (chain.length >= 2 && this.isCoherentChain(chain)) {
|
|
84
96
|
const chainEdges = edges.filter((e) => {
|
|
85
97
|
const s = new Set(chain);
|
|
86
98
|
return s.has(e.from) && s.has(e.to);
|
|
87
99
|
});
|
|
88
|
-
const intentPatterns = this.generateIntentPatterns(chain, chainEdges);
|
|
89
100
|
flows.push({
|
|
90
101
|
id: `flow_${flows.length + 1}`,
|
|
91
102
|
name: chain
|
|
@@ -94,20 +105,32 @@ class FlowInferrer {
|
|
|
94
105
|
page: chain[0],
|
|
95
106
|
steps: chain,
|
|
96
107
|
edges: chainEdges,
|
|
97
|
-
intentPatterns,
|
|
108
|
+
intentPatterns: this.generateIntentPatterns(chain, chainEdges),
|
|
98
109
|
});
|
|
99
110
|
chain.forEach((p) => visited.add(p));
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
return flows;
|
|
103
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Fix 3b: A coherent chain stays within the same top-level route domain.
|
|
117
|
+
* A chain that jumps from /auth/* to / or from /dashboard/* to /auth/signup
|
|
118
|
+
* is an artifact of footer/layout navigation links, not a real user flow.
|
|
119
|
+
*/
|
|
120
|
+
isCoherentChain(chain) {
|
|
121
|
+
const firstSegment = (p) => p.split('/').filter(Boolean)[0] ?? 'root';
|
|
122
|
+
const domain = firstSegment(chain[0]);
|
|
123
|
+
return chain.every((r) => firstSegment(r) === domain);
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Interaction flow detection
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
104
128
|
detectInteractionFlows() {
|
|
105
129
|
if (!this.components || this.components.length === 0)
|
|
106
130
|
return [];
|
|
107
131
|
const flows = [];
|
|
108
132
|
for (const component of this.components) {
|
|
109
133
|
for (const interaction of component.interactions || []) {
|
|
110
|
-
// Only emit flows for interactions with a meaningful wait strategy
|
|
111
134
|
if (!interaction.trigger?.event)
|
|
112
135
|
continue;
|
|
113
136
|
if (!interaction.effect || interaction.effect.waitStrategy === 'none')
|
|
@@ -138,6 +161,9 @@ class FlowInferrer {
|
|
|
138
161
|
}
|
|
139
162
|
return flows;
|
|
140
163
|
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Chain builder
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
141
167
|
buildChain(start, adjacency, seen) {
|
|
142
168
|
if (seen.has(start))
|
|
143
169
|
return [];
|
|
@@ -148,6 +174,9 @@ class FlowInferrer {
|
|
|
148
174
|
const best = [...next].sort((a, b) => b.confidence - a.confidence)[0];
|
|
149
175
|
return [start, ...this.buildChain(best.to, adjacency, seen)];
|
|
150
176
|
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Intent pattern generation (Fix 4)
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
151
180
|
generateIntentPatterns(chain, edges) {
|
|
152
181
|
const patterns = new Set();
|
|
153
182
|
// 1. Route-segment semantic lookup
|
|
@@ -160,14 +189,52 @@ class FlowInferrer {
|
|
|
160
189
|
known.forEach((p) => patterns.add(p));
|
|
161
190
|
}
|
|
162
191
|
}
|
|
163
|
-
// 2. Link/button trigger text from the edges
|
|
192
|
+
// 2. Link/button trigger text from the edges — apply noise filter
|
|
164
193
|
for (const edge of edges) {
|
|
165
194
|
const trigger = edge.trigger?.trim();
|
|
166
|
-
if (trigger && trigger
|
|
195
|
+
if (trigger && this.isValidIntentPattern(trigger)) {
|
|
167
196
|
patterns.add(trigger.toLowerCase());
|
|
168
197
|
}
|
|
169
198
|
}
|
|
170
199
|
return Array.from(patterns).filter(Boolean);
|
|
171
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Returns true if a string is a usable user intent phrase.
|
|
203
|
+
* Rejects blocklisted words, PascalCase component names, very short strings,
|
|
204
|
+
* and URL-like strings.
|
|
205
|
+
*/
|
|
206
|
+
isValidIntentPattern(pattern) {
|
|
207
|
+
if (!pattern || pattern.length < 4)
|
|
208
|
+
return false;
|
|
209
|
+
const lower = pattern.toLowerCase();
|
|
210
|
+
if (INTENT_BLOCKLIST.has(lower))
|
|
211
|
+
return false;
|
|
212
|
+
// Reject PascalCase strings — these are component names leaking through
|
|
213
|
+
if (/^[A-Z][a-zA-Z0-9]+$/.test(pattern))
|
|
214
|
+
return false;
|
|
215
|
+
// Reject paths and URLs
|
|
216
|
+
if (pattern.startsWith('/') || pattern.startsWith('http'))
|
|
217
|
+
return false;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Fix 4: Remove intent patterns that appear across more than 30% of all flows.
|
|
222
|
+
* These are structural navigation phrases (sidebar, footer links) that ended up
|
|
223
|
+
* attached to many unrelated flows rather than genuine user intents.
|
|
224
|
+
*/
|
|
225
|
+
deduplicateCrossFlowNoise(flows) {
|
|
226
|
+
if (flows.length === 0)
|
|
227
|
+
return flows;
|
|
228
|
+
const threshold = Math.max(3, Math.ceil(flows.length * 0.3));
|
|
229
|
+
const counts = new Map();
|
|
230
|
+
flows.forEach((f) => (f.intentPatterns ?? []).forEach((p) => counts.set(p, (counts.get(p) ?? 0) + 1)));
|
|
231
|
+
const globalNoise = new Set([...counts.entries()].filter(([, n]) => n > threshold).map(([p]) => p));
|
|
232
|
+
if (globalNoise.size === 0)
|
|
233
|
+
return flows;
|
|
234
|
+
return flows.map((f) => ({
|
|
235
|
+
...f,
|
|
236
|
+
intentPatterns: (f.intentPatterns ?? []).filter((p) => !globalNoise.has(p)),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
172
239
|
}
|
|
173
240
|
exports.FlowInferrer = FlowInferrer;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { DiscoveredElement, ElementWithSelectors } from '../types';
|
|
2
|
+
import type { InjectedEntry } from '../injection/ClippyIdInjector';
|
|
2
3
|
export declare class SelectorGenerator {
|
|
3
|
-
generate(elements: DiscoveredElement[], injectedMap?: Record<string,
|
|
4
|
-
clippyId: string;
|
|
5
|
-
component: string;
|
|
6
|
-
tag: string;
|
|
7
|
-
line: number;
|
|
8
|
-
}>>): ElementWithSelectors[];
|
|
4
|
+
generate(elements: DiscoveredElement[], injectedMap?: Record<string, InjectedEntry[]>): ElementWithSelectors[];
|
|
9
5
|
private generateForElement;
|
|
10
6
|
}
|
|
@@ -16,14 +16,19 @@ class SelectorGenerator {
|
|
|
16
16
|
if (injectedMap && el.filePath) {
|
|
17
17
|
const entries = injectedMap[el.filePath];
|
|
18
18
|
if (entries && entries.length) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const elLine = el.loc?.line;
|
|
20
|
+
const elLabel = el.label;
|
|
21
|
+
// 1. Exact line match — most reliable
|
|
22
|
+
let match = elLine ? entries.find((e) => e.line === elLine) : null;
|
|
23
|
+
// 2. Label + tag match — handles multiple same-tag elements in one file
|
|
24
|
+
// (e.g. two <button> tags where line lookup fails due to AST offset)
|
|
25
|
+
if (!match && elLabel) {
|
|
26
|
+
match = entries.find((e) => e.tag === el.tag && e.label === elLabel) ?? null;
|
|
23
27
|
}
|
|
24
|
-
//
|
|
28
|
+
// 3. Tag-only fallback — last resort, may pick the wrong element when
|
|
29
|
+
// multiple same-tag elements exist but is better than no clippy_id at all
|
|
25
30
|
if (!match) {
|
|
26
|
-
match = entries.find((e) => e.tag === el.tag);
|
|
31
|
+
match = entries.find((e) => e.tag === el.tag) ?? null;
|
|
27
32
|
}
|
|
28
33
|
if (match) {
|
|
29
34
|
candidates.push({
|
|
@@ -49,7 +49,7 @@ function injectClippyIds(source, filePath, routePath) {
|
|
|
49
49
|
}
|
|
50
50
|
const line = opening.loc?.start.line ?? 0;
|
|
51
51
|
// Extract visible label text from element children for ID enrichment
|
|
52
|
-
const labelText = extractLabelFromOpeningElement(path, attrs);
|
|
52
|
+
const labelText = extractLabelFromOpeningElement(path, attrs, tagName);
|
|
53
53
|
const clippyId = (0, IdStrategy_1.deriveClippyId)(componentName, tagName, line, routePath, labelText ?? undefined);
|
|
54
54
|
const componentAttr = (0, HtmlTagGuards_1.hasAttribute)(attrs, 'data-clippy-component')
|
|
55
55
|
? ''
|
|
@@ -77,8 +77,10 @@ function injectClippyIds(source, filePath, routePath) {
|
|
|
77
77
|
/**
|
|
78
78
|
* Extract a usable label from the element's attributes and children.
|
|
79
79
|
* Priority: aria-label > title > visible text content > placeholder > name.
|
|
80
|
+
* For form elements: only aria-label or the last short text (submit button),
|
|
81
|
+
* never the full concatenation of all child field labels.
|
|
80
82
|
*/
|
|
81
|
-
function extractLabelFromOpeningElement(path, attrs) {
|
|
83
|
+
function extractLabelFromOpeningElement(path, attrs, tagName) {
|
|
82
84
|
// Check attributes first — fastest path
|
|
83
85
|
for (const attr of attrs) {
|
|
84
86
|
if (attr.type !== 'JSXAttribute')
|
|
@@ -96,11 +98,17 @@ function extractLabelFromOpeningElement(path, attrs) {
|
|
|
96
98
|
const parent = path.parentPath?.node;
|
|
97
99
|
if (parent?.children) {
|
|
98
100
|
const text = extractTextFromChildren(parent.children, 0);
|
|
101
|
+
if (tagName === 'form') {
|
|
102
|
+
// Forms: only use the last short text node (submit button label), not the full
|
|
103
|
+
// concatenation of every field label inside the form.
|
|
104
|
+
const lastShort = [...text].reverse().find((t) => t.length >= 2 && t.length <= 30);
|
|
105
|
+
return lastShort || null;
|
|
106
|
+
}
|
|
99
107
|
const joined = text.join(' ').trim();
|
|
100
108
|
if (joined && joined.length <= 60) {
|
|
101
109
|
const sanitized = (0, IdStrategy_1.sanitizeLabelForId)(joined);
|
|
102
110
|
if (sanitized)
|
|
103
|
-
return joined;
|
|
111
|
+
return joined;
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
// Fallback to placeholder / name for inputs
|
|
@@ -3,6 +3,7 @@ export declare class PackageBuilder {
|
|
|
3
3
|
private version;
|
|
4
4
|
buildPackage(data: Omit<KnowledgePackage, 'generatedAt'>): KnowledgePackage;
|
|
5
5
|
buildArtifacts(data: {
|
|
6
|
+
projectRoot?: string;
|
|
6
7
|
buildId: string;
|
|
7
8
|
bundler: 'webpack' | 'vite';
|
|
8
9
|
routes: DiscoveredRoute[];
|
|
@@ -11,6 +12,7 @@ export declare class PackageBuilder {
|
|
|
11
12
|
components?: PolicyComponent[];
|
|
12
13
|
}): PolicyArtifacts;
|
|
13
14
|
buildPolicyDocument(data: {
|
|
15
|
+
projectRoot?: string;
|
|
14
16
|
buildId: string;
|
|
15
17
|
generatedAt: string;
|
|
16
18
|
bundler: 'webpack' | 'vite';
|
|
@@ -26,13 +28,21 @@ export declare class PackageBuilder {
|
|
|
26
28
|
}): SelectorManifest;
|
|
27
29
|
build(data: Omit<KnowledgePackage, 'generatedAt'>): Buffer;
|
|
28
30
|
buildArtifactsBuffer(artifacts: PolicyArtifacts): Buffer;
|
|
31
|
+
/**
|
|
32
|
+
* Compute a fallback project root from the common ancestor of route file paths.
|
|
33
|
+
* Used only when the adapter does not provide an explicit projectRoot.
|
|
34
|
+
*/
|
|
29
35
|
private inferProjectRoot;
|
|
30
36
|
private normalizePath;
|
|
37
|
+
/**
|
|
38
|
+
* Remove design-system primitives and other shared components that carry no
|
|
39
|
+
* state or interaction data. Normalises filePaths against the project root.
|
|
40
|
+
*/
|
|
41
|
+
private filterMeaningfulComponents;
|
|
31
42
|
private toPolicySelectors;
|
|
32
43
|
private toPolicyComponents;
|
|
33
44
|
private toPolicyFlows;
|
|
34
45
|
private deriveComponentName;
|
|
35
46
|
private deriveStableId;
|
|
36
47
|
private extractClippyIdFromSelector;
|
|
37
|
-
private simpleHash;
|
|
38
48
|
}
|
|
@@ -40,13 +40,20 @@ class PackageBuilder {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
buildPolicyDocument(data) {
|
|
43
|
-
|
|
43
|
+
// Use the adapter-provided project root (most reliable).
|
|
44
|
+
// Fall back to computing the common ancestor of route paths only when unavailable.
|
|
45
|
+
const projectRoot = data.projectRoot || this.inferProjectRoot(data.routes);
|
|
44
46
|
const normalizedRoutes = data.routes.map((r) => ({
|
|
45
47
|
...r,
|
|
46
48
|
filePath: this.normalizePath(r.filePath, projectRoot),
|
|
47
49
|
layout: r.layout ? this.normalizePath(r.layout, projectRoot) : null,
|
|
48
50
|
}));
|
|
49
51
|
const policySelectors = this.toPolicySelectors(data.selectors, projectRoot);
|
|
52
|
+
// Fix 2: only keep components that carry meaningful data (state or interactions).
|
|
53
|
+
// Pure design-system primitives (Button, Card, Input, etc.) with empty arrays
|
|
54
|
+
// are excluded — they bloat the policy without adding actionable information.
|
|
55
|
+
const rawComponents = data.components ?? this.toPolicyComponents(data.selectors, projectRoot);
|
|
56
|
+
const meaningfulComponents = this.filterMeaningfulComponents(rawComponents, projectRoot);
|
|
50
57
|
return {
|
|
51
58
|
version: this.version,
|
|
52
59
|
buildId: data.buildId,
|
|
@@ -54,7 +61,7 @@ class PackageBuilder {
|
|
|
54
61
|
bundler: data.bundler,
|
|
55
62
|
routes: normalizedRoutes,
|
|
56
63
|
selectors: policySelectors,
|
|
57
|
-
components:
|
|
64
|
+
components: meaningfulComponents,
|
|
58
65
|
flows: this.toPolicyFlows(data.flows, normalizedRoutes, policySelectors),
|
|
59
66
|
};
|
|
60
67
|
}
|
|
@@ -94,11 +101,17 @@ class PackageBuilder {
|
|
|
94
101
|
buildArtifactsBuffer(artifacts) {
|
|
95
102
|
return zlib_1.default.gzipSync(JSON.stringify(artifacts));
|
|
96
103
|
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Path helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* Compute a fallback project root from the common ancestor of route file paths.
|
|
109
|
+
* Used only when the adapter does not provide an explicit projectRoot.
|
|
110
|
+
*/
|
|
97
111
|
inferProjectRoot(routes) {
|
|
98
112
|
const filePaths = routes.map((r) => r.filePath).filter(Boolean);
|
|
99
113
|
if (filePaths.length === 0)
|
|
100
114
|
return process.cwd();
|
|
101
|
-
// Find the longest common directory prefix across all route file paths
|
|
102
115
|
const parts = filePaths[0].replace(/\\/g, '/').split('/');
|
|
103
116
|
let commonParts = parts;
|
|
104
117
|
for (const fp of filePaths.slice(1)) {
|
|
@@ -109,17 +122,45 @@ class PackageBuilder {
|
|
|
109
122
|
i++;
|
|
110
123
|
commonParts = commonParts.slice(0, i);
|
|
111
124
|
}
|
|
112
|
-
|
|
125
|
+
// Common ancestor of route files is typically the `app/` dir.
|
|
126
|
+
// Go one level up to reach the actual project root.
|
|
127
|
+
if (commonParts.length > 0) {
|
|
128
|
+
const candidate = commonParts.join('/');
|
|
129
|
+
const parent = candidate.includes('/') ? candidate.replace(/\/[^/]+$/, '') : candidate;
|
|
130
|
+
if (parent)
|
|
131
|
+
return parent;
|
|
132
|
+
}
|
|
133
|
+
return process.cwd();
|
|
113
134
|
}
|
|
114
135
|
normalizePath(filePath, projectRoot) {
|
|
115
136
|
try {
|
|
116
137
|
const rel = path_1.default.relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
138
|
+
// Only use the relative path if it doesn't escape above the project root
|
|
117
139
|
return rel.startsWith('..') ? filePath.replace(/\\/g, '/') : rel;
|
|
118
140
|
}
|
|
119
141
|
catch {
|
|
120
142
|
return filePath.replace(/\\/g, '/');
|
|
121
143
|
}
|
|
122
144
|
}
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Component filtering (Fix 2)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
/**
|
|
149
|
+
* Remove design-system primitives and other shared components that carry no
|
|
150
|
+
* state or interaction data. Normalises filePaths against the project root.
|
|
151
|
+
*/
|
|
152
|
+
filterMeaningfulComponents(components, projectRoot) {
|
|
153
|
+
return components
|
|
154
|
+
.filter((c) => (c.stateVariables && c.stateVariables.length > 0) ||
|
|
155
|
+
(c.interactions && c.interactions.length > 0))
|
|
156
|
+
.map((c) => ({
|
|
157
|
+
...c,
|
|
158
|
+
filePath: this.normalizePath(c.filePath, projectRoot),
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Selector conversion
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
123
164
|
toPolicySelectors(selectors, projectRoot) {
|
|
124
165
|
return selectors
|
|
125
166
|
.filter((element) => !element.isNavigationLink)
|
|
@@ -132,8 +173,7 @@ class PackageBuilder {
|
|
|
132
173
|
? this.extractClippyIdFromSelector(preferred.value) ||
|
|
133
174
|
this.deriveStableId(component, element.tag, stableLine)
|
|
134
175
|
: this.deriveStableId(component, element.tag, stableLine);
|
|
135
|
-
const selector = preferred?.value ||
|
|
136
|
-
`[data-clippy-id='${clippyId}']`;
|
|
176
|
+
const selector = preferred?.value || `[data-clippy-id='${clippyId}']`;
|
|
137
177
|
return {
|
|
138
178
|
clippyId,
|
|
139
179
|
selector,
|
|
@@ -141,13 +181,15 @@ class PackageBuilder {
|
|
|
141
181
|
component,
|
|
142
182
|
label: element.label,
|
|
143
183
|
route: element.route,
|
|
144
|
-
filePath: projectRoot
|
|
184
|
+
filePath: projectRoot
|
|
185
|
+
? this.normalizePath(element.filePath, projectRoot)
|
|
186
|
+
: element.filePath.replace(/\\/g, '/'),
|
|
145
187
|
attributes: Object.entries(element.staticProps).map(([name, value]) => ({ name, value })),
|
|
146
188
|
candidates: element.selectors,
|
|
147
189
|
};
|
|
148
190
|
});
|
|
149
191
|
}
|
|
150
|
-
toPolicyComponents(selectors) {
|
|
192
|
+
toPolicyComponents(selectors, projectRoot) {
|
|
151
193
|
const byFile = new Map();
|
|
152
194
|
for (const element of selectors) {
|
|
153
195
|
if (element.isNavigationLink)
|
|
@@ -157,32 +199,28 @@ class PackageBuilder {
|
|
|
157
199
|
byFile.get(element.filePath).push(element);
|
|
158
200
|
}
|
|
159
201
|
return Array.from(byFile.entries()).map(([filePath, items]) => {
|
|
160
|
-
|
|
161
|
-
const componentName = items.find((i) => i.component)?.component ||
|
|
162
|
-
this.deriveComponentName(filePath);
|
|
202
|
+
const componentName = items.find((i) => i.component)?.component || this.deriveComponentName(filePath);
|
|
163
203
|
const route = items[0]?.route || '/';
|
|
164
204
|
return {
|
|
165
205
|
name: componentName,
|
|
166
|
-
filePath,
|
|
206
|
+
filePath: projectRoot ? this.normalizePath(filePath, projectRoot) : filePath.replace(/\\/g, '/'),
|
|
167
207
|
route,
|
|
168
208
|
stateVariables: [],
|
|
169
209
|
interactions: [],
|
|
170
210
|
};
|
|
171
211
|
});
|
|
172
212
|
}
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Flow conversion
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
173
216
|
toPolicyFlows(flows, routes, selectors) {
|
|
174
217
|
return flows.map((flow, flowIndex) => {
|
|
175
218
|
const firstRoute = flow.steps[0] || '/';
|
|
176
219
|
const page = flow.page || routes.find((r) => r.path === firstRoute)?.path || firstRoute;
|
|
177
220
|
const steps = flow.steps.map((routePath, stepIndex) => {
|
|
178
|
-
|
|
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;
|
|
221
|
+
const incomingEdge = stepIndex > 0 ? flow.edges.find((e) => e.to === routePath) : null;
|
|
183
222
|
let target;
|
|
184
223
|
if (incomingEdge?.trigger && incomingEdge.trigger !== 'link') {
|
|
185
|
-
// Try to find the specific triggering element by label match
|
|
186
224
|
const fromRoute = incomingEdge.from;
|
|
187
225
|
const byLabel = selectors.find((s) => s.route === fromRoute &&
|
|
188
226
|
s.label?.toLowerCase() === incomingEdge.trigger.toLowerCase());
|
|
@@ -192,7 +230,6 @@ class PackageBuilder {
|
|
|
192
230
|
: null;
|
|
193
231
|
target = (byLabel || byAttr)?.selector;
|
|
194
232
|
}
|
|
195
|
-
// Fallback: first selector on the destination route
|
|
196
233
|
if (!target) {
|
|
197
234
|
target =
|
|
198
235
|
selectors.find((s) => s.route === routePath)?.selector ||
|
|
@@ -202,9 +239,7 @@ class PackageBuilder {
|
|
|
202
239
|
const isInteractionStep = flow.edges.some((e) => e.from === e.to && e.trigger?.startsWith('on'));
|
|
203
240
|
return {
|
|
204
241
|
step: stepIndex + 1,
|
|
205
|
-
action: isFirst
|
|
206
|
-
? isInteractionStep ? 'interact' : 'navigate'
|
|
207
|
-
: 'transition',
|
|
242
|
+
action: isFirst ? (isInteractionStep ? 'interact' : 'navigate') : 'transition',
|
|
208
243
|
target,
|
|
209
244
|
};
|
|
210
245
|
});
|
|
@@ -219,6 +254,9 @@ class PackageBuilder {
|
|
|
219
254
|
};
|
|
220
255
|
});
|
|
221
256
|
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Helpers
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
222
260
|
deriveComponentName(filePath) {
|
|
223
261
|
return (0, IdStrategy_1.deriveComponentName)(filePath);
|
|
224
262
|
}
|
|
@@ -229,12 +267,5 @@ class PackageBuilder {
|
|
|
229
267
|
const match = selector.match(/\[data-clippy-id=['\"]([^'\"]+)['\"]\]/);
|
|
230
268
|
return match?.[1] || null;
|
|
231
269
|
}
|
|
232
|
-
simpleHash(input) {
|
|
233
|
-
let hash = 0;
|
|
234
|
-
for (let i = 0; i < input.length; i++) {
|
|
235
|
-
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
|
|
236
|
-
}
|
|
237
|
-
return hash.toString(36);
|
|
238
|
-
}
|
|
239
270
|
}
|
|
240
271
|
exports.PackageBuilder = PackageBuilder;
|