@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.
@@ -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. Inspect frontend code and routes to extract feature descriptions, user-facing copy, and button texts, locations, and actions.",
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": [],
@@ -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. Scan page content → generate \`.shiplens/contexts/<app_id>.md\`
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.6';
19
+ let VERSION = '1.4.7';
20
20
  try {
21
21
  const pkg = require('../package.json');
22
22
  if (pkg.version) VERSION = pkg.version;
@@ -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
- mdLines.push(`| \`${b.id}\` | ${textDisplay} | \`${b.selector}\` | ${b.intent} |`);
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
- * @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string }>}
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 extractInteractiveElements(code) {
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_-]+)\s+/g;
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
- if (!targetTags.includes(tag.toLowerCase())) {
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 + match[0].length;
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 isExplicitButton = /^(button|a|link)$/i.test(tag);
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
- // 提取 id
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
- // 提取 class/className
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
- function cleanTagContent(content) {
229
- if (!content) return '';
230
- return content
231
- .replace(/<[^>]*>/g, ' ')
232
- .replace(/\{[^}]*\}/g, '')
233
- .replace(/\s+/g, ' ')
234
- .trim();
235
- }
236
-
237
- function extractInputValue(attrs) {
238
- const match = attrs.match(/value=["']([^"']+)["']/i);
239
- return match ? match[1].trim() : '';
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 rawButtons = extractInteractiveElements(code);
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.6",
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. Inspect frontend code and routes to extract feature descriptions, user-facing copy, and button texts, locations, and actions.
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
  ```