@shiplens/cli 1.4.6 → 1.4.7
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/lib/assets/actions.json +1 -1
- package/lib/assets/skill.js +1 -1
- package/lib/cli.js +1 -1
- package/lib/context-generator.js +5 -4
- package/lib/extractor.js +374 -65
- package/package.json +6 -1
- package/prompts/prompts_cli_en-US.md +1 -1
package/lib/assets/actions.json
CHANGED
|
@@ -710,7 +710,7 @@
|
|
|
710
710
|
"prompt": "Use Shiplens CLI to map pages, features, and button layouts into `.shiplens/contexts/<app_id>.md` so AI analytics can understand the business context behind each metric:",
|
|
711
711
|
"steps": [
|
|
712
712
|
"1. Check `./.shiplens.json` for current app_id and project name.",
|
|
713
|
-
"2.
|
|
713
|
+
"2. Run AST static code scan across frontend code and routes to extract page purposes, feature copy, interactive buttons, test IDs, selectors, and exact source code locations.",
|
|
714
714
|
"3. Write structured details into `.shiplens/contexts/<app_id>.md`, binding app_id and project name in the header."
|
|
715
715
|
],
|
|
716
716
|
"commands": [],
|
package/lib/assets/skill.js
CHANGED
|
@@ -46,7 +46,7 @@ Backup mirror fallback: add \`--registry=https://registry.npmmirror.com\`
|
|
|
46
46
|
2. Install \`@shiplens/sdk\`
|
|
47
47
|
3. Inject tracking code into entry file
|
|
48
48
|
4. Register project via \`POST /api/connect\` → receive \`app_id\` + \`dashboard_url\`
|
|
49
|
-
5.
|
|
49
|
+
5. AST static source scan (pages, buttons, routes) → generate \`.shiplens/contexts/<app_id>.md\` UI business dictionary
|
|
50
50
|
6. Write local state machine \`.shiplens.json\`
|
|
51
51
|
7. Inject this AI Skill file and Agent rules
|
|
52
52
|
|
package/lib/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ const { handleContext } = require('./commands/context');
|
|
|
16
16
|
const { handleMcp } = require('./commands/mcp');
|
|
17
17
|
const { handleAction } = require('./commands/action');
|
|
18
18
|
|
|
19
|
-
let VERSION = '1.4.
|
|
19
|
+
let VERSION = '1.4.7';
|
|
20
20
|
try {
|
|
21
21
|
const pkg = require('../package.json');
|
|
22
22
|
if (pkg.version) VERSION = pkg.version;
|
package/lib/context-generator.js
CHANGED
|
@@ -68,7 +68,7 @@ function generateProjectContext(dir = process.cwd(), appId, projectName = 'My Ap
|
|
|
68
68
|
let totalButtonsCount = 0;
|
|
69
69
|
|
|
70
70
|
for (const p of pages) {
|
|
71
|
-
const skeleton = extractPageSkeleton(p.filePath, p.path, p.name);
|
|
71
|
+
const skeleton = extractPageSkeleton(p.filePath, p.path, p.name, dir);
|
|
72
72
|
const purpose = inferPagePurpose(p, skeleton.headings, skeleton.descriptions, skeleton.faqs);
|
|
73
73
|
totalButtonsCount += skeleton.raw_buttons.length;
|
|
74
74
|
pageDetails.push({
|
|
@@ -129,11 +129,12 @@ function generateProjectContext(dir = process.cwd(), appId, projectName = 'My Ap
|
|
|
129
129
|
if (p.buttons.length > 0) {
|
|
130
130
|
mdLines.push('');
|
|
131
131
|
mdLines.push(`#### Interactive Elements & Buttons (${p.buttons.length}):`);
|
|
132
|
-
mdLines.push('| Button ID | Text / Label | Selector | Inferred Action / Intent |');
|
|
133
|
-
mdLines.push('| :--- | :--- | :--- | :--- |');
|
|
132
|
+
mdLines.push('| Button ID | Text / Label | Selector | Source Location | Inferred Action / Intent |');
|
|
133
|
+
mdLines.push('| :--- | :--- | :--- | :--- | :--- |');
|
|
134
134
|
for (const b of p.buttons) {
|
|
135
135
|
const textDisplay = (b.text || '').replace(/\|/g, '\\|').trim() || '(icon/action)';
|
|
136
|
-
|
|
136
|
+
const locDisplay = b.source_loc ? `\`${b.source_loc}\`` : '-';
|
|
137
|
+
mdLines.push(`| \`${b.id}\` | ${textDisplay} | \`${b.selector}\` | ${locDisplay} | ${b.intent} |`);
|
|
137
138
|
}
|
|
138
139
|
} else {
|
|
139
140
|
mdLines.push('- **Interactive Elements**: *(No buttons or interactive controls found on this page)*');
|
package/lib/extractor.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const parser = require('@babel/parser');
|
|
4
|
+
const traverse = require('@babel/traverse').default || require('@babel/traverse');
|
|
5
|
+
const t = require('@babel/types');
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* 清洗代码字符串,去除注释、import、CSS-in-JS 与复杂逻辑噪音
|
|
@@ -88,29 +92,354 @@ function extractRichPageContent(code) {
|
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
const NATIVE_BUTTON_TAGS = new Set(['button', 'a', 'input']);
|
|
96
|
+
const CUSTOM_BUTTON_PATTERN = /^(?:.*Button|.*Btn|.*CTA|.*Submit|.*Link|Link|Button|CTA|Submit)$/i;
|
|
97
|
+
|
|
98
|
+
function getTagName(nameNode) {
|
|
99
|
+
if (!nameNode) return '';
|
|
100
|
+
if (t.isJSXIdentifier(nameNode)) return nameNode.name;
|
|
101
|
+
if (t.isJSXMemberExpression(nameNode)) {
|
|
102
|
+
return `${getTagName(nameNode.object)}.${nameNode.property.name}`;
|
|
103
|
+
}
|
|
104
|
+
if (t.isJSXNamespacedName(nameNode)) {
|
|
105
|
+
return `${nameNode.namespace.name}:${nameNode.name.name}`;
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractAttributeStringValue(valNode) {
|
|
111
|
+
if (!valNode) return null;
|
|
112
|
+
if (t.isStringLiteral(valNode)) return valNode.value;
|
|
113
|
+
if (t.isJSXExpressionContainer(valNode)) {
|
|
114
|
+
const expr = valNode.expression;
|
|
115
|
+
if (t.isStringLiteral(expr)) return expr.value;
|
|
116
|
+
if (t.isNumericLiteral(expr)) return String(expr.value);
|
|
117
|
+
if (t.isTemplateLiteral(expr)) {
|
|
118
|
+
return expr.quasis.map((q) => q.value.raw).join('');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractHandlerDescription(valNode) {
|
|
125
|
+
if (!valNode) return '';
|
|
126
|
+
if (t.isJSXExpressionContainer(valNode)) {
|
|
127
|
+
const expr = valNode.expression;
|
|
128
|
+
if (t.isIdentifier(expr)) return expr.name;
|
|
129
|
+
if (t.isCallExpression(expr)) {
|
|
130
|
+
if (t.isIdentifier(expr.callee)) return `${expr.callee.name}()`;
|
|
131
|
+
if (t.isMemberExpression(expr.callee) && t.isIdentifier(expr.callee.property)) {
|
|
132
|
+
return `${expr.callee.property.name}()`;
|
|
133
|
+
}
|
|
134
|
+
return 'function()';
|
|
135
|
+
}
|
|
136
|
+
if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
|
|
137
|
+
if (t.isCallExpression(expr.body)) {
|
|
138
|
+
if (t.isIdentifier(expr.body.callee)) return `${expr.body.callee.name}()`;
|
|
139
|
+
if (t.isMemberExpression(expr.body.callee) && t.isIdentifier(expr.body.callee.property)) {
|
|
140
|
+
return `${expr.body.callee.property.name}()`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (t.isBlockStatement(expr.body) && expr.body.body.length > 0) {
|
|
144
|
+
const firstStmt = expr.body.body[0];
|
|
145
|
+
if (t.isExpressionStatement(firstStmt) && t.isCallExpression(firstStmt.expression)) {
|
|
146
|
+
const call = firstStmt.expression;
|
|
147
|
+
if (t.isIdentifier(call.callee)) return `${call.callee.name}()`;
|
|
148
|
+
if (t.isMemberExpression(call.callee) && t.isIdentifier(call.callee.property)) {
|
|
149
|
+
return `${call.callee.property.name}()`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return 'inline handler';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractAttrs(attributes) {
|
|
160
|
+
const result = {
|
|
161
|
+
id: null,
|
|
162
|
+
shiplensLabel: null,
|
|
163
|
+
testId: null,
|
|
164
|
+
href: null,
|
|
165
|
+
to: null,
|
|
166
|
+
type: null,
|
|
167
|
+
role: null,
|
|
168
|
+
className: null,
|
|
169
|
+
clickHandler: null,
|
|
170
|
+
hasClick: false,
|
|
171
|
+
value: null,
|
|
172
|
+
rel: null,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const attr of attributes) {
|
|
176
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
177
|
+
const name = attr.name.name;
|
|
178
|
+
const attrNameLower = name.toLowerCase();
|
|
179
|
+
const valNode = attr.value;
|
|
180
|
+
const strVal = extractAttributeStringValue(valNode);
|
|
181
|
+
|
|
182
|
+
if (attrNameLower === 'id') {
|
|
183
|
+
result.id = strVal;
|
|
184
|
+
} else if (attrNameLower === 'data-shiplens-label' || attrNameLower === 'data-label') {
|
|
185
|
+
result.shiplensLabel = strVal;
|
|
186
|
+
} else if (attrNameLower === 'data-testid' || attrNameLower === 'data-test-id' || attrNameLower === 'testid') {
|
|
187
|
+
result.testId = strVal;
|
|
188
|
+
} else if (attrNameLower === 'href') {
|
|
189
|
+
result.href = strVal;
|
|
190
|
+
} else if (attrNameLower === 'to') {
|
|
191
|
+
result.to = strVal;
|
|
192
|
+
} else if (attrNameLower === 'type') {
|
|
193
|
+
result.type = strVal;
|
|
194
|
+
} else if (attrNameLower === 'role') {
|
|
195
|
+
result.role = strVal;
|
|
196
|
+
} else if (attrNameLower === 'class' || attrNameLower === 'classname') {
|
|
197
|
+
result.className = strVal;
|
|
198
|
+
} else if (attrNameLower === 'value') {
|
|
199
|
+
result.value = strVal;
|
|
200
|
+
} else if (attrNameLower === 'rel') {
|
|
201
|
+
result.rel = strVal;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (['onclick', '@click', 'v-on:click', 'onpress', 'ontap', 'bindtap'].includes(attrNameLower)) {
|
|
205
|
+
result.hasClick = true;
|
|
206
|
+
result.clickHandler = extractHandlerDescription(valNode);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractChildText(children) {
|
|
215
|
+
const parts = [];
|
|
216
|
+
|
|
217
|
+
for (const child of children) {
|
|
218
|
+
if (t.isJSXText(child)) {
|
|
219
|
+
const text = child.value.replace(/\s+/g, ' ').trim();
|
|
220
|
+
if (text) parts.push(text);
|
|
221
|
+
} else if (t.isJSXExpressionContainer(child)) {
|
|
222
|
+
const expr = child.expression;
|
|
223
|
+
if (t.isStringLiteral(expr)) {
|
|
224
|
+
const text = expr.value.trim();
|
|
225
|
+
if (text) parts.push(text);
|
|
226
|
+
} else if (t.isNumericLiteral(expr)) {
|
|
227
|
+
parts.push(String(expr.value));
|
|
228
|
+
} else if (t.isTemplateLiteral(expr)) {
|
|
229
|
+
const text = expr.quasis.map((q) => q.value.raw).join('').trim();
|
|
230
|
+
if (text) parts.push(text);
|
|
231
|
+
} else if (t.isConditionalExpression(expr)) {
|
|
232
|
+
const consequent = t.isStringLiteral(expr.consequent) ? expr.consequent.value.trim() : '';
|
|
233
|
+
const alternate = t.isStringLiteral(expr.alternate) ? expr.alternate.value.trim() : '';
|
|
234
|
+
if (consequent && alternate) {
|
|
235
|
+
parts.push(`${consequent} / ${alternate}`);
|
|
236
|
+
} else if (consequent || alternate) {
|
|
237
|
+
parts.push(consequent || alternate);
|
|
238
|
+
} else {
|
|
239
|
+
parts.push('{conditional}');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else if (t.isJSXElement(child)) {
|
|
243
|
+
const nested = extractChildText(child.children);
|
|
244
|
+
if (nested) parts.push(nested);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return parts.join(' ').trim();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function inferActionHint(attrs) {
|
|
252
|
+
const target = attrs.href || attrs.to;
|
|
253
|
+
if (target) return `navigate to ${target}`;
|
|
254
|
+
if (attrs.clickHandler) return `calls ${attrs.clickHandler}`;
|
|
255
|
+
if (attrs.type === 'submit') return 'submit form';
|
|
256
|
+
return '';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cleanClassName(classStr) {
|
|
260
|
+
if (!classStr) return '';
|
|
261
|
+
return classStr
|
|
262
|
+
.split(/\s+/)
|
|
263
|
+
.filter((c) => c && !/^(css-|_[a-z0-9]{5,}|[a-z0-9]{8,})/i.test(c))
|
|
264
|
+
.slice(0, 2)
|
|
265
|
+
.join(' ');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cleanTagContent(content) {
|
|
269
|
+
if (!content) return '';
|
|
270
|
+
return content
|
|
271
|
+
.replace(/<[^>]*>/g, ' ')
|
|
272
|
+
.replace(/\{[^}]*\}/g, '')
|
|
273
|
+
.replace(/\s+/g, ' ')
|
|
274
|
+
.trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function extractInputValue(attrs) {
|
|
278
|
+
const match = attrs.match(/value=["']([^"']+)["']/i);
|
|
279
|
+
return match ? match[1].trim() : '';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function generateIdFromTextOrClass(text, classStr, tag, index) {
|
|
283
|
+
if (text && typeof text === 'string') {
|
|
284
|
+
const cleanText = text.replace(/[\r\n\t]+/g, ' ').trim();
|
|
285
|
+
if (cleanText.length >= 1 && cleanText.length <= 50) {
|
|
286
|
+
if (/[\u4e00-\u9fa5]/.test(cleanText)) {
|
|
287
|
+
const hexToken = encodeURIComponent(cleanText)
|
|
288
|
+
.replace(/%/g, '')
|
|
289
|
+
.slice(0, 12)
|
|
290
|
+
.toLowerCase();
|
|
291
|
+
if (hexToken) return `btn_${hexToken}`;
|
|
292
|
+
} else {
|
|
293
|
+
const slug = cleanText
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
296
|
+
.replace(/^_+|_+$/g, '')
|
|
297
|
+
.slice(0, 24);
|
|
298
|
+
if (slug) return `btn_${slug}`;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (classStr && typeof classStr === 'string') {
|
|
304
|
+
const firstClass = classStr.split(' ')[0].replace(/[^a-zA-Z0-9_-]/g, '');
|
|
305
|
+
if (firstClass) return `btn_${firstClass.replace(/^btn_?/, '') ? firstClass : `btn_${firstClass}`}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return `btn_${(tag || 'element').toLowerCase()}_${index || 1}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 基于 Babel AST 遍历提取页面内所有的交互按钮、链接和提交控件
|
|
313
|
+
* @param {string} code 源代码
|
|
314
|
+
* @param {string} filePath 相对文件路径(用于 source_loc)
|
|
315
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string, is_custom_component?: boolean }> | null}
|
|
316
|
+
*/
|
|
317
|
+
function extractInteractiveElementsAST(code, filePath = '') {
|
|
318
|
+
let ast;
|
|
319
|
+
try {
|
|
320
|
+
ast = parser.parse(code, {
|
|
321
|
+
sourceType: 'unambiguous',
|
|
322
|
+
plugins: [
|
|
323
|
+
'jsx',
|
|
324
|
+
'typescript',
|
|
325
|
+
'decorators-legacy',
|
|
326
|
+
'classProperties',
|
|
327
|
+
'classPrivateProperties',
|
|
328
|
+
'classPrivateMethods',
|
|
329
|
+
'exportDefaultFrom',
|
|
330
|
+
'exportNamespaceFrom',
|
|
331
|
+
'asyncGenerators',
|
|
332
|
+
'dynamicImport',
|
|
333
|
+
'objectRestSpread',
|
|
334
|
+
'optionalCatchBinding',
|
|
335
|
+
'optionalChaining',
|
|
336
|
+
'nullishCoalescingOperator',
|
|
337
|
+
],
|
|
338
|
+
errorRecovery: true,
|
|
339
|
+
});
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const elements = [];
|
|
345
|
+
let elementIndex = 1;
|
|
346
|
+
|
|
347
|
+
traverse(ast, {
|
|
348
|
+
JSXElement(pathNode) {
|
|
349
|
+
const opening = pathNode.node.openingElement;
|
|
350
|
+
const rawTagName = getTagName(opening.name);
|
|
351
|
+
if (!rawTagName) return;
|
|
352
|
+
|
|
353
|
+
const lowerTagName = rawTagName.toLowerCase();
|
|
354
|
+
const isNative = NATIVE_BUTTON_TAGS.has(lowerTagName);
|
|
355
|
+
const isCustom = CUSTOM_BUTTON_PATTERN.test(rawTagName);
|
|
356
|
+
|
|
357
|
+
// 过滤 HTML head 中的 link 标签 (如 <link rel="stylesheet" />)
|
|
358
|
+
const attrs = extractAttrs(opening.attributes);
|
|
359
|
+
if (lowerTagName === 'link' && attrs.rel && !attrs.href && !attrs.to && !attrs.hasClick) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isSubmit = attrs.type === 'submit';
|
|
364
|
+
const isButtonRole = attrs.role === 'button' || attrs.role === 'link';
|
|
365
|
+
|
|
366
|
+
if (!isNative && !isCustom && !attrs.hasClick && !isSubmit && !isButtonRole) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const childText = extractChildText(pathNode.node.children);
|
|
371
|
+
const text = childText || (attrs.value ? attrs.value : '');
|
|
372
|
+
|
|
373
|
+
const stableId =
|
|
374
|
+
attrs.shiplensLabel ||
|
|
375
|
+
attrs.id ||
|
|
376
|
+
attrs.testId ||
|
|
377
|
+
generateIdFromTextOrClass(text, attrs.className || '', rawTagName, elementIndex);
|
|
378
|
+
|
|
379
|
+
const cleanClass = cleanClassName(attrs.className);
|
|
380
|
+
const firstClass = cleanClass ? cleanClass.split(' ')[0] : '';
|
|
381
|
+
const selector = attrs.id
|
|
382
|
+
? `#${attrs.id}`
|
|
383
|
+
: firstClass
|
|
384
|
+
? `${lowerTagName}.${firstClass}`
|
|
385
|
+
: `${lowerTagName}`;
|
|
386
|
+
|
|
387
|
+
const actionHint = inferActionHint(attrs);
|
|
388
|
+
|
|
389
|
+
let sourceLoc = undefined;
|
|
390
|
+
if (opening.loc) {
|
|
391
|
+
const line = opening.loc.start.line;
|
|
392
|
+
sourceLoc = filePath ? `${filePath}:${line}` : `line ${line}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
elements.push({
|
|
396
|
+
id: stableId,
|
|
397
|
+
tag: lowerTagName,
|
|
398
|
+
text: text || '(图标/无文案)',
|
|
399
|
+
selector: selector,
|
|
400
|
+
...(attrs.shiplensLabel ? { label: attrs.shiplensLabel } : {}),
|
|
401
|
+
...(actionHint ? { action_hint: actionHint } : {}),
|
|
402
|
+
...(sourceLoc ? { source_loc: sourceLoc } : {}),
|
|
403
|
+
...(isCustom && !isNative ? { is_custom_component: true } : {}),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
elementIndex += 1;
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// 去重保底(相同 id 保留第一个)
|
|
411
|
+
const seenIds = new Set();
|
|
412
|
+
return elements.filter((el) => {
|
|
413
|
+
if (seenIds.has(el.id)) return false;
|
|
414
|
+
seenIds.add(el.id);
|
|
415
|
+
return true;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
91
419
|
/**
|
|
92
|
-
*
|
|
93
|
-
* 采用状态机/匹配法准确跳过 JSX 属性中的 arrow function `() =>` 和花括号
|
|
420
|
+
* 正则状态机提取按钮(AST 解析异常时的平滑降级保底)
|
|
94
421
|
* @param {string} code 清洗后的代码
|
|
95
|
-
* @
|
|
422
|
+
* @param {string} filePath 相对文件路径
|
|
423
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string }>}
|
|
96
424
|
*/
|
|
97
|
-
function
|
|
425
|
+
function extractInteractiveElementsRegex(code, filePath = '') {
|
|
98
426
|
const elements = [];
|
|
99
427
|
let elementIndex = 1;
|
|
100
428
|
|
|
101
429
|
// 匹配所有 <button, <a, <Link, <input, <div, <span 开头的标签
|
|
102
|
-
const tagStartRegex = /<([a-zA-Z0-9_
|
|
430
|
+
const tagStartRegex = /<([a-zA-Z0-9_\-]+)(?:\s+|>)/g;
|
|
103
431
|
let match;
|
|
104
432
|
|
|
105
433
|
while ((match = tagStartRegex.exec(code)) !== null) {
|
|
106
434
|
const tag = match[1];
|
|
107
435
|
const targetTags = ['button', 'a', 'link', 'input', 'div', 'span'];
|
|
108
|
-
|
|
436
|
+
const isTargetTag = targetTags.includes(tag.toLowerCase()) || CUSTOM_BUTTON_PATTERN.test(tag);
|
|
437
|
+
if (!isTargetTag) {
|
|
109
438
|
continue;
|
|
110
439
|
}
|
|
111
440
|
|
|
112
441
|
const startIndex = match.index;
|
|
113
|
-
let index = startIndex +
|
|
442
|
+
let index = startIndex + tag.length + 1;
|
|
114
443
|
let inQuotes = null;
|
|
115
444
|
let braceDepth = 0;
|
|
116
445
|
let isSelfClosing = false;
|
|
@@ -147,7 +476,6 @@ function extractInteractiveElements(code) {
|
|
|
147
476
|
let innerContent = '';
|
|
148
477
|
|
|
149
478
|
if (!isSelfClosing) {
|
|
150
|
-
// 寻找对应的闭合标签 </tag>
|
|
151
479
|
const closeTagStr = `</${tag}>`;
|
|
152
480
|
const closeTagIndex = code.indexOf(closeTagStr, tagEndIndex + 1);
|
|
153
481
|
if (closeTagIndex !== -1 && closeTagIndex - tagEndIndex < 2000) {
|
|
@@ -155,50 +483,50 @@ function extractInteractiveElements(code) {
|
|
|
155
483
|
}
|
|
156
484
|
}
|
|
157
485
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const hasClick = /onClick|@click|v-on:click|role=["']button["']/i.test(attrs);
|
|
486
|
+
const isExplicitButton = /^(button|a|link)$/i.test(tag) || CUSTOM_BUTTON_PATTERN.test(tag);
|
|
487
|
+
const hasClick = /onClick|@click|v-on:click|onPress|role=["']button["']/i.test(attrs);
|
|
161
488
|
const isSubmit = /type=["']submit["']/i.test(attrs);
|
|
162
489
|
|
|
163
490
|
if (!isExplicitButton && !hasClick && !isSubmit) {
|
|
164
491
|
continue;
|
|
165
492
|
}
|
|
166
493
|
|
|
167
|
-
//
|
|
494
|
+
// 过滤 HTML head 中的 link 标签
|
|
495
|
+
if (tag.toLowerCase() === 'link' && /rel=["'](?:stylesheet|icon|preload|manifest)["']/i.test(attrs)) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
168
499
|
const idMatch = attrs.match(/\bid=["']([^"']+)["']/i);
|
|
169
500
|
const rawId = idMatch ? idMatch[1] : null;
|
|
170
501
|
|
|
171
|
-
// 提取 data-shiplens-label
|
|
172
502
|
const labelMatch = attrs.match(/data-shiplens-label=["']([^"']+)["']/i);
|
|
173
503
|
const explicitLabel = labelMatch ? labelMatch[1] : null;
|
|
174
504
|
|
|
175
|
-
|
|
505
|
+
const testIdMatch = attrs.match(/data-testid=["']([^"']+)["']/i);
|
|
506
|
+
const testId = testIdMatch ? testIdMatch[1] : null;
|
|
507
|
+
|
|
176
508
|
const classMatch = attrs.match(/(?:class|className)=["']([^"']+)["']/i);
|
|
177
509
|
const classStr = classMatch ? cleanClassName(classMatch[1]) : '';
|
|
178
510
|
|
|
179
|
-
// 提取文案
|
|
180
511
|
const text = cleanTagContent(innerContent) || extractInputValue(attrs);
|
|
181
512
|
|
|
182
|
-
// 提取 href / to / onClick 提示
|
|
183
513
|
const hrefMatch = attrs.match(/(?:href|to)=["']([^"']+)["']/i);
|
|
184
514
|
const href = hrefMatch ? hrefMatch[1] : '';
|
|
185
515
|
|
|
186
|
-
const clickMatch = attrs.match(/(?:onClick|@click)=["']?\{?([^"'>}]+)\}?["']?/i);
|
|
516
|
+
const clickMatch = attrs.match(/(?:onClick|@click|onPress)=["']?\{?([^"'>}]+)\}?["']?/i);
|
|
187
517
|
const clickHandler = clickMatch ? clickMatch[1].trim() : '';
|
|
188
518
|
|
|
189
|
-
// 生成稳定 ID
|
|
190
519
|
const stableId = explicitLabel
|
|
191
520
|
|| rawId
|
|
521
|
+
|| testId
|
|
192
522
|
|| generateIdFromTextOrClass(text, classStr, tag, elementIndex);
|
|
193
523
|
|
|
194
|
-
// 生成可读 CSS 选择器
|
|
195
524
|
const selector = rawId
|
|
196
525
|
? `#${rawId}`
|
|
197
526
|
: classStr
|
|
198
527
|
? `${tag.toLowerCase()}.${classStr.split(' ')[0]}`
|
|
199
528
|
: `${tag.toLowerCase()}`;
|
|
200
529
|
|
|
201
|
-
// 动作推断线索
|
|
202
530
|
let actionHint = '';
|
|
203
531
|
if (href) actionHint = `navigate to ${href}`;
|
|
204
532
|
else if (clickHandler) actionHint = `calls ${clickHandler}`;
|
|
@@ -216,65 +544,41 @@ function extractInteractiveElements(code) {
|
|
|
216
544
|
elementIndex += 1;
|
|
217
545
|
}
|
|
218
546
|
|
|
219
|
-
// 去重保底(相同 id 保留第一个)
|
|
220
547
|
const seenIds = new Set();
|
|
221
|
-
return elements.filter(el => {
|
|
548
|
+
return elements.filter((el) => {
|
|
222
549
|
if (seenIds.has(el.id)) return false;
|
|
223
550
|
seenIds.add(el.id);
|
|
224
551
|
return true;
|
|
225
552
|
});
|
|
226
553
|
}
|
|
227
554
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function cleanClassName(classStr) {
|
|
243
|
-
if (!classStr) return '';
|
|
244
|
-
return classStr
|
|
245
|
-
.split(/\s+/)
|
|
246
|
-
.filter(c => c && !/^(css-|_[a-z0-9]{5,}|[a-z0-9]{8,})/i.test(c))
|
|
247
|
-
.slice(0, 2)
|
|
248
|
-
.join(' ');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function generateIdFromTextOrClass(text, classStr, tag, index) {
|
|
252
|
-
if (text && text.length >= 2 && text.length <= 30 && /^[\u4e00-\u9fa5a-zA-Z0-9_\-\s]+$/.test(text)) {
|
|
253
|
-
const slug = text
|
|
254
|
-
.trim()
|
|
255
|
-
.toLowerCase()
|
|
256
|
-
.replace(/\s+/g, '_')
|
|
257
|
-
.replace(/[\u4e00-\u9fa5]+/g, (match) => `btn_${encodeURIComponent(match).replace(/%/g, '').slice(0, 8)}`)
|
|
258
|
-
.replace(/[^a-zA-Z0-9_]/g, '');
|
|
259
|
-
if (slug) return `btn_${slug.slice(0, 20)}`;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (classStr) {
|
|
263
|
-
const firstClass = classStr.split(' ')[0].replace(/[^a-zA-Z0-9_-]/g, '');
|
|
264
|
-
if (firstClass) return `btn_${firstClass}`;
|
|
555
|
+
/**
|
|
556
|
+
* 提取页面内所有的交互按钮、链接和提交控件(入口函数:优先 AST,降级正则)
|
|
557
|
+
* @param {string} code 代码
|
|
558
|
+
* @param {string} filePath 相对文件路径
|
|
559
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string, source_loc?: string, is_custom_component?: boolean }>}
|
|
560
|
+
*/
|
|
561
|
+
function extractInteractiveElements(code, filePath = '') {
|
|
562
|
+
try {
|
|
563
|
+
const astResult = extractInteractiveElementsAST(code, filePath);
|
|
564
|
+
if (astResult !== null && astResult.length >= 0) {
|
|
565
|
+
return astResult;
|
|
566
|
+
}
|
|
567
|
+
} catch (e) {
|
|
568
|
+
// 降级兜底
|
|
265
569
|
}
|
|
266
|
-
|
|
267
|
-
return `btn_${tag.toLowerCase()}_${index}`;
|
|
570
|
+
return extractInteractiveElementsRegex(code, filePath);
|
|
268
571
|
}
|
|
269
572
|
|
|
270
573
|
/**
|
|
271
574
|
* 完整解析一个页面的结构与富文本骨架
|
|
272
|
-
* @param {string} filePath
|
|
575
|
+
* @param {string} filePath 文件绝对路径
|
|
273
576
|
* @param {string} routePath 对应路由
|
|
274
577
|
* @param {string} pageName 页面候选名称
|
|
578
|
+
* @param {string} rootDir 项目根目录(用于计算相对 source_loc)
|
|
275
579
|
* @returns {{ path: string, name: string, headings: string[], faqs: Array, feature_bullets: string[], descriptions: string[], raw_buttons: Array }}
|
|
276
580
|
*/
|
|
277
|
-
function extractPageSkeleton(filePath, routePath, pageName) {
|
|
581
|
+
function extractPageSkeleton(filePath, routePath, pageName, rootDir = '') {
|
|
278
582
|
if (!fs.existsSync(filePath)) {
|
|
279
583
|
return { path: routePath, name: pageName, headings: [], faqs: [], feature_bullets: [], descriptions: [], raw_buttons: [] };
|
|
280
584
|
}
|
|
@@ -282,7 +586,8 @@ function extractPageSkeleton(filePath, routePath, pageName) {
|
|
|
282
586
|
const rawCode = fs.readFileSync(filePath, 'utf-8');
|
|
283
587
|
const code = cleanCode(rawCode);
|
|
284
588
|
const richContent = extractRichPageContent(code);
|
|
285
|
-
const
|
|
589
|
+
const relFilePath = rootDir ? path.relative(rootDir, filePath).replace(/\\/g, '/') : filePath.replace(/\\/g, '/');
|
|
590
|
+
const rawButtons = extractInteractiveElements(rawCode, relFilePath);
|
|
286
591
|
|
|
287
592
|
return {
|
|
288
593
|
path: routePath,
|
|
@@ -295,6 +600,10 @@ function extractPageSkeleton(filePath, routePath, pageName) {
|
|
|
295
600
|
module.exports = {
|
|
296
601
|
cleanCode,
|
|
297
602
|
extractRichPageContent,
|
|
603
|
+
extractInteractiveElementsAST,
|
|
604
|
+
extractInteractiveElementsRegex,
|
|
298
605
|
extractInteractiveElements,
|
|
299
|
-
extractPageSkeleton
|
|
606
|
+
extractPageSkeleton,
|
|
607
|
+
generateIdFromTextOrClass
|
|
300
608
|
};
|
|
609
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shiplens/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.7",
|
|
4
4
|
"description": "Shiplens CLI — Automated Web User Analytics & AI Agent Analysis Engine",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,5 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@babel/parser": "^8.0.4",
|
|
52
|
+
"@babel/traverse": "^8.0.4",
|
|
53
|
+
"@babel/types": "^8.0.4"
|
|
49
54
|
}
|
|
50
55
|
}
|
|
@@ -792,7 +792,7 @@ Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the ab
|
|
|
792
792
|
```text
|
|
793
793
|
Use Shiplens CLI to map pages, features, and button layouts into `.shiplens/contexts/<app_id>.md` so AI analytics can understand the business context behind each metric:
|
|
794
794
|
1. Check `./.shiplens.json` for current app_id and project name.
|
|
795
|
-
2.
|
|
795
|
+
2. Run AST static code scan across frontend code and routes to extract page purposes, feature copy, interactive buttons, test IDs, selectors, and exact source code locations.
|
|
796
796
|
3. Write structured details into `.shiplens/contexts/<app_id>.md`, binding app_id and project name in the header.
|
|
797
797
|
Please prioritize calling Shiplens CLI (Action: generate_context) to execute the above requirements.
|
|
798
798
|
```
|