@dcoder-x/plugin-shared 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -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,11 +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
+ */
35
+ private inferProjectRoot;
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;
29
42
  private toPolicySelectors;
30
43
  private toPolicyComponents;
31
44
  private toPolicyFlows;
32
45
  private deriveComponentName;
33
46
  private deriveStableId;
34
47
  private extractClippyIdFromSelector;
35
- private simpleHash;
36
48
  }
@@ -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,58 @@ class PackageBuilder {
39
40
  };
40
41
  }
41
42
  buildPolicyDocument(data) {
42
- const policySelectors = this.toPolicySelectors(data.selectors);
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);
46
+ const normalizedRoutes = data.routes.map((r) => ({
47
+ ...r,
48
+ filePath: this.normalizePath(r.filePath, projectRoot),
49
+ layout: r.layout ? this.normalizePath(r.layout, projectRoot) : null,
50
+ }));
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);
43
57
  return {
44
58
  version: this.version,
45
59
  buildId: data.buildId,
46
60
  generatedAt: data.generatedAt,
47
61
  bundler: data.bundler,
48
- routes: data.routes,
62
+ routes: normalizedRoutes,
49
63
  selectors: policySelectors,
50
- components: data.components ?? this.toPolicyComponents(data.selectors),
51
- flows: this.toPolicyFlows(data.flows, data.routes, policySelectors),
64
+ components: meaningfulComponents,
65
+ flows: this.toPolicyFlows(data.flows, normalizedRoutes, policySelectors),
52
66
  };
53
67
  }
54
68
  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
- }));
69
+ // Deduplicate by clippyId shared components used on multiple routes appear once
70
+ // with a routes[] array listing every route where they appear.
71
+ const byId = new Map();
72
+ for (const entry of data.selectors) {
73
+ if (byId.has(entry.clippyId)) {
74
+ const existing = byId.get(entry.clippyId);
75
+ if (!existing.routes.includes(entry.route)) {
76
+ existing.routes.push(entry.route);
77
+ }
78
+ }
79
+ else {
80
+ byId.set(entry.clippyId, {
81
+ id: entry.clippyId,
82
+ selector: entry.selector,
83
+ component: entry.component,
84
+ tag: entry.tag,
85
+ label: entry.label,
86
+ routes: [entry.route],
87
+ });
88
+ }
89
+ }
62
90
  return {
63
91
  version: this.version,
64
92
  buildId: data.buildId,
65
93
  generatedAt: data.generatedAt,
66
- selectors,
94
+ selectors: Array.from(byId.values()),
67
95
  };
68
96
  }
