@dcoder-x/plugin-shared 0.1.6 → 0.1.8
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/README.md +300 -0
- package/dist/extractors/RouteExtractor.js +8 -4
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# @dcoder-x/plugin-shared
|
|
2
|
+
|
|
3
|
+
Internal shared library for the Clippy build plugin ecosystem. Contains the AST extractors, selector generators, flow inferrers, and package builders used by `@dcoder-x/next` and `@dcoder-x/vite`.
|
|
4
|
+
|
|
5
|
+
**You do not need to install this package directly.** It is a transitive dependency of the adapter packages and is installed automatically.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## If you are building a custom adapter
|
|
10
|
+
|
|
11
|
+
If you are integrating Clippy with a bundler other than Next.js or Vite, this package provides the full pipeline as composable classes.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @dcoder-x/plugin-shared
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Pipeline overview
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Source files
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
ClippyIdInjector.injectClippyIds(source, filePath, routePath?)
|
|
24
|
+
│ Injects data-clippy-id and data-clippy-component into compiled HTML elements
|
|
25
|
+
▼
|
|
26
|
+
RouteExtractor.extract()
|
|
27
|
+
│ Discovers all routes from filesystem conventions or React Router AST
|
|
28
|
+
▼
|
|
29
|
+
ComponentExtractor.extract() ComponentExtractor.extractComponents()
|
|
30
|
+
│ Finds all interactive elements │ Extracts state, handlers, interaction graphs
|
|
31
|
+
▼ ▼
|
|
32
|
+
SelectorGenerator.generate(elements, injectedMap)
|
|
33
|
+
│ Matches injected IDs to elements, ranks selector candidates
|
|
34
|
+
▼
|
|
35
|
+
FlowInferrer.infer()
|
|
36
|
+
│ Builds navigation edge graph, detects flow chains, generates intent patterns
|
|
37
|
+
▼
|
|
38
|
+
PackageBuilder.buildArtifacts()
|
|
39
|
+
│ Assembles clippy-policy.json and clippy-selectors.json
|
|
40
|
+
▼
|
|
41
|
+
PackageWriter.writeArtifacts() / Uploader.upload()
|
|
42
|
+
Writes JSON files locally Uploads to Clippy backend
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Minimal custom adapter
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { injectClippyIds, inferRouteFromFilePath } from '@dcoder-x/plugin-shared'
|
|
49
|
+
import { RouteExtractor } from '@dcoder-x/plugin-shared/extractors/RouteExtractor'
|
|
50
|
+
import { ComponentExtractor } from '@dcoder-x/plugin-shared/extractors/ComponentExtractor'
|
|
51
|
+
import { SelectorGenerator } from '@dcoder-x/plugin-shared/extractors/SelectorGenerator'
|
|
52
|
+
import { FlowInferrer } from '@dcoder-x/plugin-shared/extractors/FlowInferrer'
|
|
53
|
+
import { PackageBuilder } from '@dcoder-x/plugin-shared/upload/PackageBuilder'
|
|
54
|
+
import { PackageWriter } from '@dcoder-x/plugin-shared/upload/PackageWriter'
|
|
55
|
+
import type { ClippyPluginOptions } from '@dcoder-x/plugin-shared'
|
|
56
|
+
|
|
57
|
+
async function runClippyPipeline(
|
|
58
|
+
projectRoot: string,
|
|
59
|
+
buildId: string,
|
|
60
|
+
moduleGraph: Map<string, { id: string; importedIds: readonly string[] }>,
|
|
61
|
+
injectedMap: Record<string, Array<{ clippyId: string; component: string; tag: string; line: number; label?: string }>>,
|
|
62
|
+
options: ClippyPluginOptions
|
|
63
|
+
) {
|
|
64
|
+
const routes = await new RouteExtractor(projectRoot).extract()
|
|
65
|
+
|
|
66
|
+
const extractor = new ComponentExtractor(
|
|
67
|
+
{ type: 'rollup', moduleGraph },
|
|
68
|
+
routes
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const elements = await extractor.extract()
|
|
72
|
+
const components = await extractor.extractComponents()
|
|
73
|
+
const selectors = new SelectorGenerator().generate(elements, injectedMap)
|
|
74
|
+
const flows = new FlowInferrer(routes, elements, components).infer()
|
|
75
|
+
|
|
76
|
+
const artifacts = new PackageBuilder().buildArtifacts({
|
|
77
|
+
projectRoot,
|
|
78
|
+
buildId,
|
|
79
|
+
bundler: 'vite', // or 'webpack'
|
|
80
|
+
routes,
|
|
81
|
+
selectors,
|
|
82
|
+
flows,
|
|
83
|
+
components,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (options.localOutputDir) {
|
|
87
|
+
new PackageWriter().writeArtifacts(options.localOutputDir, artifacts)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Transform hook (injection)
|
|
93
|
+
|
|
94
|
+
Call `injectClippyIds` in your bundler's transform hook for each `.tsx` / `.jsx` / `.ts` / `.js` file:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// In your bundler's transform/loader:
|
|
98
|
+
const routePath = inferRouteFromFilePath(filePath) ?? undefined
|
|
99
|
+
const result = injectClippyIds(sourceCode, filePath, routePath)
|
|
100
|
+
|
|
101
|
+
// result.source — transformed source with data-clippy-id attributes
|
|
102
|
+
// result.injected — metadata array for building the injectedMap
|
|
103
|
+
// result.injectedCount — number of attributes injected (0 = file had no HTML elements)
|
|
104
|
+
|
|
105
|
+
if (result.injectedCount > 0) {
|
|
106
|
+
injectedMap[filePath] = result.injected
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`inferRouteFromFilePath` detects App Router (`app/**/page.tsx`) and Pages Router (`pages/**/*.tsx`) conventions and returns the route path string (e.g., `/dashboard/forms`). Returns `null` for non-route files.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Exported API
|
|
115
|
+
|
|
116
|
+
### Injection
|
|
117
|
+
|
|
118
|
+
| Export | Description |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `injectClippyIds(source, filePath, routePath?)` | Injects `data-clippy-id` and `data-clippy-component` into all native HTML elements in a JSX/TSX source string |
|
|
121
|
+
| `inferRouteFromFilePath(filePath)` | Derives route path from an absolute file path for App Router / Pages Router files |
|
|
122
|
+
| `deriveClippyId(component, tag, line, routePath?, label?)` | Computes a stable `data-clippy-id` value |
|
|
123
|
+
| `deriveRouteComponentName(routePath)` | Converts `/admin/transactions` → `AdminTransactions` |
|
|
124
|
+
| `GENERIC_COMPONENT_NAMES` | Set of component names treated as generic (Page, Layout, App, etc.) |
|
|
125
|
+
|
|
126
|
+
### Extractors
|
|
127
|
+
|
|
128
|
+
| Export | Description |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `RouteExtractor` | Filesystem + AST route discovery |
|
|
131
|
+
| `ComponentExtractor` | Module-graph traversal, element extraction, component analysis |
|
|
132
|
+
| `SelectorGenerator` | Ranks and matches selector candidates for each element |
|
|
133
|
+
| `FlowInferrer` | Navigation edge graph construction and flow chain detection |
|
|
134
|
+
| `InteractionGraphExtractor` | AST extraction of state → conditional render chains |
|
|
135
|
+
| `ComponentContextResolver` | useState / useReducer / event handler extraction |
|
|
136
|
+
|
|
137
|
+
### Output
|
|
138
|
+
|
|
139
|
+
| Export | Description |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `PackageBuilder` | Assembles `PolicyArtifacts` from pipeline output |
|
|
142
|
+
| `PackageWriter` | Writes `clippy-policy.json` and `clippy-selectors.json` to disk |
|
|
143
|
+
| `Uploader` | HTTP upload client for the Clippy backend |
|
|
144
|
+
|
|
145
|
+
### Types
|
|
146
|
+
|
|
147
|
+
All public types are exported from the package root:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import type {
|
|
151
|
+
ClippyPluginOptions,
|
|
152
|
+
PolicyArtifacts,
|
|
153
|
+
PolicyDocument,
|
|
154
|
+
PolicyComponent,
|
|
155
|
+
PolicyFlow,
|
|
156
|
+
PolicySelectorEntry,
|
|
157
|
+
SelectorManifest,
|
|
158
|
+
SelectorManifestEntry,
|
|
159
|
+
DiscoveredRoute,
|
|
160
|
+
DiscoveredElement,
|
|
161
|
+
ElementWithSelectors,
|
|
162
|
+
SelectorCandidate,
|
|
163
|
+
ComponentInteraction,
|
|
164
|
+
TriggerSpec,
|
|
165
|
+
EffectSpec,
|
|
166
|
+
InferredFlow,
|
|
167
|
+
FlowEdge,
|
|
168
|
+
UploadResult,
|
|
169
|
+
} from '@dcoder-x/plugin-shared'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Output artifact formats
|
|
175
|
+
|
|
176
|
+
### `clippy-policy.json` — full policy document
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
interface PolicyDocument {
|
|
180
|
+
version: string
|
|
181
|
+
buildId: string
|
|
182
|
+
generatedAt: string
|
|
183
|
+
bundler: 'webpack' | 'vite'
|
|
184
|
+
routes: DiscoveredRoute[]
|
|
185
|
+
selectors: PolicySelectorEntry[]
|
|
186
|
+
components: PolicyComponent[] // only components with state or interactions
|
|
187
|
+
flows: PolicyFlow[]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### `clippy-selectors.json` — deduplicated selector manifest
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
interface SelectorManifest {
|
|
195
|
+
version: string
|
|
196
|
+
buildId: string
|
|
197
|
+
generatedAt: string
|
|
198
|
+
selectors: SelectorManifestEntry[]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface SelectorManifestEntry {
|
|
202
|
+
id: string // data-clippy-id value
|
|
203
|
+
selector: string // CSS selector string
|
|
204
|
+
component: string // enclosing component name
|
|
205
|
+
tag: string // lowercase HTML tag
|
|
206
|
+
label?: string // human-readable label (may be null)
|
|
207
|
+
routes: string[] // all routes where this element appears
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `DiscoveredRoute`
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
interface DiscoveredRoute {
|
|
215
|
+
path: string // e.g. "/dashboard/forms/[id]"
|
|
216
|
+
filePath: string // relative to project root, e.g. "app/dashboard/forms/[id]/page.tsx"
|
|
217
|
+
isDynamic: boolean
|
|
218
|
+
params: string[] // e.g. ["id"]
|
|
219
|
+
layout: string | null // relative path to nearest layout file
|
|
220
|
+
routerType: 'app' | 'pages' | 'react-router' | 'tanstack'
|
|
221
|
+
semantic: string // space-separated route segments, reversed
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `PolicyComponent`
|
|
226
|
+
|
|
227
|
+
Only components with at least one `stateVariable` or `interaction` are included.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
interface PolicyComponent {
|
|
231
|
+
name: string
|
|
232
|
+
filePath: string // relative to project root
|
|
233
|
+
route: string
|
|
234
|
+
stateVariables: Array<{
|
|
235
|
+
name: string
|
|
236
|
+
setter?: string
|
|
237
|
+
initialValue?: string
|
|
238
|
+
}>
|
|
239
|
+
interactions: ComponentInteraction[]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface ComponentInteraction {
|
|
243
|
+
trigger: {
|
|
244
|
+
event: string // e.g. "onClick"
|
|
245
|
+
element: string // JSX tag that carries the handler
|
|
246
|
+
setsState?: string // state variable the handler mutates
|
|
247
|
+
}
|
|
248
|
+
effect: {
|
|
249
|
+
type: 'conditionalRender' | 'asyncEffect' | 'contextDependency'
|
|
250
|
+
rendersWhenTrue?: string // component name that appears
|
|
251
|
+
rendersWhenFalse?: string // component name that disappears
|
|
252
|
+
waitStrategy: 'elementAppears' | 'domSettle' | 'none'
|
|
253
|
+
selector?: string // [data-clippy-component='...'] selector to wait for
|
|
254
|
+
settleMs?: number // milliseconds to wait for DOM to settle
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `PolicyFlow`
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
interface PolicyFlow {
|
|
263
|
+
flowId: string
|
|
264
|
+
page: string // starting route
|
|
265
|
+
intentPatterns: string[] // user phrases that match this flow
|
|
266
|
+
steps: PolicyFlowStep[]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface PolicyFlowStep {
|
|
270
|
+
step: number
|
|
271
|
+
action: 'navigate' | 'transition' | 'interact'
|
|
272
|
+
target: string // CSS selector for the element to act on
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Selector ID format
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
ComponentName[-LabelText]-tag-lineNumber
|
|
282
|
+
|
|
283
|
+
Examples:
|
|
284
|
+
DashboardForms-CreateForm-button-138
|
|
285
|
+
AdminTransactions-Approve-button-172
|
|
286
|
+
LoginPage-form-115 (no label — form labels use submit button text only)
|
|
287
|
+
Input-input-7 (shared component, no label from its own file)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- **ComponentName** — enclosing React component, or route-derived name when generic (e.g., `AdminTransactions` instead of `Page`)
|
|
291
|
+
- **LabelText** — `aria-label` > visible text (TitleCase, max 2 words) > `placeholder`. Omitted for forms (submit button text is used instead of the full field list)
|
|
292
|
+
- **tag** — lowercase HTML tag
|
|
293
|
+
- **lineNumber** — source file line, stable tiebreaker
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Related packages
|
|
298
|
+
|
|
299
|
+
- [`@dcoder-x/next`](https://www.npmjs.com/package/@dcoder-x/next) — Next.js adapter
|
|
300
|
+
- [`@dcoder-x/vite`](https://www.npmjs.com/package/@dcoder-x/vite) — Vite adapter
|
|
@@ -6,6 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.RouteExtractor = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const parser_1 = require("@babel/parser");
|
|
10
|
+
const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
11
|
+
// @babel/traverse ships as CJS but may be loaded by an ESM host (e.g. Vite).
|
|
12
|
+
// The CJS-in-ESM import wraps exports in a namespace object, so `.default`
|
|
13
|
+
// may be the module object rather than the function. This guard handles both.
|
|
14
|
+
const traverse = typeof traverse_1.default === 'function' ? traverse_1.default : traverse_1.default.default;
|
|
9
15
|
class RouteExtractor {
|
|
10
16
|
constructor(dir) {
|
|
11
17
|
this.dir = dir;
|
|
@@ -114,21 +120,19 @@ class RouteExtractor {
|
|
|
114
120
|
async extractReactRouterRoutes(srcDir) {
|
|
115
121
|
if (!fs_1.default.existsSync(srcDir))
|
|
116
122
|
return [];
|
|
117
|
-
const { parse } = await import('@babel/parser');
|
|
118
|
-
const traverse = (await import('@babel/traverse')).default;
|
|
119
123
|
const results = [];
|
|
120
124
|
const routerFiles = this.findFilesContaining(srcDir, /createBrowserRouter|<Route/);
|
|
121
125
|
for (const filePath of routerFiles) {
|
|
122
126
|
const source = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
123
127
|
let ast;
|
|
124
128
|
try {
|
|
125
|
-
ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
129
|
+
ast = (0, parser_1.parse)(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
126
130
|
}
|
|
127
131
|
catch {
|
|
128
132
|
continue;
|
|
129
133
|
}
|
|
130
134
|
traverse(ast, {
|
|
131
|
-
JSXOpeningElement(nodePath) {
|
|
135
|
+
JSXOpeningElement: (nodePath) => {
|
|
132
136
|
const name = nodePath.node.name?.name;
|
|
133
137
|
if (name !== 'Route')
|
|
134
138
|
return;
|