@shiplens/cli 1.4.4 → 1.4.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/lib/assets/actions.json +22 -7
- package/lib/cli.js +2 -2
- package/lib/commands/context.js +20 -2
- package/lib/commands/init.js +70 -57
- package/lib/context-generator.js +174 -0
- package/lib/extractor.js +300 -0
- package/lib/injector.js +1 -1
- package/lib/scanner.js +152 -0
- package/package.json +4 -4
- package/prompts/prompts_cli_en-US.md +44 -17
package/lib/assets/actions.json
CHANGED
|
@@ -649,9 +649,9 @@
|
|
|
649
649
|
"title": "Shiplens CLI One-Click Setup & Analytics Initialization",
|
|
650
650
|
"suffix": "None",
|
|
651
651
|
"category": "Initial Setup",
|
|
652
|
-
"prompt": "Set up Shiplens analytics:
|
|
652
|
+
"prompt": "Set up Shiplens analytics: install SDK, create the project, and retrieve the live dashboard URL.",
|
|
653
653
|
"steps": [
|
|
654
|
-
"1. Run CLI initialization command:\n- Windows: npx.cmd --yes @shiplens/cli init\n- macOS/Linux: npx --yes @shiplens/cli init",
|
|
654
|
+
"1. Run CLI initialization command:\n- Windows: npx.cmd --yes @shiplens/cli init --json\n- macOS/Linux: npx --yes @shiplens/cli init --json",
|
|
655
655
|
"2. Present the standard project & dashboard summary (including: Project Name, App ID, Code Injection file, User Account Status, Live Dashboard / Activation URL);",
|
|
656
656
|
"3. Immediately prompt the user for their email address (informing them that you will register on their behalf and Shiplens will send an activation email for 50,000 monthly free events and project binding);",
|
|
657
657
|
"4. Upon receiving the user's email, run: npx.cmd --yes @shiplens/cli auth bind --email <email> --json."
|
|
@@ -660,12 +660,27 @@
|
|
|
660
660
|
"foundation": "",
|
|
661
661
|
"source": ""
|
|
662
662
|
},
|
|
663
|
+
{
|
|
664
|
+
"id": "setup_bind_project",
|
|
665
|
+
"title": "Use Shiplens CLI to Install Analytics SDK & Link to Existing Account",
|
|
666
|
+
"suffix": "None",
|
|
667
|
+
"category": "Initial Setup",
|
|
668
|
+
"prompt": "Use Shiplens CLI to install the analytics SDK in this new project and link it to the user's existing Shiplens account:",
|
|
669
|
+
"steps": [
|
|
670
|
+
"1. Run the project setup & initialization command:\n- Windows: npx.cmd --yes @shiplens/cli init --json\n- macOS/Linux: npx --yes @shiplens/cli init --json",
|
|
671
|
+
"2. Inspect returned `user_account_status` and credential states:\n- [Branch A: Logged in & linked] (`logged_in_linked`):\n- [Branch B: No local credentials detected] (`not_logged_in_unlinked` or `activation_required=true`):\n- [Branch C: Account quota / project limit reached] (Error `QUOTA_EXCEEDED` / `PLAN_LIMIT` / unable to link):",
|
|
672
|
+
"3. Output the final project summary and dashboard link, confirming the project is ready for analytics queries."
|
|
673
|
+
],
|
|
674
|
+
"commands": [],
|
|
675
|
+
"foundation": "",
|
|
676
|
+
"source": ""
|
|
677
|
+
},
|
|
663
678
|
{
|
|
664
679
|
"id": "remove_sdk",
|
|
665
|
-
"title": "
|
|
680
|
+
"title": "Delete Shiplens Project Analytics Data & SDK (No Longer Needed)",
|
|
666
681
|
"suffix": "None",
|
|
667
682
|
"category": "Initial Setup",
|
|
668
|
-
"prompt": "
|
|
683
|
+
"prompt": "Delete Shiplens project analytics data and SDK integration (analytics no longer needed):",
|
|
669
684
|
"steps": [
|
|
670
685
|
"1. Read `.shiplens.json` in the project root to get project name, app_id, and instrumented file paths.",
|
|
671
686
|
"2. Clearly display the project name and app_id to be deleted, warn the user that historical events and hosted data will be permanently purged, and pause for explicit user confirmation (\"Confirm deleting this project\").",
|
|
@@ -678,7 +693,7 @@
|
|
|
678
693
|
},
|
|
679
694
|
{
|
|
680
695
|
"id": "uninstall_cli",
|
|
681
|
-
"title": "
|
|
696
|
+
"title": "Delete Shiplens CLI and Restore Project",
|
|
682
697
|
"suffix": "None",
|
|
683
698
|
"category": "Initial Setup",
|
|
684
699
|
"prompt": "Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the above requirements. - Windows: npm.cmd uninstall -g @shiplens/cli - macOS/Linux: npm uninstall -g @shiplens/cli",
|
|
@@ -689,10 +704,10 @@
|
|
|
689
704
|
},
|
|
690
705
|
{
|
|
691
706
|
"id": "generate_context",
|
|
692
|
-
"title": "Generate Context Descriptions for All Pages and Buttons",
|
|
707
|
+
"title": "Use Shiplens CLI to Generate Context Descriptions for All Pages and Buttons",
|
|
693
708
|
"suffix": "None",
|
|
694
709
|
"category": "Initial Setup",
|
|
695
|
-
"prompt": "
|
|
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:",
|
|
696
711
|
"steps": [
|
|
697
712
|
"1. Check `./.shiplens.json` for current app_id and project name.",
|
|
698
713
|
"2. Inspect frontend code and routes to extract feature descriptions, user-facing copy, and button texts, locations, and actions.",
|
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.6';
|
|
20
20
|
try {
|
|
21
21
|
const pkg = require('../package.json');
|
|
22
22
|
if (pkg.version) VERSION = pkg.version;
|
|
@@ -90,7 +90,7 @@ Core Commands:
|
|
|
90
90
|
heatmap Retrieve click heatmaps and skeleton wireframes
|
|
91
91
|
dashboards AI dashboard management and one-click creation (list, create [--ai])
|
|
92
92
|
doctor Run full end-to-end diagnostics on SDK, credentials, and network
|
|
93
|
-
context Manage business context dictionary (push, pull, show)
|
|
93
|
+
context Manage business context dictionary (generate, push, pull, show)
|
|
94
94
|
action Retrieve deterministic steps, commands, and theory for analysis actions (list, <action_id>)
|
|
95
95
|
mcp serve Run local MCP proxy server with device credentials
|
|
96
96
|
|
package/lib/commands/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { getLocalConfig } = require('../config');
|
|
4
|
+
const { generateProjectContext } = require('../context-generator');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Get standard storage path for business context: .shiplens/contexts/<app_id>.md
|
|
@@ -37,7 +38,7 @@ async function executeWithSafeFallback(fn, maxRetries = 2, baseMs = 500) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* Handle context command (push, pull, show)
|
|
41
|
+
* Handle context command (generate, push, pull, show)
|
|
41
42
|
*/
|
|
42
43
|
async function handleContext(subcommand, args, flags, ctx) {
|
|
43
44
|
const targetSub = subcommand || 'show';
|
|
@@ -47,6 +48,23 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
47
48
|
const projectName = flags.name || localCfg.project_name || '';
|
|
48
49
|
|
|
49
50
|
switch (targetSub) {
|
|
51
|
+
case 'generate':
|
|
52
|
+
case 'scan':
|
|
53
|
+
case 'extract': {
|
|
54
|
+
const genResult = generateProjectContext(process.cwd(), appId, projectName, flags.framework || localCfg.framework);
|
|
55
|
+
ctx.output({
|
|
56
|
+
ok: true,
|
|
57
|
+
action: 'generate',
|
|
58
|
+
app_id: appId,
|
|
59
|
+
file: genResult.filePath,
|
|
60
|
+
rel_file: genResult.relPath,
|
|
61
|
+
pages_count: genResult.totalPages,
|
|
62
|
+
buttons_count: genResult.totalButtons,
|
|
63
|
+
message: `Project business context scanned and saved to ${genResult.relPath} (${genResult.totalPages} pages, ${genResult.totalButtons} buttons).`,
|
|
64
|
+
});
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
case 'push': {
|
|
51
69
|
if (!fs.existsSync(filePath)) {
|
|
52
70
|
const displayPath = path.relative(process.cwd(), filePath) || filePath;
|
|
@@ -173,7 +191,7 @@ async function handleContext(subcommand, args, flags, ctx) {
|
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
default:
|
|
176
|
-
throw new Error(`Unknown context subcommand: ${targetSub}. Supported: push, pull, show`);
|
|
194
|
+
throw new Error(`Unknown context subcommand: ${targetSub}. Supported: generate, push, pull, show`);
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
197
|
|
package/lib/commands/init.js
CHANGED
|
@@ -3,6 +3,7 @@ const { detectProject, detectExistingApp, injectSDK, injectSkill, installSDKDepe
|
|
|
3
3
|
const { saveLocalConfig } = require('../config');
|
|
4
4
|
const { saveDeviceEnv } = require('../device-env');
|
|
5
5
|
const { resolveTaxonomyFromIDs, formatTaxonomySummary } = require('../taxonomy');
|
|
6
|
+
const { generateProjectContext } = require('../context-generator');
|
|
6
7
|
|
|
7
8
|
async function handleInit(args, flags, ctx) {
|
|
8
9
|
const start = Date.now();
|
|
@@ -30,49 +31,29 @@ async function handleInit(args, flags, ctx) {
|
|
|
30
31
|
|
|
31
32
|
// 1. Safety check for existing project configuration
|
|
32
33
|
const existing = detectExistingApp(wd);
|
|
34
|
+
let isReusedExisting = false;
|
|
35
|
+
|
|
33
36
|
if (existing.has_existing && !flags.force) {
|
|
34
|
-
if (ctx.isJSON) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
code: 'PROJECT_EXISTS_LOCALLY',
|
|
38
|
-
message: `Existing Shiplens configuration detected (App ID: ${existing.app_id}) in ${existing.source_file}. ` +
|
|
39
|
-
`Option 1 [Recommended]: Keep existing statistics and project ID. ` +
|
|
40
|
-
`Option 2: Use --force to overwrite and request a new App ID from cloud (Note: Old dashboard stops receiving new data).`,
|
|
41
|
-
existing_app_id: existing.app_id,
|
|
42
|
-
source_file: existing.source_file,
|
|
43
|
-
project_name: existing.project_name || projectName,
|
|
44
|
-
dashboard_url: `${ctx.client.baseURL}/dashboard/${existing.app_id}`,
|
|
45
|
-
};
|
|
46
|
-
ctx.output(result);
|
|
47
|
-
process.exitCode = 1;
|
|
48
|
-
return;
|
|
49
|
-
} else if (!process.stdin.isTTY) {
|
|
50
|
-
console.log(`\n⚠️ Existing Shiplens project detected!`);
|
|
51
|
-
console.log(`📌 App ID: ${existing.app_id} (Source: ${existing.source_file})`);
|
|
52
|
-
console.log(`📊 Dashboard: ${ctx.client.baseURL}/dashboard/${existing.app_id}`);
|
|
53
|
-
console.log(`🛑 Non-interactive terminal detected (CI/Agent). Existing configuration preserved.`);
|
|
54
|
-
console.log(`💡 Option 1 [Recommended]: Keep existing setup, retain App ID and historical statistics;`);
|
|
55
|
-
console.log(`💡 Option 2: Overwrite with --force to create a new project:`);
|
|
56
|
-
console.log(` npx.cmd --yes @shiplens/cli init --force\n`);
|
|
57
|
-
return;
|
|
37
|
+
if (ctx.isJSON || !process.stdin.isTTY) {
|
|
38
|
+
// Non-interactive or JSON mode (Agent/CI): Fast-forward / Idempotent reuse of existing config
|
|
39
|
+
isReusedExisting = true;
|
|
58
40
|
} else {
|
|
59
41
|
console.log(`\n⚠️ Existing Shiplens project detected!`);
|
|
60
42
|
console.log(`📌 App ID: ${existing.app_id} (Source: ${existing.source_file})`);
|
|
61
43
|
console.log(`📊 Dashboard: ${ctx.client.baseURL}/dashboard/${existing.app_id}`);
|
|
62
|
-
console.log(`\n💡 Option 1 [Recommended]: Keep existing setup, retain App ID and historical statistics;`);
|
|
63
|
-
console.log(`💡 Option 2: Overwrite with --force to request a brand new App ID from cloud (
|
|
44
|
+
console.log(`\n💡 Option 1 [Recommended]: Keep existing setup, retain App ID and historical statistics (Press Enter or N);`);
|
|
45
|
+
console.log(`💡 Option 2: Overwrite with --force to request a brand new App ID from cloud (y):`);
|
|
64
46
|
console.log(` npx.cmd --yes @shiplens/cli init --force\n`);
|
|
65
47
|
|
|
66
48
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
67
49
|
const confirmed = await new Promise((resolve) => {
|
|
68
|
-
rl.question('Overwrite and create a new project? (y/N): ', (ans) => {
|
|
50
|
+
rl.question('Overwrite and create a brand new project? (y/N): ', (ans) => {
|
|
69
51
|
rl.close();
|
|
70
52
|
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
|
71
53
|
});
|
|
72
54
|
});
|
|
73
55
|
if (!confirmed) {
|
|
74
|
-
|
|
75
|
-
return;
|
|
56
|
+
isReusedExisting = true;
|
|
76
57
|
}
|
|
77
58
|
}
|
|
78
59
|
}
|
|
@@ -80,40 +61,52 @@ async function handleInit(args, flags, ctx) {
|
|
|
80
61
|
// 2. Inject AI Skill (.agents/skills/shiplens/SKILL.md & Agent rules)
|
|
81
62
|
const skillFile = injectSkill(wd);
|
|
82
63
|
|
|
83
|
-
// 3. Register project on cloud
|
|
64
|
+
// 3. Register project on cloud (or reuse existing)
|
|
84
65
|
let appId = '';
|
|
85
66
|
let dashboardUrl = '';
|
|
86
67
|
let accountStatus = 'not_logged_in_unlinked';
|
|
87
68
|
let accountStatusText = 'Not Logged In (Default state after first installation or no valid local credentials)';
|
|
88
69
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
description,
|
|
93
|
-
industry,
|
|
94
|
-
genre_id: genreId,
|
|
95
|
-
subgenre_id: subgenreId,
|
|
96
|
-
feature_tags: featureTagIds.join(','),
|
|
97
|
-
platform: framework,
|
|
98
|
-
});
|
|
99
|
-
appId = connResp.app_id;
|
|
100
|
-
dashboardUrl = connResp.dashboard_url || `${ctx.client.baseURL}/dashboard/${appId}`;
|
|
101
|
-
|
|
70
|
+
if (isReusedExisting) {
|
|
71
|
+
appId = existing.app_id;
|
|
72
|
+
dashboardUrl = `${ctx.client.baseURL}/dashboard/${appId}`;
|
|
102
73
|
if (ctx.resolvedAuth && ctx.resolvedAuth.is_present) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
accountStatusText = 'Logged In (Project linked to account)';
|
|
106
|
-
} else {
|
|
107
|
-
accountStatus = 'logged_in_unlinked';
|
|
108
|
-
accountStatusText = 'Logged In (Project not linked to account)';
|
|
109
|
-
}
|
|
74
|
+
accountStatus = 'logged_in_linked';
|
|
75
|
+
accountStatusText = 'Logged In (Project linked to account)';
|
|
110
76
|
} else {
|
|
111
77
|
accountStatus = 'not_logged_in_unlinked';
|
|
112
78
|
accountStatusText = 'Not Logged In (Default state after first installation or no valid local credentials)';
|
|
113
79
|
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
const connResp = await ctx.client.connect({
|
|
83
|
+
project_name: projectName,
|
|
84
|
+
description,
|
|
85
|
+
industry,
|
|
86
|
+
genre_id: genreId,
|
|
87
|
+
subgenre_id: subgenreId,
|
|
88
|
+
feature_tags: featureTagIds.join(','),
|
|
89
|
+
platform: framework,
|
|
90
|
+
});
|
|
91
|
+
appId = connResp.app_id;
|
|
92
|
+
dashboardUrl = connResp.dashboard_url || `${ctx.client.baseURL}/dashboard/${appId}`;
|
|
93
|
+
|
|
94
|
+
if (ctx.resolvedAuth && ctx.resolvedAuth.is_present) {
|
|
95
|
+
if (connResp.account_linked !== false && connResp.linked !== false && !connResp.unlinked) {
|
|
96
|
+
accountStatus = 'logged_in_linked';
|
|
97
|
+
accountStatusText = 'Logged In (Project linked to account)';
|
|
98
|
+
} else {
|
|
99
|
+
accountStatus = 'logged_in_unlinked';
|
|
100
|
+
accountStatusText = 'Logged In (Project not linked to account)';
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
accountStatus = 'not_logged_in_unlinked';
|
|
104
|
+
accountStatusText = 'Not Logged In (Default state after first installation or no valid local credentials)';
|
|
105
|
+
}
|
|
106
|
+
} catch (netErr) {
|
|
107
|
+
netErr.code = netErr.code || 'CONNECT_FAILED';
|
|
108
|
+
throw netErr;
|
|
109
|
+
}
|
|
117
110
|
}
|
|
118
111
|
|
|
119
112
|
// 4. Inject SDK code
|
|
@@ -132,7 +125,13 @@ async function handleInit(args, flags, ctx) {
|
|
|
132
125
|
last_synced_at: new Date().toISOString(),
|
|
133
126
|
});
|
|
134
127
|
|
|
135
|
-
// 6.
|
|
128
|
+
// 6. Automatically scan page structures & generate UI business context (.shiplens/contexts/<app_id>.md)
|
|
129
|
+
let contextResult = null;
|
|
130
|
+
try {
|
|
131
|
+
contextResult = generateProjectContext(wd, appId, projectName, framework);
|
|
132
|
+
} catch (e) {}
|
|
133
|
+
|
|
134
|
+
// 7. Install SDK dependency
|
|
136
135
|
let installResult = { success: true, manager: 'skipped' };
|
|
137
136
|
if (!flags['no-install'] && detected.package_manager) {
|
|
138
137
|
installResult = await installSDKDependency(wd, detected.package_manager);
|
|
@@ -160,7 +159,7 @@ async function handleInit(args, flags, ctx) {
|
|
|
160
159
|
}
|
|
161
160
|
}
|
|
162
161
|
|
|
163
|
-
//
|
|
162
|
+
// 8. Bind email if specified
|
|
164
163
|
let targetEmail = flags.email;
|
|
165
164
|
if (targetEmail === 'auto') {
|
|
166
165
|
targetEmail = getGitEmail();
|
|
@@ -188,6 +187,8 @@ async function handleInit(args, flags, ctx) {
|
|
|
188
187
|
|
|
189
188
|
const result = {
|
|
190
189
|
ok: true,
|
|
190
|
+
reconnected: isReusedExisting,
|
|
191
|
+
reused_existing: isReusedExisting,
|
|
191
192
|
app_id: appId,
|
|
192
193
|
project_name: projectName,
|
|
193
194
|
description: description || undefined,
|
|
@@ -201,6 +202,10 @@ async function handleInit(args, flags, ctx) {
|
|
|
201
202
|
sdk_manager: installResult.manager,
|
|
202
203
|
skill_injected: true,
|
|
203
204
|
skill_file: skillFile,
|
|
205
|
+
context_generated: !!contextResult,
|
|
206
|
+
context_file: contextResult ? contextResult.relPath : undefined,
|
|
207
|
+
context_pages_count: contextResult ? contextResult.totalPages : 0,
|
|
208
|
+
context_buttons_count: contextResult ? contextResult.totalButtons : 0,
|
|
204
209
|
package_manager: detected.package_manager,
|
|
205
210
|
auth_status: authStatus,
|
|
206
211
|
shiplens_env_written: authStatus === 'magic_link_sent',
|
|
@@ -209,12 +214,17 @@ async function handleInit(args, flags, ctx) {
|
|
|
209
214
|
bound_email: targetEmail || undefined,
|
|
210
215
|
elapsed_ms: elapsed,
|
|
211
216
|
atomic_completed: true,
|
|
217
|
+
message: isReusedExisting
|
|
218
|
+
? `Existing Shiplens project detected and successfully verified (App ID: ${appId}).`
|
|
219
|
+
: `Shiplens SDK successfully integrated and project created.`,
|
|
212
220
|
};
|
|
213
221
|
|
|
214
222
|
ctx.output(result, () => {
|
|
215
223
|
console.log(`\n==================================================`);
|
|
216
|
-
console.log(
|
|
217
|
-
|
|
224
|
+
console.log(isReusedExisting
|
|
225
|
+
? `✅ Shiplens SDK verified & reconnected (${elapsed} ms)\n`
|
|
226
|
+
: `✅ Shiplens SDK successfully integrated (${elapsed} ms)\n`);
|
|
227
|
+
console.log(`📦 Project & Dashboard Information` + (isReusedExisting ? ` (Existing setup preserved)` : ''));
|
|
218
228
|
console.log(`Project Name: ${projectName}`);
|
|
219
229
|
if (description) {
|
|
220
230
|
console.log(`Description: ${description}`);
|
|
@@ -228,6 +238,9 @@ async function handleInit(args, flags, ctx) {
|
|
|
228
238
|
console.log(`Live Dashboard / Activation URL: 🔗 ${dashboardUrl}`);
|
|
229
239
|
console.log(`User Account: ${accountStatusText}`);
|
|
230
240
|
console.log(`🧠 AI Skill Ready: ${skillFile}`);
|
|
241
|
+
if (contextResult && contextResult.relPath) {
|
|
242
|
+
console.log(`📑 Business Context: ${contextResult.relPath} (${contextResult.totalPages} pages, ${contextResult.totalButtons} buttons mapped)`);
|
|
243
|
+
}
|
|
231
244
|
console.log(`🔒 [SHIPLENS_ATOMIC_COMPLETED] Setup completed atomically. No extra commands needed.`);
|
|
232
245
|
console.log(`==================================================\n`);
|
|
233
246
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { detectProjectPages } = require('./scanner');
|
|
4
|
+
const { extractPageSkeleton } = require('./extractor');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Infer page purpose from headings, descriptions, and route name
|
|
8
|
+
*/
|
|
9
|
+
function inferPagePurpose(page, headings, descriptions, faqs) {
|
|
10
|
+
if (descriptions && descriptions.length > 0) {
|
|
11
|
+
return descriptions[0];
|
|
12
|
+
}
|
|
13
|
+
if (headings && headings.length > 0) {
|
|
14
|
+
return headings.join(' / ');
|
|
15
|
+
}
|
|
16
|
+
if (page.path === '/') {
|
|
17
|
+
return 'Primary landing / core product overview page';
|
|
18
|
+
}
|
|
19
|
+
const cleanName = page.name.replace(/[^a-zA-Z0-9_\- ]/g, '');
|
|
20
|
+
return `Core ${cleanName} feature page`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Infer intent and next step for interactive controls
|
|
25
|
+
*/
|
|
26
|
+
function inferButtonIntent(btn, page) {
|
|
27
|
+
if (btn.action_hint) {
|
|
28
|
+
return btn.action_hint;
|
|
29
|
+
}
|
|
30
|
+
const t = (btn.text || '').toLowerCase();
|
|
31
|
+
if (t.includes('start') || t.includes('free') || t.includes('try') || t.includes('get started') || t.includes('免费') || t.includes('试用')) {
|
|
32
|
+
return 'Primary conversion CTA / onboarding initiation';
|
|
33
|
+
}
|
|
34
|
+
if (t.includes('sign up') || t.includes('register') || t.includes('注册')) {
|
|
35
|
+
return 'User account registration';
|
|
36
|
+
}
|
|
37
|
+
if (t.includes('login') || t.includes('sign in') || t.includes('登录')) {
|
|
38
|
+
return 'User authentication login';
|
|
39
|
+
}
|
|
40
|
+
if (t.includes('upgrade') || t.includes('buy') || t.includes('pro') || t.includes('pricing') || t.includes('订阅') || t.includes('购买')) {
|
|
41
|
+
return 'Monetization checkout / subscription upgrade';
|
|
42
|
+
}
|
|
43
|
+
if (t.includes('save') || t.includes('submit') || t.includes('保存') || t.includes('提交')) {
|
|
44
|
+
return 'Save settings or submit form data';
|
|
45
|
+
}
|
|
46
|
+
if (t.includes('delete') || t.includes('remove') || t.includes('删除')) {
|
|
47
|
+
return 'Delete or purge data item';
|
|
48
|
+
}
|
|
49
|
+
if (t.includes('calculate') || t.includes('search') || t.includes('filter') || t.includes('计算') || t.includes('查询')) {
|
|
50
|
+
return 'Execute calculation / filter workflow';
|
|
51
|
+
}
|
|
52
|
+
return `Trigger ${btn.tag} interaction on ${page.path}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate standard business context markdown and write to .shiplens/contexts/<app_id>.md
|
|
57
|
+
* @param {string} dir Project root directory
|
|
58
|
+
* @param {string} appId Target App ID
|
|
59
|
+
* @param {string} projectName Project Name
|
|
60
|
+
* @param {string} framework Framework detected
|
|
61
|
+
* @returns {{ filePath: string, relPath: string, totalPages: number, totalButtons: number, markdown: string }}
|
|
62
|
+
*/
|
|
63
|
+
function generateProjectContext(dir = process.cwd(), appId, projectName = 'My App', framework = 'custom') {
|
|
64
|
+
const { framework: detectedFramework, pages } = detectProjectPages(dir);
|
|
65
|
+
const activeFramework = framework || detectedFramework;
|
|
66
|
+
|
|
67
|
+
const pageDetails = [];
|
|
68
|
+
let totalButtonsCount = 0;
|
|
69
|
+
|
|
70
|
+
for (const p of pages) {
|
|
71
|
+
const skeleton = extractPageSkeleton(p.filePath, p.path, p.name);
|
|
72
|
+
const purpose = inferPagePurpose(p, skeleton.headings, skeleton.descriptions, skeleton.faqs);
|
|
73
|
+
totalButtonsCount += skeleton.raw_buttons.length;
|
|
74
|
+
pageDetails.push({
|
|
75
|
+
...p,
|
|
76
|
+
relFilePath: path.relative(dir, p.filePath).replace(/\\/g, '/'),
|
|
77
|
+
purpose,
|
|
78
|
+
headings: skeleton.headings,
|
|
79
|
+
faqs: skeleton.faqs,
|
|
80
|
+
feature_bullets: skeleton.feature_bullets,
|
|
81
|
+
descriptions: skeleton.descriptions,
|
|
82
|
+
buttons: skeleton.raw_buttons.map((b) => ({
|
|
83
|
+
...b,
|
|
84
|
+
intent: inferButtonIntent(b, p),
|
|
85
|
+
})),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const mdLines = [
|
|
91
|
+
'# Shiplens Project Business Context & UI Dictionary',
|
|
92
|
+
'',
|
|
93
|
+
`> **Project Name**: ${projectName}`,
|
|
94
|
+
`> **App ID**: ${appId}`,
|
|
95
|
+
`> **Framework**: ${activeFramework}`,
|
|
96
|
+
`> **Generated At**: ${now}`,
|
|
97
|
+
'',
|
|
98
|
+
'---',
|
|
99
|
+
'',
|
|
100
|
+
'## 1. Page & Route Catalog',
|
|
101
|
+
'',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
if (pageDetails.length === 0) {
|
|
105
|
+
mdLines.push('*No frontend template pages detected during initial static scanning.*');
|
|
106
|
+
mdLines.push('');
|
|
107
|
+
} else {
|
|
108
|
+
for (const p of pageDetails) {
|
|
109
|
+
mdLines.push(`### Page: ${p.name} (\`${p.path}\`)`);
|
|
110
|
+
mdLines.push(`- **Source File**: [\`${p.relFilePath}\`](file:///${path.resolve(dir, p.relFilePath).replace(/\\/g, '/')})`);
|
|
111
|
+
mdLines.push(`- **Page Purpose**: ${p.purpose}`);
|
|
112
|
+
|
|
113
|
+
if (p.headings.length > 0) {
|
|
114
|
+
mdLines.push(`- **Key Headings**: ${p.headings.map((h) => `\`${h}\``).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (p.feature_bullets.length > 0) {
|
|
117
|
+
mdLines.push('- **Feature Highlights**:');
|
|
118
|
+
for (const f of p.feature_bullets.slice(0, 5)) {
|
|
119
|
+
mdLines.push(` - ${f}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (p.faqs.length > 0) {
|
|
123
|
+
mdLines.push('- **FAQ Context**:');
|
|
124
|
+
for (const f of p.faqs.slice(0, 3)) {
|
|
125
|
+
mdLines.push(` - Q: ${f.q}${f.a ? ` -> A: ${f.a}` : ''}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (p.buttons.length > 0) {
|
|
130
|
+
mdLines.push('');
|
|
131
|
+
mdLines.push(`#### Interactive Elements & Buttons (${p.buttons.length}):`);
|
|
132
|
+
mdLines.push('| Button ID | Text / Label | Selector | Inferred Action / Intent |');
|
|
133
|
+
mdLines.push('| :--- | :--- | :--- | :--- |');
|
|
134
|
+
for (const b of p.buttons) {
|
|
135
|
+
const textDisplay = (b.text || '').replace(/\|/g, '\\|').trim() || '(icon/action)';
|
|
136
|
+
mdLines.push(`| \`${b.id}\` | ${textDisplay} | \`${b.selector}\` | ${b.intent} |`);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
mdLines.push('- **Interactive Elements**: *(No buttons or interactive controls found on this page)*');
|
|
140
|
+
}
|
|
141
|
+
mdLines.push('');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
mdLines.push('---');
|
|
146
|
+
mdLines.push('');
|
|
147
|
+
mdLines.push('## 2. Business Metric Grounding');
|
|
148
|
+
mdLines.push(`- **Total Pages Mapped**: ${pageDetails.length}`);
|
|
149
|
+
mdLines.push(`- **Total Interactive Controls**: ${totalButtonsCount}`);
|
|
150
|
+
mdLines.push('- **Usage**: When analyzing drop-offs, funnels, or heatmaps, reference the Button IDs and Page Purposes above to translate technical telemetry into actionable product insights.');
|
|
151
|
+
mdLines.push('');
|
|
152
|
+
|
|
153
|
+
const markdownContent = mdLines.join('\n');
|
|
154
|
+
|
|
155
|
+
// Standard storage path: .shiplens/contexts/<app_id>.md
|
|
156
|
+
const contextDir = path.join(dir, '.shiplens', 'contexts');
|
|
157
|
+
fs.mkdirSync(contextDir, { recursive: true });
|
|
158
|
+
const targetFilePath = path.join(contextDir, `${appId}.md`);
|
|
159
|
+
fs.writeFileSync(targetFilePath, markdownContent, 'utf8');
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
filePath: targetFilePath,
|
|
163
|
+
relPath: path.relative(dir, targetFilePath).replace(/\\/g, '/'),
|
|
164
|
+
totalPages: pageDetails.length,
|
|
165
|
+
totalButtons: totalButtonsCount,
|
|
166
|
+
markdown: markdownContent,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
generateProjectContext,
|
|
172
|
+
inferPagePurpose,
|
|
173
|
+
inferButtonIntent,
|
|
174
|
+
};
|
package/lib/extractor.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 清洗代码字符串,去除注释、import、CSS-in-JS 与复杂逻辑噪音
|
|
5
|
+
* @param {string} rawCode 原始文件代码
|
|
6
|
+
* @returns {string} 精简后的模板与文案字符串
|
|
7
|
+
*/
|
|
8
|
+
function cleanCode(rawCode) {
|
|
9
|
+
return rawCode
|
|
10
|
+
// 移除多行注释与 JSDoc
|
|
11
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
12
|
+
// 移除单行注释
|
|
13
|
+
.replace(/\/\/.*$/gm, '')
|
|
14
|
+
// 移除 import 语句
|
|
15
|
+
.replace(/^import\s+[\s\S]*?from\s+['"].*?['"];?/gm, '')
|
|
16
|
+
// 移除 require 语句
|
|
17
|
+
.replace(/const\s+.*?=\s*require\(.*?\);?/gm, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 深度提取页面内的多维富文本文案:
|
|
22
|
+
* 包含:H1~H6 标题、FAQ 折叠问答、套餐权益列表 (li)、卡片段落描述 (p)、Badge 标签
|
|
23
|
+
* @param {string} code 清洗后的代码
|
|
24
|
+
* @returns {{ headings: string[], faqs: Array<{ q: string, a?: string }>, feature_bullets: string[], descriptions: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
function extractRichPageContent(code) {
|
|
27
|
+
const headings = [];
|
|
28
|
+
const faqs = [];
|
|
29
|
+
const featureBullets = [];
|
|
30
|
+
const descriptions = [];
|
|
31
|
+
|
|
32
|
+
// 1. 提取 H1 到 H6 标题
|
|
33
|
+
const headingRegex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = headingRegex.exec(code)) !== null) {
|
|
36
|
+
const text = cleanTagContent(match[1]);
|
|
37
|
+
if (text && text.length > 1 && text.length < 120 && !headings.includes(text)) {
|
|
38
|
+
headings.push(text);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. 提取 FAQ 问答结构 (<details><summary> 或常见 FAQ 容器)
|
|
43
|
+
const detailsRegex = /<details[^>]*>[\s\S]*?<summary[^>]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi;
|
|
44
|
+
while ((match = detailsRegex.exec(code)) !== null) {
|
|
45
|
+
const question = cleanTagContent(match[1]);
|
|
46
|
+
const answer = cleanTagContent(match[2]);
|
|
47
|
+
if (question && question.length > 2) {
|
|
48
|
+
faqs.push({
|
|
49
|
+
q: question,
|
|
50
|
+
...(answer ? { a: answer.slice(0, 150) } : {})
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 匹配 dt/dd 键值问答
|
|
56
|
+
const dtRegex = /<dt[^>]*>([\s\S]*?)<\/dt>[\s\S]*?<dd[^>]*>([\s\S]*?)<\/dd>/gi;
|
|
57
|
+
while ((match = dtRegex.exec(code)) !== null) {
|
|
58
|
+
const q = cleanTagContent(match[1]);
|
|
59
|
+
const a = cleanTagContent(match[2]);
|
|
60
|
+
if (q && q.length > 2 && !faqs.some(f => f.q === q)) {
|
|
61
|
+
faqs.push({ q, ...(a ? { a: a.slice(0, 150) } : {}) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. 提取套餐权益与核心特性列表 (<li>)
|
|
66
|
+
const liRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
|
|
67
|
+
while ((match = liRegex.exec(code)) !== null) {
|
|
68
|
+
const text = cleanTagContent(match[1]);
|
|
69
|
+
if (text && text.length > 2 && text.length < 100 && !featureBullets.includes(text)) {
|
|
70
|
+
featureBullets.push(text);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. 提取核心描述段落 (<p> 及含有 desc/title/text 类的元素)
|
|
75
|
+
const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi;
|
|
76
|
+
while ((match = pRegex.exec(code)) !== null) {
|
|
77
|
+
const text = cleanTagContent(match[1]);
|
|
78
|
+
if (text && text.length > 5 && text.length < 200 && !descriptions.includes(text) && !headings.includes(text)) {
|
|
79
|
+
descriptions.push(text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
headings: headings.slice(0, 8),
|
|
85
|
+
faqs: faqs.slice(0, 10),
|
|
86
|
+
feature_bullets: featureBullets.slice(0, 12),
|
|
87
|
+
descriptions: descriptions.slice(0, 6)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 提取页面内所有的交互按钮、链接和提交控件
|
|
93
|
+
* 采用状态机/匹配法准确跳过 JSX 属性中的 arrow function `() =>` 和花括号
|
|
94
|
+
* @param {string} code 清洗后的代码
|
|
95
|
+
* @returns {Array<{ id: string, tag: string, text: string, selector: string, label?: string, action_hint?: string }>}
|
|
96
|
+
*/
|
|
97
|
+
function extractInteractiveElements(code) {
|
|
98
|
+
const elements = [];
|
|
99
|
+
let elementIndex = 1;
|
|
100
|
+
|
|
101
|
+
// 匹配所有 <button, <a, <Link, <input, <div, <span 开头的标签
|
|
102
|
+
const tagStartRegex = /<([a-zA-Z0-9_-]+)\s+/g;
|
|
103
|
+
let match;
|
|
104
|
+
|
|
105
|
+
while ((match = tagStartRegex.exec(code)) !== null) {
|
|
106
|
+
const tag = match[1];
|
|
107
|
+
const targetTags = ['button', 'a', 'link', 'input', 'div', 'span'];
|
|
108
|
+
if (!targetTags.includes(tag.toLowerCase())) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const startIndex = match.index;
|
|
113
|
+
let index = startIndex + match[0].length;
|
|
114
|
+
let inQuotes = null;
|
|
115
|
+
let braceDepth = 0;
|
|
116
|
+
let isSelfClosing = false;
|
|
117
|
+
let tagEndIndex = -1;
|
|
118
|
+
|
|
119
|
+
// 解析属性直到遇到真正的 '>'
|
|
120
|
+
while (index < code.length) {
|
|
121
|
+
const char = code[index];
|
|
122
|
+
const prevChar = code[index - 1];
|
|
123
|
+
|
|
124
|
+
if (inQuotes) {
|
|
125
|
+
if (char === inQuotes && prevChar !== '\\') {
|
|
126
|
+
inQuotes = null;
|
|
127
|
+
}
|
|
128
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
129
|
+
inQuotes = char;
|
|
130
|
+
} else if (char === '{') {
|
|
131
|
+
braceDepth += 1;
|
|
132
|
+
} else if (char === '}') {
|
|
133
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
134
|
+
} else if (char === '>' && braceDepth === 0) {
|
|
135
|
+
tagEndIndex = index;
|
|
136
|
+
if (code[index - 1] === '/') {
|
|
137
|
+
isSelfClosing = true;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (tagEndIndex === -1) continue;
|
|
145
|
+
|
|
146
|
+
const attrs = code.slice(startIndex + tag.length + 1, tagEndIndex);
|
|
147
|
+
let innerContent = '';
|
|
148
|
+
|
|
149
|
+
if (!isSelfClosing) {
|
|
150
|
+
// 寻找对应的闭合标签 </tag>
|
|
151
|
+
const closeTagStr = `</${tag}>`;
|
|
152
|
+
const closeTagIndex = code.indexOf(closeTagStr, tagEndIndex + 1);
|
|
153
|
+
if (closeTagIndex !== -1 && closeTagIndex - tagEndIndex < 2000) {
|
|
154
|
+
innerContent = code.slice(tagEndIndex + 1, closeTagIndex);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 判断是否可交互
|
|
159
|
+
const isExplicitButton = /^(button|a|link)$/i.test(tag);
|
|
160
|
+
const hasClick = /onClick|@click|v-on:click|role=["']button["']/i.test(attrs);
|
|
161
|
+
const isSubmit = /type=["']submit["']/i.test(attrs);
|
|
162
|
+
|
|
163
|
+
if (!isExplicitButton && !hasClick && !isSubmit) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 提取 id
|
|
168
|
+
const idMatch = attrs.match(/\bid=["']([^"']+)["']/i);
|
|
169
|
+
const rawId = idMatch ? idMatch[1] : null;
|
|
170
|
+
|
|
171
|
+
// 提取 data-shiplens-label
|
|
172
|
+
const labelMatch = attrs.match(/data-shiplens-label=["']([^"']+)["']/i);
|
|
173
|
+
const explicitLabel = labelMatch ? labelMatch[1] : null;
|
|
174
|
+
|
|
175
|
+
// 提取 class/className
|
|
176
|
+
const classMatch = attrs.match(/(?:class|className)=["']([^"']+)["']/i);
|
|
177
|
+
const classStr = classMatch ? cleanClassName(classMatch[1]) : '';
|
|
178
|
+
|
|
179
|
+
// 提取文案
|
|
180
|
+
const text = cleanTagContent(innerContent) || extractInputValue(attrs);
|
|
181
|
+
|
|
182
|
+
// 提取 href / to / onClick 提示
|
|
183
|
+
const hrefMatch = attrs.match(/(?:href|to)=["']([^"']+)["']/i);
|
|
184
|
+
const href = hrefMatch ? hrefMatch[1] : '';
|
|
185
|
+
|
|
186
|
+
const clickMatch = attrs.match(/(?:onClick|@click)=["']?\{?([^"'>}]+)\}?["']?/i);
|
|
187
|
+
const clickHandler = clickMatch ? clickMatch[1].trim() : '';
|
|
188
|
+
|
|
189
|
+
// 生成稳定 ID
|
|
190
|
+
const stableId = explicitLabel
|
|
191
|
+
|| rawId
|
|
192
|
+
|| generateIdFromTextOrClass(text, classStr, tag, elementIndex);
|
|
193
|
+
|
|
194
|
+
// 生成可读 CSS 选择器
|
|
195
|
+
const selector = rawId
|
|
196
|
+
? `#${rawId}`
|
|
197
|
+
: classStr
|
|
198
|
+
? `${tag.toLowerCase()}.${classStr.split(' ')[0]}`
|
|
199
|
+
: `${tag.toLowerCase()}`;
|
|
200
|
+
|
|
201
|
+
// 动作推断线索
|
|
202
|
+
let actionHint = '';
|
|
203
|
+
if (href) actionHint = `navigate to ${href}`;
|
|
204
|
+
else if (clickHandler) actionHint = `calls ${clickHandler}`;
|
|
205
|
+
else if (isSubmit) actionHint = 'submit form';
|
|
206
|
+
|
|
207
|
+
elements.push({
|
|
208
|
+
id: stableId,
|
|
209
|
+
tag: tag.toLowerCase(),
|
|
210
|
+
text: text || '(图标/无文案)',
|
|
211
|
+
selector: selector,
|
|
212
|
+
...(explicitLabel ? { label: explicitLabel } : {}),
|
|
213
|
+
...(actionHint ? { action_hint: actionHint } : {})
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
elementIndex += 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 去重保底(相同 id 保留第一个)
|
|
220
|
+
const seenIds = new Set();
|
|
221
|
+
return elements.filter(el => {
|
|
222
|
+
if (seenIds.has(el.id)) return false;
|
|
223
|
+
seenIds.add(el.id);
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
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}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return `btn_${tag.toLowerCase()}_${index}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 完整解析一个页面的结构与富文本骨架
|
|
272
|
+
* @param {string} filePath 文件路径
|
|
273
|
+
* @param {string} routePath 对应路由
|
|
274
|
+
* @param {string} pageName 页面候选名称
|
|
275
|
+
* @returns {{ path: string, name: string, headings: string[], faqs: Array, feature_bullets: string[], descriptions: string[], raw_buttons: Array }}
|
|
276
|
+
*/
|
|
277
|
+
function extractPageSkeleton(filePath, routePath, pageName) {
|
|
278
|
+
if (!fs.existsSync(filePath)) {
|
|
279
|
+
return { path: routePath, name: pageName, headings: [], faqs: [], feature_bullets: [], descriptions: [], raw_buttons: [] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const rawCode = fs.readFileSync(filePath, 'utf-8');
|
|
283
|
+
const code = cleanCode(rawCode);
|
|
284
|
+
const richContent = extractRichPageContent(code);
|
|
285
|
+
const rawButtons = extractInteractiveElements(code);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
path: routePath,
|
|
289
|
+
name: pageName,
|
|
290
|
+
...richContent,
|
|
291
|
+
raw_buttons: rawButtons
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
cleanCode,
|
|
297
|
+
extractRichPageContent,
|
|
298
|
+
extractInteractiveElements,
|
|
299
|
+
extractPageSkeleton
|
|
300
|
+
};
|
package/lib/injector.js
CHANGED
|
@@ -394,7 +394,7 @@ async function installSDKDependency(dir, pkgManager = 'npm') {
|
|
|
394
394
|
},
|
|
395
395
|
];
|
|
396
396
|
|
|
397
|
-
const TIER1_DELAY_MS =
|
|
397
|
+
const TIER1_DELAY_MS = 5 * 1000; // 5s (fast-forward backup mirrors on high latency)
|
|
398
398
|
const TOTAL_TIMEOUT_MS = 5 * 60 * 1000; // 5min
|
|
399
399
|
|
|
400
400
|
return new Promise((resolve) => {
|
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 递归扫描目录中的代码文件
|
|
6
|
+
* @param {string} dir 目标目录
|
|
7
|
+
* @param {string[]} extensions 支持的扩展名
|
|
8
|
+
* @param {string[]} ignoreDirs 忽略的目录
|
|
9
|
+
* @returns {string[]} 文件绝对路径列表
|
|
10
|
+
*/
|
|
11
|
+
function scanFiles(dir, extensions = ['.tsx', '.jsx', '.vue', '.html', '.svelte', '.js', '.ts'], ignoreDirs = ['node_modules', '.git', '.next', 'dist', 'build', 'out', '.agents']) {
|
|
12
|
+
const results = [];
|
|
13
|
+
if (!fs.existsSync(dir)) return results;
|
|
14
|
+
|
|
15
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (!ignoreDirs.includes(entry.name)) {
|
|
20
|
+
results.push(...scanFiles(fullPath, extensions, ignoreDirs));
|
|
21
|
+
}
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
24
|
+
if (extensions.includes(ext)) {
|
|
25
|
+
results.push(fullPath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 自动探测项目类型并提取页面路由列表
|
|
34
|
+
* @param {string} rootDir 项目根目录
|
|
35
|
+
* @returns {{ framework: string, pages: Array<{ path: string, filePath: string, name: string }> }}
|
|
36
|
+
*/
|
|
37
|
+
function detectProjectPages(rootDir) {
|
|
38
|
+
const pages = [];
|
|
39
|
+
let framework = 'custom';
|
|
40
|
+
|
|
41
|
+
// 1. 检测 Next.js App Router (src/app 或 app)
|
|
42
|
+
const appRouterDir = fs.existsSync(path.join(rootDir, 'src', 'app'))
|
|
43
|
+
? path.join(rootDir, 'src', 'app')
|
|
44
|
+
: fs.existsSync(path.join(rootDir, 'app'))
|
|
45
|
+
? path.join(rootDir, 'app')
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
if (appRouterDir) {
|
|
49
|
+
framework = 'nextjs-app-router';
|
|
50
|
+
const allFiles = scanFiles(appRouterDir, ['.tsx', '.jsx', '.js', '.ts']);
|
|
51
|
+
for (const file of allFiles) {
|
|
52
|
+
const baseName = path.basename(file);
|
|
53
|
+
if (/^page\.(tsx|jsx|js|ts)$/.test(baseName)) {
|
|
54
|
+
const relDir = path.relative(appRouterDir, path.dirname(file));
|
|
55
|
+
const routePath = relDir === '' ? '/' : `/${relDir.replace(/\\/g, '/').replace(/\(.*?\)\/?/g, '')}`;
|
|
56
|
+
pages.push({
|
|
57
|
+
path: routePath || '/',
|
|
58
|
+
filePath: file,
|
|
59
|
+
name: routePath === '/' ? 'Home (首页)' : routePath
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. 检测 Next.js / Nuxt Pages Router (src/pages 或 pages)
|
|
66
|
+
if (pages.length === 0) {
|
|
67
|
+
const pagesRouterDir = fs.existsSync(path.join(rootDir, 'src', 'pages'))
|
|
68
|
+
? path.join(rootDir, 'src', 'pages')
|
|
69
|
+
: fs.existsSync(path.join(rootDir, 'pages'))
|
|
70
|
+
? path.join(rootDir, 'pages')
|
|
71
|
+
: null;
|
|
72
|
+
|
|
73
|
+
if (pagesRouterDir) {
|
|
74
|
+
framework = 'pages-router';
|
|
75
|
+
const allFiles = scanFiles(pagesRouterDir, ['.tsx', '.jsx', '.vue', '.js', '.ts']);
|
|
76
|
+
for (const file of allFiles) {
|
|
77
|
+
const rel = path.relative(pagesRouterDir, file);
|
|
78
|
+
const nameWithoutExt = rel.replace(/\.[^/.]+$/, '').replace(/\\/g, '/');
|
|
79
|
+
if (nameWithoutExt.startsWith('_') || nameWithoutExt.startsWith('api/')) continue;
|
|
80
|
+
|
|
81
|
+
const routePath = nameWithoutExt === 'index' ? '/' : `/${nameWithoutExt.replace(/\/index$/, '')}`;
|
|
82
|
+
pages.push({
|
|
83
|
+
path: routePath,
|
|
84
|
+
filePath: file,
|
|
85
|
+
name: routePath === '/' ? 'Home (首页)' : routePath
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. 检测通用 Vite / React / Vue (src/views, src/routes, src/pages, 或扫描全部 .vue/.tsx 组件)
|
|
92
|
+
if (pages.length === 0) {
|
|
93
|
+
const candidateDirs = ['src/views', 'src/pages', 'src/routes', 'src/components', 'src'].map(d => path.join(rootDir, d));
|
|
94
|
+
for (const cDir of candidateDirs) {
|
|
95
|
+
if (fs.existsSync(cDir)) {
|
|
96
|
+
const files = scanFiles(cDir, ['.tsx', '.jsx', '.vue', '.html', '.svelte']);
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const base = path.basename(file, path.extname(file));
|
|
99
|
+
// 过滤通用辅助或原子组件 (如 Button, Icon, Header)
|
|
100
|
+
if (/^(Page|View|Screen|Home|Pricing|Login|Dashboard|Checkout|Register|Setting|About)/i.test(base)) {
|
|
101
|
+
const cleanName = base.toLowerCase().replace(/(page|view|screen)$/i, '');
|
|
102
|
+
const routePath = cleanName === 'home' || cleanName === 'index' ? '/' : `/${cleanName}`;
|
|
103
|
+
pages.push({
|
|
104
|
+
path: routePath,
|
|
105
|
+
filePath: file,
|
|
106
|
+
name: base
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (pages.length > 0) {
|
|
111
|
+
framework = 'vite-react-vue';
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. 静态 HTML 项目保底
|
|
119
|
+
if (pages.length === 0) {
|
|
120
|
+
const htmlFiles = scanFiles(rootDir, ['.html'], ['node_modules', '.git', 'dist']);
|
|
121
|
+
if (htmlFiles.length > 0) {
|
|
122
|
+
framework = 'static-html';
|
|
123
|
+
for (const file of htmlFiles) {
|
|
124
|
+
const rel = path.relative(rootDir, file).replace(/\\/g, '/');
|
|
125
|
+
const routePath = rel === 'index.html' ? '/' : `/${rel.replace(/\.html$/, '')}`;
|
|
126
|
+
pages.push({
|
|
127
|
+
path: routePath,
|
|
128
|
+
filePath: file,
|
|
129
|
+
name: path.basename(file)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 去重保底
|
|
136
|
+
const uniqueMap = new Map();
|
|
137
|
+
for (const p of pages) {
|
|
138
|
+
if (!uniqueMap.has(p.path)) {
|
|
139
|
+
uniqueMap.set(p.path, p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
framework,
|
|
145
|
+
pages: Array.from(uniqueMap.values())
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
scanFiles,
|
|
151
|
+
detectProjectPages
|
|
152
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shiplens/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "Shiplens CLI — Automated Web User Analytics & AI Agent Analysis Engine",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"cli": "
|
|
8
|
-
"shiplens": "
|
|
9
|
-
"shiplens-cli": "
|
|
7
|
+
"cli": "bin/shiplens.js",
|
|
8
|
+
"shiplens": "bin/shiplens.js",
|
|
9
|
+
"shiplens-cli": "bin/shiplens.js"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"shiplens",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
## Scenario Outline (
|
|
5
|
+
## Scenario Outline (43 Scenarios)
|
|
6
6
|
|
|
7
7
|
> **How to use**: Scan this outline to find the matching scenario, then jump to its full section below for deterministic CLI commands and analysis steps.
|
|
8
8
|
|
|
@@ -61,16 +61,17 @@
|
|
|
61
61
|
34. [Track and Measure User Aha Moment](#track-and-measure-user-aha-moment)
|
|
62
62
|
35. [Track Subscription Revenue & Build Daily Subscription Dashboard](#track-subscription-revenue--build-daily-subscription-dashboard)
|
|
63
63
|
|
|
64
|
-
### Initial Setup (
|
|
65
|
-
36. [Shiplens CLI One-Click Setup & Analytics Initialization](#shiplens-cli-one-click-setup
|
|
66
|
-
37. [
|
|
67
|
-
38. [
|
|
68
|
-
39. [
|
|
64
|
+
### Initial Setup (5)
|
|
65
|
+
36. [Shiplens CLI One-Click Setup & Analytics Initialization](#shiplens-cli-one-click-setup-analytics-initialization)
|
|
66
|
+
37. [Use Shiplens CLI to Install Analytics SDK & Link to Existing Account](#use-shiplens-cli-to-install-analytics-sdk-link-to-existing-account)
|
|
67
|
+
38. [Delete Shiplens Project Analytics Data & SDK (No Longer Needed)](#delete-shiplens-project-analytics-data-sdk-no-longer-needed)
|
|
68
|
+
39. [Delete Shiplens CLI](#delete-shiplens-cli)
|
|
69
|
+
40. [Use Shiplens CLI to Generate Context Descriptions for All Pages and Buttons](#use-shiplens-cli-to-generate-context-descriptions-for-all-pages-and-buttons)
|
|
69
70
|
|
|
70
71
|
### Troubleshooting & Diagnostics (3)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
41. [Test Shiplens Telemetry Pipeline and Environment](#test-shiplens-telemetry-pipeline-and-environment)
|
|
73
|
+
42. [Enable Local Debug Mode and Verify Event Reporting](#enable-local-debug-mode-and-verify-event-reporting)
|
|
74
|
+
43. [Test Event Reporting in Production Build & Release Environments](#test-event-reporting-in-production-build--release-environments)
|
|
74
75
|
|
|
75
76
|
---
|
|
76
77
|
|
|
@@ -734,18 +735,44 @@ Sources:
|
|
|
734
735
|
|
|
735
736
|
#### [setup_init] Shiplens CLI One-Click Setup & Analytics Initialization (Suffix: None)
|
|
736
737
|
```text
|
|
737
|
-
Set up Shiplens analytics:
|
|
738
|
+
Set up Shiplens analytics: install SDK, create the project, and retrieve the live dashboard URL.
|
|
738
739
|
1. Run CLI initialization command:
|
|
739
|
-
- Windows: npx.cmd --yes @shiplens/cli init
|
|
740
|
-
- macOS/Linux: npx --yes @shiplens/cli init
|
|
740
|
+
- Windows: npx.cmd --yes @shiplens/cli init --json
|
|
741
|
+
- macOS/Linux: npx --yes @shiplens/cli init --json
|
|
741
742
|
2. Present the standard project & dashboard summary (including: Project Name, App ID, Code Injection file, User Account Status, Live Dashboard / Activation URL);
|
|
742
743
|
3. Immediately prompt the user for their email address (informing them that you will register on their behalf and Shiplens will send an activation email for 50,000 monthly free events and project binding);
|
|
743
744
|
4. Upon receiving the user's email, run: npx.cmd --yes @shiplens/cli auth bind --email <email> --json.
|
|
744
745
|
```
|
|
745
746
|
|
|
746
|
-
#### [
|
|
747
|
+
#### [setup_bind_project] Use Shiplens CLI to Install Analytics SDK & Link to Existing Account (Suffix: None)
|
|
747
748
|
```text
|
|
748
|
-
|
|
749
|
+
Use Shiplens CLI to install the analytics SDK in this new project and link it to the user's existing Shiplens account:
|
|
750
|
+
|
|
751
|
+
1. Run the project setup & initialization command:
|
|
752
|
+
- Windows: npx.cmd --yes @shiplens/cli init --json
|
|
753
|
+
- macOS/Linux: npx --yes @shiplens/cli init --json
|
|
754
|
+
(Automatically detects framework, installs SDK dependency, injects code entry, and creates cloud project)
|
|
755
|
+
|
|
756
|
+
2. Inspect returned `user_account_status` and credential states:
|
|
757
|
+
- [Branch A: Logged in & linked] (`logged_in_linked`):
|
|
758
|
+
No extra step needed; present the project summary card containing App ID, Project Name, Injected file, and Live Dashboard URL (`dashboard_url`).
|
|
759
|
+
- [Branch B: No local credentials detected] (`not_logged_in_unlinked` or `activation_required=true`):
|
|
760
|
+
Prompt the user for their registered Shiplens account email, and upon reply run:
|
|
761
|
+
`npx.cmd --yes @shiplens/cli auth bind --email <email> --json`
|
|
762
|
+
Instruct the user to click the Magic Link in the email to authorize and complete project binding.
|
|
763
|
+
- [Branch C: Account quota / project limit reached] (Error `QUOTA_EXCEEDED` / `PLAN_LIMIT` / unable to link):
|
|
764
|
+
Clearly notify the user that their current free/existing tier project limit or monthly event quota is full;
|
|
765
|
+
Provide upgrade instructions: recommend upgrading to Pro / Team tier (URL: https://shiplens.dev/pricing or console settings) for expanded project limits and advanced analytics;
|
|
766
|
+
Note that once upgraded, re-running the bind command will activate immediately without modifying code.
|
|
767
|
+
|
|
768
|
+
3. Output the final project summary and dashboard link, confirming the project is ready for analytics queries.
|
|
769
|
+
|
|
770
|
+
Please prioritize calling Shiplens CLI (Action: setup_bind_project) to execute the above requirements.
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
#### [remove_sdk] Delete Shiplens Project Analytics Data & SDK (No Longer Needed) (Suffix: None)
|
|
774
|
+
```text
|
|
775
|
+
Delete Shiplens project analytics data and SDK integration (analytics no longer needed):
|
|
749
776
|
1. Read `.shiplens.json` in the project root to get project name, app_id, and instrumented file paths.
|
|
750
777
|
2. Clearly display the project name and app_id to be deleted, warn the user that historical events and hosted data will be permanently purged, and pause for explicit user confirmation ("Confirm deleting this project").
|
|
751
778
|
3. Upon confirmation, remove SDK imports/instrumentation code from local frontend files, and delete `./.shiplens.json` and `.shiplens/contexts/<app_id>.md`.
|
|
@@ -754,16 +781,16 @@ Remove Shiplens SDK integration and analytics data:
|
|
|
754
781
|
Please prioritize calling Shiplens CLI (Action: remove_sdk) to execute the above requirements.
|
|
755
782
|
```
|
|
756
783
|
|
|
757
|
-
#### [uninstall_cli]
|
|
784
|
+
#### [uninstall_cli] Delete Shiplens CLI and Restore Project (Suffix: None)
|
|
758
785
|
```text
|
|
759
786
|
Please prioritize calling Shiplens CLI (Action: uninstall_cli) to execute the above requirements.
|
|
760
787
|
- Windows: npm.cmd uninstall -g @shiplens/cli
|
|
761
788
|
- macOS/Linux: npm uninstall -g @shiplens/cli
|
|
762
789
|
```
|
|
763
790
|
|
|
764
|
-
#### [generate_context] Generate Context Descriptions for All Pages and Buttons (Suffix: None)
|
|
791
|
+
#### [generate_context] Use Shiplens CLI to Generate Context Descriptions for All Pages and Buttons (Suffix: None)
|
|
765
792
|
```text
|
|
766
|
-
|
|
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:
|
|
767
794
|
1. Check `./.shiplens.json` for current app_id and project name.
|
|
768
795
|
2. Inspect frontend code and routes to extract feature descriptions, user-facing copy, and button texts, locations, and actions.
|
|
769
796
|
3. Write structured details into `.shiplens/contexts/<app_id>.md`, binding app_id and project name in the header.
|