69
97
  build(data) {
@@ -73,9 +101,71 @@ class PackageBuilder {
73
101
  buildArtifactsBuffer(artifacts) {
74
102
  return zlib_1.default.gzipSync(JSON.stringify(artifacts));
75
103
  }
76
- toPolicySelectors(selectors) {
77
- return selectors.map((element, index) => {
78
- const component = (0, IdStrategy_1.deriveComponentName)(element.filePath);
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
+ */
111
+ inferProjectRoot(routes) {
112
+ const filePaths = routes.map((r) => r.filePath).filter(Boolean);
113
+ if (filePaths.length === 0)
114
+ return process.cwd();
115
+ const parts = filePaths[0].replace(/\\/g, '/').split('/');
116
+ let commonParts = parts;
117
+ for (const fp of filePaths.slice(1)) {
118
+ const fpParts = fp.replace(/\\/g, '/').split('/');
119
+ const len = Math.min(commonParts.length, fpParts.length);
120
+ let i = 0;
121
+ while (i < len && commonParts[i] === fpParts[i])
122
+ i++;
123
+ commonParts = commonParts.slice(0, i);
124
+ }
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();
134
+ }
135
+ normalizePath(filePath, projectRoot) {
136
+ try {
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
139
+ return rel.startsWith('..') ? filePath.replace(/\\/g, '/') : rel;
140
+ }
141
+ catch {
142
+ return filePath.replace(/\\/g, '/');
143
+ }
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
+ // ---------------------------------------------------------------------------
164
+ toPolicySelectors(selectors, projectRoot) {
165
+ return selectors
166
+ .filter((element) => !element.isNavigationLink)
167
+ .map((element, index) => {
168
+ const component = element.component || (0, IdStrategy_1.deriveComponentName)(element.filePath);
79
169
  const preferred = element.selectors.find((candidate) => candidate.type === 'clippy_id') ||
80
170
  element.selectors[0];
81
171
  const stableLine = element.loc?.line ?? index;
@@ -83,65 +173,90 @@ class PackageBuilder {
83
173
  ? this.extractClippyIdFromSelector(preferred.value) ||
84
174
  this.deriveStableId(component, element.tag, stableLine)
85
175
  : this.deriveStableId(component, element.tag, stableLine);
86
- const selector = preferred?.value ||
87
- `[data-clippy-id='${clippyId}']`;
176
+ const selector = preferred?.value || `[data-clippy-id='${clippyId}']`;
88
177
  return {
89
178
  clippyId,
90
179
  selector,
91
180
  tag: element.tag,
92
181
  component,
182
+ label: element.label,
93
183
  route: element.route,
94
- filePath: element.filePath,
184
+ filePath: projectRoot
185
+ ? this.normalizePath(element.filePath, projectRoot)
186
+ : element.filePath.replace(/\\/g, '/'),
95
187
  attributes: Object.entries(element.staticProps).map(([name, value]) => ({ name, value })),
96
188
  candidates: element.selectors,
97
189
  };
98
190
  });
99
191
  }
100
- toPolicyComponents(selectors) {
192
+ toPolicyComponents(selectors, projectRoot) {
101
193
  const byFile = new Map();
102
194
  for (const element of selectors) {
195
+ if (element.isNavigationLink)
196
+ continue;
103
197
  if (!byFile.has(element.filePath))
104
198
  byFile.set(element.filePath, []);
105
199
  byFile.get(element.filePath).push(element);
106
200
  }
107
201
  return Array.from(byFile.entries()).map(([filePath, items]) => {
108
- const componentName = this.deriveComponentName(filePath);
202
+ const componentName = items.find((i) => i.component)?.component || this.deriveComponentName(filePath);
109
203
  const route = items[0]?.route || '/';
110
204
  return {
111
205
  name: componentName,
112
- filePath,
206
+ filePath: projectRoot ? this.normalizePath(filePath, projectRoot) : filePath.replace(/\\/g, '/'),
113
207
  route,
114
208
  stateVariables: [],
115
209
  interactions: [],
116
210
  };
117
211
  });
118
212
  }
213
+ // ---------------------------------------------------------------------------
214
+ // Flow conversion
215
+ // ---------------------------------------------------------------------------
119
216
  toPolicyFlows(flows, routes, selectors) {
120
217
  return flows.map((flow, flowIndex) => {
121
218
  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'));
219
+ const page = flow.page || routes.find((r) => r.path === firstRoute)?.path || firstRoute;
124
220
  const steps = flow.steps.map((routePath, stepIndex) => {
125
- const target = selectors.find((entry) => entry.route === routePath)?.selector ||
126
- `[data-clippy-route='${routePath}']`;
221
+ const incomingEdge = stepIndex > 0 ? flow.edges.find((e) => e.to === routePath) : null;
222
+ let target;
223
+ if (incomingEdge?.trigger && incomingEdge.trigger !== 'link') {
224
+ const fromRoute = incomingEdge.from;
225
+ const byLabel = selectors.find((s) => s.route === fromRoute &&
226
+ s.label?.toLowerCase() === incomingEdge.trigger.toLowerCase());
227
+ const byAttr = !byLabel
228
+ ? selectors.find((s) => s.route === fromRoute &&
229
+ s.attributes.some((a) => a.value.toLowerCase() === incomingEdge.trigger.toLowerCase()))
230
+ : null;
231
+ target = (byLabel || byAttr)?.selector;
232
+ }
233
+ if (!target) {
234
+ target =
235
+ selectors.find((s) => s.route === routePath)?.selector ||
236
+ `[data-clippy-route='${routePath}']`;
237
+ }
238
+ const isFirst = stepIndex === 0;
239
+ const isInteractionStep = flow.edges.some((e) => e.from === e.to && e.trigger?.startsWith('on'));
127
240
  return {
128
241
  step: stepIndex + 1,
129
- action: stepIndex === 0
130
- ? hasInteraction
131
- ? 'interact'
132
- : 'navigate'
133
- : 'transition',
242
+ action: isFirst ? (isInteractionStep ? 'interact' : 'navigate') : 'transition',
134
243
  target,
135
244
  };
136
245
  });
246
+ const intentPatterns = flow.intentPatterns && flow.intentPatterns.length > 0
247
+ ? flow.intentPatterns
248
+ : [flow.name.toLowerCase()];
137
249
  return {
138
250
  flowId: flow.id || `flow_${flowIndex + 1}`,
139
251
  page,
140
- intentPatterns: [flow.name.toLowerCase()],
252
+ intentPatterns,
141
253
  steps,
142
254
  };
143
255
  });
144
256
  }
257
+ // ---------------------------------------------------------------------------
258
+ // Helpers
259
+ // ---------------------------------------------------------------------------
145
260
  deriveComponentName(filePath) {
146
261
  return (0, IdStrategy_1.deriveComponentName)(filePath);
147
262
  }
@@ -152,12 +267,5 @@ class PackageBuilder {
152
267
  const match = selector.match(/\[data-clippy-id=['\"]([^'\"]+)['\"]\]/);
153
268
  return match?.[1] || null;
154
269
  }
155
- simpleHash(input) {
156
- let hash = 0;
157
- for (let i = 0; i < input.length; i++) {
158
- hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
159
- }
160
- return hash.toString(36);
161
- }
162
270
  }
163
271
  exports.PackageBuilder = PackageBuilder;
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.6",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",