@hhhpyb/system-assistant-client 1.3.6 → 1.3.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/README.md +2 -2
- package/dist/compiler/angular.js +15 -0
- package/dist/compiler/core.js +852 -0
- package/dist/compiler/index.js +14 -0
- package/dist/compiler/nuxt2.cjs +20 -0
- package/dist/compiler/vite.js +39 -0
- package/dist/compiler/vue2.js +8 -0
- package/dist/compiler/vue3.js +8 -0
- package/dist/compiler/webpack-loader.cjs +71 -0
- package/dist/compiler/webpack-plugin.cjs +33 -0
- package/nuxt.cjs +2 -2
- package/package.json +2 -5
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ export default {
|
|
|
24
24
|
|
|
25
25
|
无需在 `nuxt.config.js` 中配置 `SA_GUIDE_APP_KEY`。模块会在构建时自动将应用标识注入 Nuxt 运行时配置。
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
模块已内置 guide compiler:Nuxt 2/Bridge 的 Webpack 与 Vite 客户端构建均会在不重复挂载的前提下生成 `.nuxt/sa-guide-manifest.json`,无需在业务项目额外安装 compiler 或配置 loader、Vite plugin。
|
|
28
28
|
|
|
29
29
|
### 应用标识推导
|
|
30
30
|
|
|
@@ -69,7 +69,7 @@ URL 带 `?sa_studio=1` 时自动进入 Studio 点选模式,支持 `sa_app_key`
|
|
|
69
69
|
|
|
70
70
|
## 发布(维护者)
|
|
71
71
|
|
|
72
|
-
源码:`system-assistant/deployables/sa-guide-sdk/app/v1/system-assistant.client.js
|
|
72
|
+
源码:`system-assistant/deployables/sa-guide-sdk/app/v1/system-assistant.client.js`;内置 compiler 权威源码:`system-assistant/packages/guide-compiler/src`。
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
75
|
cd packages/system-assistant-client
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { normalizeOptions, transformInlineTemplates, transformTemplate } from './core.js'
|
|
2
|
+
|
|
3
|
+
export function transformAngularTemplate(code, id, options = {}, sink = []) {
|
|
4
|
+
return transformTemplate(code, id, {
|
|
5
|
+
...normalizeOptions(options),
|
|
6
|
+
framework: 'angular',
|
|
7
|
+
}, sink)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function transformAngularComponentTs(code, id, options = {}, sink = []) {
|
|
11
|
+
return transformInlineTemplates(code, id, {
|
|
12
|
+
...normalizeOptions(options),
|
|
13
|
+
framework: 'angular',
|
|
14
|
+
}, sink)
|
|
15
|
+
}
|
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SENSITIVE_WORDS = [
|
|
5
|
+
'salary', 'amount', 'money', 'payroll', 'approval-result',
|
|
6
|
+
'薪资', '工资', '金额', '审批结论', '人员信息', '身份证', '手机号',
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
const ACTION_ALIASES = [
|
|
10
|
+
['add', /^(add|new|create|新增|添加|新建)$/i],
|
|
11
|
+
['edit', /^(edit|update|modify|编辑|修改)$/i],
|
|
12
|
+
['detail', /^(detail|details|view|show|info|详情|查看)$/i],
|
|
13
|
+
['delete', /^(delete|remove|del|删除|移除)$/i],
|
|
14
|
+
['search', /^(search|query|find|查询|搜索)$/i],
|
|
15
|
+
['export', /^(export|download|导出|下载)$/i],
|
|
16
|
+
['import', /^(import|upload|导入|上传)$/i],
|
|
17
|
+
['save', /^(save|保存)$/i],
|
|
18
|
+
['submit', /^(submit|提交)$/i],
|
|
19
|
+
['close', /^(close|cancel|关闭|取消)$/i],
|
|
20
|
+
['filter', /^(filter|筛选|过滤)$/i],
|
|
21
|
+
['switch', /^(switch|change|tab|切换)$/i],
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const TARGET_TAGS = new Set([
|
|
25
|
+
'button', 'a', 'input', 'textarea', 'select', 'option', 'label',
|
|
26
|
+
'table', 'thead', 'tbody', 'th', 'td', 'tr', 'form',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const TARGET_COMPONENT_HINTS = [
|
|
30
|
+
'button', 'btn', 'input', 'select', 'date', 'picker', 'tab', 'tabs', 'menu',
|
|
31
|
+
'dialog', 'modal', 'drawer', 'table', 'column', 'form', 'toolbar', 'search',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
|
35
|
+
|
|
36
|
+
function findTagCloseIndex(code, start) {
|
|
37
|
+
let quote = null
|
|
38
|
+
for (let i = start + 1; i < code.length; i += 1) {
|
|
39
|
+
const ch = code[i]
|
|
40
|
+
if (quote) {
|
|
41
|
+
if (ch === quote && code[i - 1] !== '\\') quote = null
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (ch === '"' || ch === "'") {
|
|
45
|
+
quote = ch
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (ch === '>') return i
|
|
49
|
+
}
|
|
50
|
+
return -1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findNextTemplateTag(code, fromIndex = 0) {
|
|
54
|
+
let i = fromIndex
|
|
55
|
+
while (i < code.length) {
|
|
56
|
+
if (code[i] !== '<') {
|
|
57
|
+
i += 1
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (code.startsWith('<!--', i)) {
|
|
61
|
+
const end = code.indexOf('-->', i + 4)
|
|
62
|
+
i = end >= 0 ? end + 3 : code.length
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (code[i + 1] === '!' || code[i + 1] === '?') {
|
|
66
|
+
const closeIndex = findTagCloseIndex(code, i)
|
|
67
|
+
i = closeIndex >= 0 ? closeIndex + 1 : code.length
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (code[i + 1] === '/') {
|
|
71
|
+
const closeTagMatch = code.slice(i + 2).match(/^([A-Za-z][\w.:-]*)/)
|
|
72
|
+
if (closeTagMatch) {
|
|
73
|
+
const tag = closeTagMatch[1]
|
|
74
|
+
const closeIndex = findTagCloseIndex(code, i)
|
|
75
|
+
if (closeIndex < 0) return null
|
|
76
|
+
return { index: i, tag, attrs: '', raw: code.slice(i, closeIndex + 1), closeIndex, isClose: true }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const nameMatch = code.slice(i + 1).match(/^([A-Za-z][\w.:-]*)/)
|
|
80
|
+
if (!nameMatch) {
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const tag = nameMatch[1]
|
|
85
|
+
const closeIndex = findTagCloseIndex(code, i)
|
|
86
|
+
if (closeIndex < 0) return null
|
|
87
|
+
const raw = code.slice(i, closeIndex + 1)
|
|
88
|
+
const attrs = raw.slice(1 + tag.length, raw.length - 1)
|
|
89
|
+
return { index: i, tag, attrs, raw, closeIndex, isClose: false }
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createGuideCompiler(options = {}) {
|
|
95
|
+
const normalized = normalizeOptions(options)
|
|
96
|
+
const collected = []
|
|
97
|
+
return {
|
|
98
|
+
transformVue2(code, id) {
|
|
99
|
+
return transformVueSfc(code, id, { ...normalized, framework: 'vue2' }, collected)
|
|
100
|
+
},
|
|
101
|
+
transformVue3(code, id) {
|
|
102
|
+
return transformVueSfc(code, id, { ...normalized, framework: 'vue3' }, collected)
|
|
103
|
+
},
|
|
104
|
+
transformAngularTemplate(code, id) {
|
|
105
|
+
return transformTemplate(code, id, { ...normalized, framework: 'angular' }, collected)
|
|
106
|
+
},
|
|
107
|
+
transformAngularComponentTs(code, id) {
|
|
108
|
+
return transformInlineTemplates(code, id, { ...normalized, framework: 'angular' }, collected)
|
|
109
|
+
},
|
|
110
|
+
getManifest(extra = {}) {
|
|
111
|
+
return buildManifest(collected, normalized, extra)
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function normalizeOptions(options = {}) {
|
|
117
|
+
return {
|
|
118
|
+
enabled: options.enabled !== false,
|
|
119
|
+
appKey: sanitizeSegment(options.appKey || 'app') || 'app',
|
|
120
|
+
idPrefix: sanitizeSegment(options.idPrefix || options.appKey || 'app') || 'app',
|
|
121
|
+
routePrefix: sanitizeSegment(options.routePrefix || '') || '',
|
|
122
|
+
menuResolver: options.menuResolver || null,
|
|
123
|
+
rootDir: options.rootDir || process.cwd(),
|
|
124
|
+
include: options.include || null,
|
|
125
|
+
exclude: options.exclude || null,
|
|
126
|
+
sensitiveWords: [...DEFAULT_SENSITIVE_WORDS, ...(options.sensitiveWords || [])],
|
|
127
|
+
productionDuplicatePolicy: options.productionDuplicatePolicy || 'block_manual',
|
|
128
|
+
manifestOutput: options.manifestOutput || '',
|
|
129
|
+
lowConfidenceThreshold: Number(options.lowConfidenceThreshold || 60),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function transformVueSfc(code, id, options = {}, sink = []) {
|
|
134
|
+
if (!options.enabled) return { code, manifest: buildManifest(sink, options) }
|
|
135
|
+
return replaceTemplateBlocks(code, (template, startOffset) => (
|
|
136
|
+
transformTemplate(template, id, options, sink, startOffset).code
|
|
137
|
+
))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function transformTemplate(code, id, options = {}, sink = [], baseOffset = 0) {
|
|
141
|
+
const opts = normalizeOptions(options)
|
|
142
|
+
if (!opts.enabled || !shouldProcess(id, opts)) return { code, manifest: buildManifest(sink, opts) }
|
|
143
|
+
|
|
144
|
+
const local = []
|
|
145
|
+
const replacements = []
|
|
146
|
+
const stack = []
|
|
147
|
+
let pos = 0
|
|
148
|
+
let tagInfo = findNextTemplateTag(code, pos)
|
|
149
|
+
while (tagInfo) {
|
|
150
|
+
const { index, tag, attrs, raw, closeIndex, isClose } = tagInfo
|
|
151
|
+
const lowerTag = tag.toLowerCase()
|
|
152
|
+
if (isClose) {
|
|
153
|
+
popStack(stack, lowerTag)
|
|
154
|
+
pos = closeIndex + 1
|
|
155
|
+
tagInfo = findNextTemplateTag(code, pos)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
const selfClosing = raw.endsWith('/>') || VOID_TAGS.has(lowerTag)
|
|
159
|
+
|
|
160
|
+
const context = stackContext(stack)
|
|
161
|
+
const classification = classifyTarget(tag, attrs, code, index, context)
|
|
162
|
+
if (classification.target) {
|
|
163
|
+
const manualId = getAttr(attrs, 'data-guide-id') || getAttr(attrs, 'data-guide') || getAttr(attrs, 'data-gui-code')
|
|
164
|
+
const openEnd = code.indexOf('>', index)
|
|
165
|
+
const simpleText = openEnd >= 0 ? extractElementText(code, openEnd + 1, lowerTag) : ''
|
|
166
|
+
const evidence = extractEvidence(tag, attrs, simpleText)
|
|
167
|
+
const source = manualId ? 'manual' : 'auto'
|
|
168
|
+
const item = buildManifestItem({
|
|
169
|
+
id,
|
|
170
|
+
tag,
|
|
171
|
+
attrs,
|
|
172
|
+
offset: baseOffset + index,
|
|
173
|
+
context,
|
|
174
|
+
classification,
|
|
175
|
+
evidence,
|
|
176
|
+
source,
|
|
177
|
+
manualId,
|
|
178
|
+
options: opts,
|
|
179
|
+
})
|
|
180
|
+
local.push(item)
|
|
181
|
+
if (!manualId) {
|
|
182
|
+
const insert = buildDataGuideAttrs(item)
|
|
183
|
+
const closeIndexInRaw = raw.endsWith('/>') ? raw.length - 2 : raw.length - 1
|
|
184
|
+
replacements.push({
|
|
185
|
+
start: index + closeIndexInRaw,
|
|
186
|
+
end: index + closeIndexInRaw,
|
|
187
|
+
text: insert,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!selfClosing) {
|
|
193
|
+
stack.push({
|
|
194
|
+
tag: lowerTag,
|
|
195
|
+
kind: classification.kind || '',
|
|
196
|
+
scope: inferScope(context, attrs, classification.kind),
|
|
197
|
+
text: extractSimpleText(code, closeIndex + 1),
|
|
198
|
+
guideId: getAttr(attrs, 'data-guide-id') || getAttr(attrs, 'data-guide') || getAttr(attrs, 'data-gui-code') || '',
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
pos = closeIndex + 1
|
|
202
|
+
tagInfo = findNextTemplateTag(code, pos)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
resolveDuplicates(local, opts)
|
|
206
|
+
sink.push(...local)
|
|
207
|
+
return { code: applyReplacements(code, replacements, local), manifest: buildManifest(sink, opts) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function transformInlineTemplates(code, id, options = {}, sink = []) {
|
|
211
|
+
const opts = normalizeOptions(options)
|
|
212
|
+
if (!opts.enabled || !shouldProcess(id, { ...opts, include: opts.include || ['.ts'] })) {
|
|
213
|
+
return { code, manifest: buildManifest(sink, opts) }
|
|
214
|
+
}
|
|
215
|
+
const replacements = []
|
|
216
|
+
const re = /template\s*:\s*(`([\s\S]*?)`|"([^"]*?)"|'([^']*?)')/g
|
|
217
|
+
let match
|
|
218
|
+
while ((match = re.exec(code))) {
|
|
219
|
+
const quote = match[1][0]
|
|
220
|
+
const template = match[2] || match[3] || match[4] || ''
|
|
221
|
+
const result = transformTemplate(template, id, { ...opts, include: opts.include || ['.ts'] }, sink, match.index)
|
|
222
|
+
if (result.code === template) continue
|
|
223
|
+
replacements.push({
|
|
224
|
+
start: match.index + match[0].indexOf(match[1]) + 1,
|
|
225
|
+
end: match.index + match[0].indexOf(match[1]) + match[1].length - 1,
|
|
226
|
+
text: escapeInlineTemplate(result.code, quote),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (!replacements.length) return { code, manifest: buildManifest(sink, opts) }
|
|
230
|
+
let out = code
|
|
231
|
+
replacements.slice().reverse().forEach((replacement) => {
|
|
232
|
+
out = out.slice(0, replacement.start) + replacement.text + out.slice(replacement.end)
|
|
233
|
+
})
|
|
234
|
+
return { code: out, manifest: buildManifest(sink, opts) }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function buildManifest(items = [], options = {}, extra = {}) {
|
|
238
|
+
const opts = normalizeOptions(options)
|
|
239
|
+
const manifestItems = dedupeManifestItems(items)
|
|
240
|
+
return {
|
|
241
|
+
schema_version: 1,
|
|
242
|
+
generated_at: extra.generated_at || new Date().toISOString(),
|
|
243
|
+
app_key: extra.app_key || opts.appKey,
|
|
244
|
+
id_prefix: opts.idPrefix,
|
|
245
|
+
items: manifestItems.map((item) => ({ ...item })),
|
|
246
|
+
diagnostics: manifestDiagnostics(manifestItems, opts),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function validateManifestContract(manifest = {}, guide = {}) {
|
|
251
|
+
const items = Array.isArray(manifest.items) ? manifest.items : []
|
|
252
|
+
const idSet = new Set(items.map((item) => item.guide_id).filter(Boolean))
|
|
253
|
+
const manualCounts = countBy(items.filter((item) => item.source === 'manual').map((item) => item.guide_id))
|
|
254
|
+
const used = collectGuideIdsFromGuide(guide)
|
|
255
|
+
const missing = used.filter((id) => !idSet.has(id))
|
|
256
|
+
const duplicateManual = Object.entries(manualCounts)
|
|
257
|
+
.filter(([, count]) => count > 1)
|
|
258
|
+
.map(([guide_id, count]) => ({ guide_id, count }))
|
|
259
|
+
const lowConfidence = items.filter((item) => item.source === 'auto' && Number(item.confidence || 0) < 60)
|
|
260
|
+
const drift = manifest.diagnostics && manifest.diagnostics.auto_id_drift || null
|
|
261
|
+
const status = missing.length || duplicateManual.length ? 'block' : (lowConfidence.length || drift && drift.status === 'warn' ? 'warn' : 'allow')
|
|
262
|
+
return {
|
|
263
|
+
status,
|
|
264
|
+
missing,
|
|
265
|
+
duplicate_manual: duplicateManual,
|
|
266
|
+
low_confidence: lowConfidence.map((item) => ({
|
|
267
|
+
guide_id: item.guide_id,
|
|
268
|
+
confidence: item.confidence,
|
|
269
|
+
file: item.file,
|
|
270
|
+
diagnostics: item.diagnostics,
|
|
271
|
+
})),
|
|
272
|
+
auto_id_drift: drift,
|
|
273
|
+
summary: {
|
|
274
|
+
used: used.length,
|
|
275
|
+
manifest_items: items.length,
|
|
276
|
+
missing: missing.length,
|
|
277
|
+
duplicate_manual: duplicateManual.length,
|
|
278
|
+
low_confidence: lowConfidence.length,
|
|
279
|
+
auto_id_drift: drift && drift.status === 'warn' ? 1 : 0,
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function compareManifestDrift(previous = {}, current = {}, options = {}) {
|
|
285
|
+
const threshold = Number(options.autoIdDriftWarnRate || 0.2)
|
|
286
|
+
const minChanged = Number(options.autoIdDriftWarnMinChanged || 5)
|
|
287
|
+
const previousItems = Array.isArray(previous.items) ? previous.items : []
|
|
288
|
+
const currentItems = Array.isArray(current.items) ? current.items : []
|
|
289
|
+
const previousAuto = previousItems.filter((item) => item && item.source === 'auto' && item.guide_id)
|
|
290
|
+
const currentAuto = currentItems.filter((item) => item && item.source === 'auto' && item.guide_id)
|
|
291
|
+
const previousByStableKey = new Map(previousAuto.map((item) => [manifestStableKey(item), item]).filter(([key]) => key))
|
|
292
|
+
const changedStableKeys = new Set()
|
|
293
|
+
const changed = currentAuto
|
|
294
|
+
.map((item) => {
|
|
295
|
+
const stableKey = manifestStableKey(item)
|
|
296
|
+
const previousItem = previousByStableKey.get(stableKey)
|
|
297
|
+
if (!previousItem || previousItem.guide_id === item.guide_id) return null
|
|
298
|
+
changedStableKeys.add(stableKey)
|
|
299
|
+
return {
|
|
300
|
+
stable_key: stableKey,
|
|
301
|
+
previous_guide_id: previousItem.guide_id,
|
|
302
|
+
current_guide_id: item.guide_id,
|
|
303
|
+
file: item.file || previousItem.file || '',
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
const previousIds = new Set(previousAuto.map((item) => item.guide_id))
|
|
308
|
+
const currentIds = new Set(currentAuto.map((item) => item.guide_id))
|
|
309
|
+
const removed = previousAuto
|
|
310
|
+
.filter((item) => !currentIds.has(item.guide_id) && !changedStableKeys.has(manifestStableKey(item)))
|
|
311
|
+
.map((item) => summarizeDriftItem(item))
|
|
312
|
+
const added = currentAuto
|
|
313
|
+
.filter((item) => !previousIds.has(item.guide_id) && !changedStableKeys.has(manifestStableKey(item)))
|
|
314
|
+
.map((item) => summarizeDriftItem(item))
|
|
315
|
+
const changedCount = removed.length + added.length + changed.length
|
|
316
|
+
const base = Math.max(previousAuto.length, currentAuto.length, 1)
|
|
317
|
+
const changedRate = changedCount / base
|
|
318
|
+
const shouldWarn = changedCount >= minChanged || changedRate > threshold
|
|
319
|
+
return {
|
|
320
|
+
auto_id_drift: {
|
|
321
|
+
status: shouldWarn ? 'warn' : 'allow',
|
|
322
|
+
previous_auto_count: previousAuto.length,
|
|
323
|
+
current_auto_count: currentAuto.length,
|
|
324
|
+
added_count: added.length,
|
|
325
|
+
removed_count: removed.length,
|
|
326
|
+
changed_count: changed.length,
|
|
327
|
+
changed_rate: Number(changedRate.toFixed(4)),
|
|
328
|
+
added: added.slice(0, 20),
|
|
329
|
+
removed: removed.slice(0, 20),
|
|
330
|
+
changed: changed.slice(0, 20),
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function replaceTemplateBlocks(code, replacer) {
|
|
336
|
+
const re = /<template([^>]*)>([\s\S]*?)<\/template>/gi
|
|
337
|
+
let out = ''
|
|
338
|
+
let last = 0
|
|
339
|
+
let changed = false
|
|
340
|
+
let match
|
|
341
|
+
while ((match = re.exec(code))) {
|
|
342
|
+
const start = match.index + match[0].indexOf(match[2])
|
|
343
|
+
out += code.slice(last, start)
|
|
344
|
+
out += replacer(match[2], start)
|
|
345
|
+
last = start + match[2].length
|
|
346
|
+
changed = true
|
|
347
|
+
}
|
|
348
|
+
if (!changed) return { code }
|
|
349
|
+
out += code.slice(last)
|
|
350
|
+
return { code: out }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function shouldProcess(id, options) {
|
|
354
|
+
const normalized = id.replace(/\\/g, '/')
|
|
355
|
+
if (options.exclude && toMatcher(options.exclude)(normalized)) return false
|
|
356
|
+
if (options.include) return toMatcher(options.include)(normalized)
|
|
357
|
+
return /\.(vue|html|component\.html)$/i.test(normalized)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function toMatcher(value) {
|
|
361
|
+
const list = Array.isArray(value) ? value : [value]
|
|
362
|
+
return (input) => list.some((item) => {
|
|
363
|
+
if (!item) return false
|
|
364
|
+
if (item instanceof RegExp) return item.test(input)
|
|
365
|
+
return input.includes(String(item))
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function escapeInlineTemplate(value, quote) {
|
|
370
|
+
if (quote === '`') return String(value).replace(/`/g, '\\`')
|
|
371
|
+
if (quote === '"') return String(value).replace(/"/g, '\\"')
|
|
372
|
+
return String(value).replace(/'/g, "\\'")
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const CLOSE_BUTTON_CLASS_RE = /(?:^|\s)(el-dialog__headerbtn|vxe-modal--close-btn|ant-modal-close|ant-drawer-close|mat-mdc-dialog-close|mat-dialog-close)(?:\s|$)/i
|
|
376
|
+
|
|
377
|
+
function isCloseButtonClass(className = '') {
|
|
378
|
+
return CLOSE_BUTTON_CLASS_RE.test(className) || /headerbtn|modal--close|icon-close/i.test(className)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function structuralAttrText(attrs = '') {
|
|
382
|
+
return String(attrs || '')
|
|
383
|
+
.replace(/(?:@|v-on:|v-bind:|:)(?:click|tap)[^=]*=\s*(?:"[^"]*"|'[^']*')/gi, ' ')
|
|
384
|
+
.replace(/\bon[a-z]+\s*=\s*(?:"[^"]*"|'[^']*')/gi, ' ')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isToolbarActionClass(className = '') {
|
|
388
|
+
return /btn[_-]|btn-green|btn_green|el-button|ant-btn/i.test(className)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function extractElementText(code, startOffset, tagName) {
|
|
392
|
+
const closeToken = `</${tagName}`
|
|
393
|
+
const closeIndex = code.toLowerCase().indexOf(closeToken.toLowerCase(), startOffset)
|
|
394
|
+
const slice = code.slice(startOffset, closeIndex >= 0 ? closeIndex : Math.min(code.length, startOffset + 240))
|
|
395
|
+
const langLiteral = slice.match(/\$lang\(\s*['"]([^'"]+)['"]\s*\)/)
|
|
396
|
+
if (langLiteral) return cleanText(langLiteral[1])
|
|
397
|
+
return cleanText(slice.replace(/<[^>]+>/g, ' '))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function classifyTarget(tag, attrs, code, offset, context) {
|
|
401
|
+
const lowerTag = tag.toLowerCase()
|
|
402
|
+
const componentName = lowerTag.replace(/^[a-z]+-/, '')
|
|
403
|
+
const attrText = attrs.toLowerCase()
|
|
404
|
+
const structuralAttrs = structuralAttrText(attrs).toLowerCase()
|
|
405
|
+
const eventName = extractEventName(attrs)
|
|
406
|
+
const className = getAttr(attrs, 'class') || ''
|
|
407
|
+
const role = getAttr(attrs, 'role') || ''
|
|
408
|
+
const inRow = context.kinds.includes('table') || /(v-for|\*ngfor|ng-repeat)/i.test(attrs)
|
|
409
|
+
const openEnd = code.indexOf('>', offset)
|
|
410
|
+
const simpleText = openEnd >= 0 ? extractElementText(code, openEnd + 1, lowerTag) : ''
|
|
411
|
+
|
|
412
|
+
if (TARGET_TAGS.has(lowerTag)) {
|
|
413
|
+
const closeButton = isCloseButtonClass(className)
|
|
414
|
+
return {
|
|
415
|
+
target: true,
|
|
416
|
+
kind: kindFromTag(lowerTag, role, className, inRow),
|
|
417
|
+
confidence: closeButton ? 80 : 80,
|
|
418
|
+
overlay_action: closeButton ? 'close' : undefined,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (role && /button|tab|menuitem|dialog|table|row|cell|tabpanel/i.test(role)) {
|
|
422
|
+
return { target: true, kind: kindFromRole(role), confidence: 82 }
|
|
423
|
+
}
|
|
424
|
+
if (eventName && /click|tap|select|change|submit/i.test(attrs) && (isToolbarActionClass(className) || simpleText)) {
|
|
425
|
+
return { target: true, kind: inRow ? 'row_action' : 'button', confidence: 75 }
|
|
426
|
+
}
|
|
427
|
+
if (/toolbar|tool-bar|filter|search|query|form|dialog|drawer|modal|btnlist/i.test(className)) {
|
|
428
|
+
return { target: true, kind: kindFromClass(className), confidence: 66 }
|
|
429
|
+
}
|
|
430
|
+
if (TARGET_COMPONENT_HINTS.some((hint) => componentName.includes(hint) || structuralAttrs.includes(hint))) {
|
|
431
|
+
return { target: true, kind: kindFromComponent(componentName, structuralAttrs, inRow), confidence: 72 }
|
|
432
|
+
}
|
|
433
|
+
if (eventName && /click|tap|select|change|submit/i.test(attrs)) {
|
|
434
|
+
return { target: true, kind: inRow ? 'row_action' : 'button', confidence: 62 }
|
|
435
|
+
}
|
|
436
|
+
if (isCloseButtonClass(className)) {
|
|
437
|
+
const inOverlay = context.scopes.includes('dialog') || context.scopes.includes('drawer') || context.kinds.includes('region')
|
|
438
|
+
return { target: true, kind: 'button', confidence: inOverlay ? 80 : 72, overlay_action: 'close' }
|
|
439
|
+
}
|
|
440
|
+
return { target: false, kind: '', confidence: 0 }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function kindFromTag(tag, role, className, inRow) {
|
|
444
|
+
if (isCloseButtonClass(className)) return 'button'
|
|
445
|
+
if (/dialog|modal/.test(className) || role === 'dialog') return 'region'
|
|
446
|
+
if (/drawer/.test(className)) return 'region'
|
|
447
|
+
if (tag === 'table') return 'table'
|
|
448
|
+
if (tag === 'th') return 'column'
|
|
449
|
+
if (/btnList/.test(className) || inRow && ['button', 'a'].includes(tag)) return 'row_action'
|
|
450
|
+
if (['input', 'textarea', 'select', 'option'].includes(tag)) return 'input'
|
|
451
|
+
if (tag === 'form') return 'region'
|
|
452
|
+
if (role === 'tab') return 'tab'
|
|
453
|
+
return 'button'
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function kindFromRole(role) {
|
|
457
|
+
if (role === 'tab') return 'tab'
|
|
458
|
+
if (role === 'dialog' || role === 'tabpanel') return 'region'
|
|
459
|
+
if (role === 'table') return 'table'
|
|
460
|
+
if (role === 'cell' || role === 'columnheader') return 'column'
|
|
461
|
+
if (role === 'menuitem') return 'menu_item'
|
|
462
|
+
return 'button'
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function kindFromComponent(componentName, attrText, inRow) {
|
|
466
|
+
if (/dialog|modal|drawer|form/.test(componentName) || /dialog|modal|drawer|form/.test(attrText)) return 'region'
|
|
467
|
+
if (/table/.test(componentName)) return 'table'
|
|
468
|
+
if (/column/.test(componentName)) return 'column'
|
|
469
|
+
if (/tab/.test(componentName)) return 'tab'
|
|
470
|
+
if (/menu/.test(componentName)) return 'menu_item'
|
|
471
|
+
if (inRow) return 'row_action'
|
|
472
|
+
if (/input|select|date|picker/.test(componentName)) return 'input'
|
|
473
|
+
return 'button'
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function kindFromClass(className) {
|
|
477
|
+
if (/dialog|drawer|modal|toolbar|filter|search|query|form/i.test(className)) return 'region'
|
|
478
|
+
if (/btnList/.test(className)) return 'row_action'
|
|
479
|
+
return 'button'
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function extractEvidence(tag, attrs, simpleText) {
|
|
483
|
+
const eventName = extractEventName(attrs)
|
|
484
|
+
return {
|
|
485
|
+
text: cleanText(simpleText),
|
|
486
|
+
aria: getAttr(attrs, 'aria-label') || '',
|
|
487
|
+
title: getAttr(attrs, 'title') || '',
|
|
488
|
+
placeholder: getAttr(attrs, 'placeholder') || '',
|
|
489
|
+
name: getAttr(attrs, 'name') || '',
|
|
490
|
+
eventName,
|
|
491
|
+
component: tag,
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function buildManifestItem(input) {
|
|
496
|
+
const { id, tag, attrs, offset, context, classification, evidence, source, manualId, options } = input
|
|
497
|
+
const file = normalizeFile(id, options.rootDir)
|
|
498
|
+
const pageContext = resolvePageContext(file, options)
|
|
499
|
+
const page = pageContext.page
|
|
500
|
+
const scope = inferScope(context, attrs, classification.kind)
|
|
501
|
+
const semantic = semanticSegment(evidence) || classification.kind || tag
|
|
502
|
+
const base = manualId || [options.idPrefix, page, scope, semantic].filter(Boolean).join('.')
|
|
503
|
+
const hash = shortHash(`${file}:${offset}:${tag}:${semantic}`)
|
|
504
|
+
const generatedBase = sanitizeGuideId(base) || options.idPrefix
|
|
505
|
+
const generatedId = source === 'manual' ? manualId : `${generatedBase}__${hash}`
|
|
506
|
+
const guideId = source === 'manual' ? manualId : generatedId
|
|
507
|
+
const diagnostics = []
|
|
508
|
+
const sensitive = findSensitiveParts(guideId, options.sensitiveWords)
|
|
509
|
+
if (sensitive.length) diagnostics.push({ type: 'sensitive_word', values: sensitive })
|
|
510
|
+
if (classification.confidence < options.lowConfidenceThreshold) diagnostics.push({ type: 'low_confidence', confidence: classification.confidence })
|
|
511
|
+
if (source === 'auto' && !semantic) diagnostics.push({ type: 'missing_semantic' })
|
|
512
|
+
return {
|
|
513
|
+
guide_id: guideId,
|
|
514
|
+
source,
|
|
515
|
+
target_kind: classification.kind || 'element',
|
|
516
|
+
scope,
|
|
517
|
+
route: pageContext.route,
|
|
518
|
+
menu: pageContext.menu,
|
|
519
|
+
page,
|
|
520
|
+
file,
|
|
521
|
+
component: tag,
|
|
522
|
+
text: evidence.text || '',
|
|
523
|
+
aria: evidence.aria || '',
|
|
524
|
+
title: evidence.title || '',
|
|
525
|
+
event_name: evidence.eventName || (classification.overlay_action === 'close' ? 'close' : ''),
|
|
526
|
+
row_policy: classification.kind === 'row_action' ? 'first_visible' : '',
|
|
527
|
+
confidence: source === 'manual' ? 100 : classification.confidence,
|
|
528
|
+
hash,
|
|
529
|
+
generated: source === 'auto',
|
|
530
|
+
diagnostics,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function resolvePageContext(file, options) {
|
|
535
|
+
const fallbackPage = pageKeyFromFile(file, options)
|
|
536
|
+
const fallback = {
|
|
537
|
+
route: options.routePrefix || '',
|
|
538
|
+
menu: options.routePrefix || '',
|
|
539
|
+
page: fallbackPage,
|
|
540
|
+
}
|
|
541
|
+
const resolver = options.menuResolver
|
|
542
|
+
if (!resolver) return fallback
|
|
543
|
+
let resolved = null
|
|
544
|
+
if (typeof resolver === 'function') {
|
|
545
|
+
resolved = resolver({ file, appKey: options.appKey, routePrefix: options.routePrefix, fallbackPage }) || null
|
|
546
|
+
} else if (resolver && typeof resolver === 'object') {
|
|
547
|
+
resolved = resolver[file] || resolver[fallbackPage] || null
|
|
548
|
+
}
|
|
549
|
+
if (!resolved || typeof resolved !== 'object') return fallback
|
|
550
|
+
const route = String(resolved.route || resolved.routePath || fallback.route || '')
|
|
551
|
+
const menu = sanitizeSegment(resolved.menu || resolved.menuKey || resolved.menuCode || route || fallback.menu)
|
|
552
|
+
const page = sanitizePageKey(resolved.page || resolved.pageInstance || resolved.pageName || fallbackPage)
|
|
553
|
+
return {
|
|
554
|
+
route,
|
|
555
|
+
menu,
|
|
556
|
+
page,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function resolveDuplicates(items, options) {
|
|
561
|
+
const seen = new Map()
|
|
562
|
+
items.forEach((item) => {
|
|
563
|
+
const previous = seen.get(item.guide_id)
|
|
564
|
+
if (!previous) {
|
|
565
|
+
seen.set(item.guide_id, item)
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
if (previous.source === 'manual' && item.source === 'manual') {
|
|
569
|
+
previous.diagnostics.push({ type: 'duplicate_manual', duplicate_file: item.file })
|
|
570
|
+
item.diagnostics.push({ type: 'duplicate_manual', duplicate_file: previous.file })
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
if (item.source === 'auto') {
|
|
574
|
+
item.guide_id = `${item.guide_id}__${item.hash}`
|
|
575
|
+
item.diagnostics.push({ type: previous.source === 'manual' ? 'manual_auto_conflict' : 'auto_duplicate_suffix' })
|
|
576
|
+
seen.set(item.guide_id, item)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
if (previous.source === 'auto') {
|
|
580
|
+
previous.guide_id = `${previous.guide_id}__${previous.hash}`
|
|
581
|
+
previous.diagnostics.push({ type: 'manual_auto_conflict' })
|
|
582
|
+
seen.set(previous.guide_id, previous)
|
|
583
|
+
seen.set(item.guide_id, item)
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function buildDataGuideAttrs(item) {
|
|
590
|
+
const attrs = [
|
|
591
|
+
` data-guide-id="${escapeAttr(item.guide_id)}"`,
|
|
592
|
+
` data-guide-kind="${escapeAttr(item.target_kind)}"`,
|
|
593
|
+
` data-guide-scope="${escapeAttr(item.scope)}"`,
|
|
594
|
+
' data-guide-generated="true"',
|
|
595
|
+
]
|
|
596
|
+
if (item.row_policy) attrs.push(` data-guide-row-policy="${escapeAttr(item.row_policy)}"`)
|
|
597
|
+
return attrs.join('')
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function applyReplacements(code, replacements, localItems) {
|
|
601
|
+
if (!replacements.length) return code
|
|
602
|
+
let out = code
|
|
603
|
+
const autoItems = localItems.filter((item) => item.source === 'auto')
|
|
604
|
+
replacements.slice().reverse().forEach((replacement, indexFromEnd) => {
|
|
605
|
+
const item = autoItems[autoItems.length - 1 - indexFromEnd]
|
|
606
|
+
const text = item ? buildDataGuideAttrs(item) : replacement.text
|
|
607
|
+
out = out.slice(0, replacement.start) + text + out.slice(replacement.end)
|
|
608
|
+
})
|
|
609
|
+
return out
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function manifestDiagnostics(items, options) {
|
|
613
|
+
const counts = countBy(items.map((item) => item.guide_id))
|
|
614
|
+
const duplicates = Object.entries(counts).filter(([, count]) => count > 1).map(([guide_id, count]) => ({ guide_id, count }))
|
|
615
|
+
const manualDuplicates = duplicates.filter((dup) => items.filter((item) => item.guide_id === dup.guide_id && item.source === 'manual').length > 1)
|
|
616
|
+
const sensitive = items.filter((item) => item.diagnostics.some((diag) => diag.type === 'sensitive_word'))
|
|
617
|
+
const lowConfidence = items.filter((item) => item.diagnostics.some((diag) => diag.type === 'low_confidence'))
|
|
618
|
+
return {
|
|
619
|
+
total: items.length,
|
|
620
|
+
auto: items.filter((item) => item.source === 'auto').length,
|
|
621
|
+
manual: items.filter((item) => item.source === 'manual').length,
|
|
622
|
+
duplicates,
|
|
623
|
+
manual_duplicates: manualDuplicates,
|
|
624
|
+
sensitive: sensitive.map((item) => item.guide_id),
|
|
625
|
+
low_confidence: lowConfidence.map((item) => item.guide_id),
|
|
626
|
+
status: manualDuplicates.length && options.productionDuplicatePolicy === 'block_manual' ? 'block' : (duplicates.length || sensitive.length || lowConfidence.length ? 'warn' : 'allow'),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function dedupeManifestItems(items) {
|
|
631
|
+
const seen = new Set()
|
|
632
|
+
const out = []
|
|
633
|
+
;(items || []).forEach((item) => {
|
|
634
|
+
if (!item || !item.guide_id) return
|
|
635
|
+
const key = [
|
|
636
|
+
item.guide_id,
|
|
637
|
+
item.source || '',
|
|
638
|
+
item.file || '',
|
|
639
|
+
item.component || '',
|
|
640
|
+
item.hash || '',
|
|
641
|
+
item.target_kind || '',
|
|
642
|
+
item.scope || '',
|
|
643
|
+
item.text || '',
|
|
644
|
+
item.aria || '',
|
|
645
|
+
item.title || '',
|
|
646
|
+
item.event_name || '',
|
|
647
|
+
item.row_policy || '',
|
|
648
|
+
].join('|')
|
|
649
|
+
if (seen.has(key)) return
|
|
650
|
+
seen.add(key)
|
|
651
|
+
out.push(item)
|
|
652
|
+
})
|
|
653
|
+
return out
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function manifestStableKey(item) {
|
|
657
|
+
if (!item) return ''
|
|
658
|
+
return [
|
|
659
|
+
item.hash || '',
|
|
660
|
+
item.file || '',
|
|
661
|
+
item.component || '',
|
|
662
|
+
item.scope || '',
|
|
663
|
+
item.target_kind || '',
|
|
664
|
+
item.text || item.aria || item.title || item.event_name || '',
|
|
665
|
+
].join('|')
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function summarizeDriftItem(item) {
|
|
669
|
+
return {
|
|
670
|
+
guide_id: item.guide_id,
|
|
671
|
+
file: item.file || '',
|
|
672
|
+
component: item.component || '',
|
|
673
|
+
scope: item.scope || '',
|
|
674
|
+
text: item.text || item.aria || item.title || item.event_name || '',
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function collectGuideIdsFromGuide(guide) {
|
|
679
|
+
const out = []
|
|
680
|
+
function visitLocator(locator) {
|
|
681
|
+
if (!locator || typeof locator !== 'object') return
|
|
682
|
+
const id = locator.guide_code || locator.guide_id
|
|
683
|
+
if (id) out.push(id)
|
|
684
|
+
if (locator.context) visitContext(locator.context)
|
|
685
|
+
;(locator.context_stack || []).forEach(visitContext)
|
|
686
|
+
;(locator.pre_actions || []).forEach((action) => visitLocator(action && action.target))
|
|
687
|
+
}
|
|
688
|
+
function visitContext(context) {
|
|
689
|
+
if (!context || typeof context !== 'object') return
|
|
690
|
+
visitLocator(context.root_locator || context.rootLocator)
|
|
691
|
+
}
|
|
692
|
+
;(guide.steps || []).forEach(visitLocator)
|
|
693
|
+
return Array.from(new Set(out))
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function inferScope(context, attrs, kind) {
|
|
697
|
+
const cls = getAttr(attrs, 'class') || ''
|
|
698
|
+
if (context.scopes && context.scopes.length) return context.scopes[context.scopes.length - 1]
|
|
699
|
+
if (/toolbar|tool-bar|operation/i.test(cls) || context.kinds.includes('toolbar')) return 'toolbar'
|
|
700
|
+
if (/filter|search|query/i.test(cls) || context.kinds.includes('filter')) return 'filter'
|
|
701
|
+
if (/dialog|modal/i.test(cls) || context.kinds.includes('dialog')) return 'dialog'
|
|
702
|
+
if (/drawer/i.test(cls) || context.kinds.includes('drawer')) return 'drawer'
|
|
703
|
+
if (/table|grid/i.test(cls) || context.kinds.includes('table') || kind === 'row_action' || kind === 'column') return 'table'
|
|
704
|
+
if (/form/i.test(cls) || context.kinds.includes('form')) return 'form'
|
|
705
|
+
if (/menu/i.test(cls) || context.kinds.includes('menu')) return 'menu'
|
|
706
|
+
return 'main'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function stackContext(stack) {
|
|
710
|
+
return {
|
|
711
|
+
kinds: stack.map((item) => item.kind).filter(Boolean),
|
|
712
|
+
scopes: stack.map((item) => item.scope).filter(Boolean),
|
|
713
|
+
guideIds: stack.map((item) => item.guideId).filter(Boolean),
|
|
714
|
+
texts: stack.map((item) => item.text).filter(Boolean),
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function popStack(stack, lowerTag) {
|
|
719
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
720
|
+
const item = stack.pop()
|
|
721
|
+
if (item.tag === lowerTag) return
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function getAttr(attrs, name) {
|
|
726
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
727
|
+
const re = new RegExp(`(?:^|\\s)(?:[:@\\w.-]+:)?${escaped}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'=<>` + '`' + `]+))`, 'i')
|
|
728
|
+
const match = attrs.match(re)
|
|
729
|
+
return match ? (match[2] || match[3] || match[4] || '') : ''
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function extractEventName(attrs) {
|
|
733
|
+
const patterns = [
|
|
734
|
+
/(?:@click|v-on:click|:on-click|\(click\)|ng-click|onClick)\s*=\s*"([^"]+)"/i,
|
|
735
|
+
/(?:@click|v-on:click|:on-click|\(click\)|ng-click|onClick)\s*=\s*'([^']+)'/i,
|
|
736
|
+
]
|
|
737
|
+
for (const pattern of patterns) {
|
|
738
|
+
const match = attrs.match(pattern)
|
|
739
|
+
if (match) return normalizeEventName(match[1])
|
|
740
|
+
}
|
|
741
|
+
return ''
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizeEventName(value) {
|
|
745
|
+
const raw = String(value || '').split(/[({\s]/)[0].replace(/^this\./, '')
|
|
746
|
+
const tokens = raw
|
|
747
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
748
|
+
.replace(/[_-]+/g, ' ')
|
|
749
|
+
.split(/\s+/)
|
|
750
|
+
.filter(Boolean)
|
|
751
|
+
const last = tokens[tokens.length - 1] || raw
|
|
752
|
+
return aliasAction(last) || sanitizeSegment(last)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function semanticSegment(evidence) {
|
|
756
|
+
const textCandidates = [
|
|
757
|
+
evidence.aria,
|
|
758
|
+
evidence.title,
|
|
759
|
+
evidence.placeholder,
|
|
760
|
+
evidence.name,
|
|
761
|
+
evidence.text,
|
|
762
|
+
].map((item) => aliasAction(item) || sanitizeSegment(item)).filter(Boolean)
|
|
763
|
+
if (textCandidates.length) return textCandidates[0]
|
|
764
|
+
const eventName = evidence.eventName || ''
|
|
765
|
+
if (eventName && !/(?:Dialog|Modal|Drawer)$/i.test(eventName)) {
|
|
766
|
+
const fromEvent = aliasAction(eventName) || sanitizeSegment(eventName)
|
|
767
|
+
if (fromEvent) return fromEvent
|
|
768
|
+
}
|
|
769
|
+
const fallback = [
|
|
770
|
+
evidence.eventName,
|
|
771
|
+
evidence.component,
|
|
772
|
+
].map((item) => aliasAction(item) || sanitizeSegment(item)).filter(Boolean)
|
|
773
|
+
return fallback[0] || ''
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function aliasAction(value) {
|
|
777
|
+
const normalized = cleanText(value)
|
|
778
|
+
for (const [alias, pattern] of ACTION_ALIASES) {
|
|
779
|
+
if (pattern.test(normalized)) return alias
|
|
780
|
+
}
|
|
781
|
+
return ''
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function extractSimpleText(code, offset) {
|
|
785
|
+
const nextTag = code.indexOf('<', offset)
|
|
786
|
+
const slice = code.slice(offset, nextTag >= 0 ? nextTag : Math.min(code.length, offset + 120))
|
|
787
|
+
return cleanText(slice)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function cleanText(value) {
|
|
791
|
+
return String(value || '').replace(/\{\{[^}]*}}/g, '').replace(/\s+/g, ' ').trim()
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function pageKeyFromFile(file, options) {
|
|
795
|
+
const withoutExt = file.replace(/\.(vue|html|component\.html|ts|js)$/i, '').replace(/\.component$/i, '')
|
|
796
|
+
const trimmed = withoutExt.replace(/^(src\/)?(views|pages|routes|app)\//, '')
|
|
797
|
+
return [options.routePrefix, trimmed].filter(Boolean).map(sanitizePath).filter(Boolean).join('.') || 'page'
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function normalizeFile(file, rootDir) {
|
|
801
|
+
const rel = path.relative(rootDir || process.cwd(), file)
|
|
802
|
+
return (rel && !rel.startsWith('..')) ? rel.replace(/\\/g, '/') : file.replace(/\\/g, '/')
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function sanitizeGuideId(value) {
|
|
806
|
+
return String(value || '')
|
|
807
|
+
.split('.')
|
|
808
|
+
.map(sanitizeSegment)
|
|
809
|
+
.filter(Boolean)
|
|
810
|
+
.join('.')
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function sanitizePath(value) {
|
|
814
|
+
return String(value || '').split(/[\\/]+/).map(sanitizeSegment).filter(Boolean).join('.')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function sanitizePageKey(value) {
|
|
818
|
+
return String(value || '').split(/[\\/.]+/).map(sanitizeSegment).filter(Boolean).join('.')
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function sanitizeSegment(value) {
|
|
822
|
+
const raw = String(value || '').trim()
|
|
823
|
+
if (!raw) return ''
|
|
824
|
+
const ascii = raw
|
|
825
|
+
.normalize('NFKD')
|
|
826
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
827
|
+
.replace(/[^A-Za-z0-9_\-\u4e00-\u9fa5]+/g, '-')
|
|
828
|
+
.replace(/^-+|-+$/g, '')
|
|
829
|
+
if (!ascii) return ''
|
|
830
|
+
return ascii.length > 36 ? ascii.slice(0, 36).replace(/-+$/g, '') : ascii
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function shortHash(value) {
|
|
834
|
+
return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 6)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function findSensitiveParts(value, words) {
|
|
838
|
+
const lower = String(value || '').toLowerCase()
|
|
839
|
+
return (words || []).filter((word) => word && lower.includes(String(word).toLowerCase()))
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function countBy(values) {
|
|
843
|
+
const counts = {}
|
|
844
|
+
values.filter(Boolean).forEach((value) => {
|
|
845
|
+
counts[value] = (counts[value] || 0) + 1
|
|
846
|
+
})
|
|
847
|
+
return counts
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function escapeAttr(value) {
|
|
851
|
+
return String(value || '').replace(/&/g, '&').replace(/"/g, '"')
|
|
852
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createGuideCompiler,
|
|
3
|
+
transformTemplate,
|
|
4
|
+
transformInlineTemplates,
|
|
5
|
+
transformVueSfc,
|
|
6
|
+
buildManifest,
|
|
7
|
+
validateManifestContract,
|
|
8
|
+
compareManifestDrift,
|
|
9
|
+
} from './core.js'
|
|
10
|
+
|
|
11
|
+
export { transformVue2 } from './vue2.js'
|
|
12
|
+
export { transformVue3 } from './vue3.js'
|
|
13
|
+
export { transformAngularTemplate, transformAngularComponentTs } from './angular.js'
|
|
14
|
+
export { systemAssistantGuideVitePlugin } from './vite.js'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const GuideManifestWebpackPlugin = require('./webpack-plugin.cjs')
|
|
3
|
+
|
|
4
|
+
module.exports = function setupGuideCompilerNuxt2(config, options) {
|
|
5
|
+
const opts = Object.assign({}, options || {})
|
|
6
|
+
const loaderPath = path.resolve(__dirname, 'webpack-loader.cjs')
|
|
7
|
+
config.module = config.module || {}
|
|
8
|
+
config.module.rules = config.module.rules || []
|
|
9
|
+
config.module.rules.unshift({
|
|
10
|
+
enforce: 'pre',
|
|
11
|
+
test: /\.vue$/,
|
|
12
|
+
use: [{
|
|
13
|
+
loader: loaderPath,
|
|
14
|
+
options: opts,
|
|
15
|
+
}],
|
|
16
|
+
})
|
|
17
|
+
config.plugins = config.plugins || []
|
|
18
|
+
config.plugins.push(new GuideManifestWebpackPlugin(opts))
|
|
19
|
+
return config
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createGuideCompiler, normalizeOptions } from './core.js'
|
|
4
|
+
|
|
5
|
+
export function systemAssistantGuideVitePlugin(options = {}) {
|
|
6
|
+
const normalized = normalizeOptions(options)
|
|
7
|
+
const compiler = createGuideCompiler(normalized)
|
|
8
|
+
return {
|
|
9
|
+
name: 'system-assistant-guide-compiler',
|
|
10
|
+
enforce: 'pre',
|
|
11
|
+
transform(code, id) {
|
|
12
|
+
if (!normalized.enabled) return null
|
|
13
|
+
if (/\.vue$/i.test(id)) {
|
|
14
|
+
const result = compiler.transformVue3(code, id)
|
|
15
|
+
return result.code === code ? null : { code: result.code, map: null }
|
|
16
|
+
}
|
|
17
|
+
if (/\.(html|component\.html)$/i.test(id)) {
|
|
18
|
+
const result = compiler.transformAngularTemplate(code, id)
|
|
19
|
+
return result.code === code ? null : { code: result.code, map: null }
|
|
20
|
+
}
|
|
21
|
+
if (/\.ts$/i.test(id) && /template\s*:/.test(code)) {
|
|
22
|
+
const result = compiler.transformAngularComponentTs(code, id)
|
|
23
|
+
return result.code === code ? null : { code: result.code, map: null }
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
},
|
|
27
|
+
generateBundle() {
|
|
28
|
+
const manifest = compiler.getManifest()
|
|
29
|
+
if (normalized.productionDuplicatePolicy === 'block_manual' && manifest.diagnostics.manual_duplicates.length) {
|
|
30
|
+
const duplicates = manifest.diagnostics.manual_duplicates.map((item) => `${item.guide_id}(${item.count})`).join(', ')
|
|
31
|
+
throw new Error(`[system-assistant-guide-compiler] 手写 data-guide-id 重复,生产构建已阻断:${duplicates}`)
|
|
32
|
+
}
|
|
33
|
+
if (!normalized.manifestOutput) return
|
|
34
|
+
const outputPath = path.resolve(process.cwd(), normalized.manifestOutput)
|
|
35
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
36
|
+
fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2), 'utf8')
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
|
|
3
|
+
const state = {
|
|
4
|
+
files: new Map(),
|
|
5
|
+
options: {},
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getLoaderOptions(ctx) {
|
|
9
|
+
if (ctx && typeof ctx.getOptions === 'function') return ctx.getOptions() || {}
|
|
10
|
+
const query = ctx && ctx.query
|
|
11
|
+
if (!query) return {}
|
|
12
|
+
if (typeof query === 'object') return query
|
|
13
|
+
if (typeof query === 'string') {
|
|
14
|
+
const text = query.replace(/^\?/, '')
|
|
15
|
+
if (!text) return {}
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(text)
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return text.split('&').reduce((acc, pair) => {
|
|
20
|
+
const parts = pair.split('=')
|
|
21
|
+
if (parts[0]) acc[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || '')
|
|
22
|
+
return acc
|
|
23
|
+
}, {})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sourceHash(source) {
|
|
30
|
+
return crypto.createHash('sha1').update(String(source || '')).digest('hex').slice(0, 8)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = function guideCompilerWebpackLoader(source) {
|
|
34
|
+
const callback = this.async()
|
|
35
|
+
const options = getLoaderOptions(this)
|
|
36
|
+
const resourcePath = this.resourcePath || ''
|
|
37
|
+
const resourceQuery = this.resourceQuery || ''
|
|
38
|
+
if (this.cacheable) this.cacheable()
|
|
39
|
+
if (/type=/.test(resourceQuery)) {
|
|
40
|
+
callback(null, source)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
state.options = Object.assign({}, state.options, options)
|
|
44
|
+
|
|
45
|
+
import('./index.js').then((mod) => {
|
|
46
|
+
const sink = []
|
|
47
|
+
const result = /\.vue$/i.test(resourcePath)
|
|
48
|
+
? mod.transformVue2(source, resourcePath, options, sink)
|
|
49
|
+
: mod.transformTemplate(source, resourcePath, options, sink)
|
|
50
|
+
state.files.set(resourcePath, {
|
|
51
|
+
hash: sourceHash(source),
|
|
52
|
+
items: sink,
|
|
53
|
+
})
|
|
54
|
+
callback(null, result.code)
|
|
55
|
+
}, (error) => {
|
|
56
|
+
callback(error)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports.getGuideCompilerState = function getGuideCompilerState() {
|
|
61
|
+
return state
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports.getGuideManifestItems = function getGuideManifestItems() {
|
|
65
|
+
return Array.from(state.files.values()).flatMap((entry) => entry.items || [])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports.resetGuideManifest = function resetGuideManifest() {
|
|
69
|
+
state.files.clear()
|
|
70
|
+
state.options = {}
|
|
71
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const loader = require('./webpack-loader.cjs')
|
|
4
|
+
|
|
5
|
+
class SystemAssistantGuideWebpackPlugin {
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.options = options || {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
apply(compiler) {
|
|
11
|
+
compiler.hooks.emit.tapAsync('SystemAssistantGuideWebpackPlugin', (compilation, done) => {
|
|
12
|
+
import('./index.js').then((mod) => {
|
|
13
|
+
const loaderState = loader.getGuideCompilerState()
|
|
14
|
+
const options = Object.assign({}, loaderState.options || {}, this.options)
|
|
15
|
+
const manifest = mod.buildManifest(loader.getGuideManifestItems(), options)
|
|
16
|
+
const json = JSON.stringify(manifest, null, 2)
|
|
17
|
+
const assetName = options.manifestAsset || 'sa-guide-manifest.json'
|
|
18
|
+
compilation.assets[assetName] = {
|
|
19
|
+
source: () => json,
|
|
20
|
+
size: () => Buffer.byteLength(json),
|
|
21
|
+
}
|
|
22
|
+
if (options.manifestOutput) {
|
|
23
|
+
const outputPath = path.resolve(options.manifestOutput)
|
|
24
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
25
|
+
fs.writeFileSync(outputPath, json, 'utf8')
|
|
26
|
+
}
|
|
27
|
+
done()
|
|
28
|
+
}, done)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = SystemAssistantGuideWebpackPlugin
|
package/nuxt.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { resolve } = require('node:path')
|
|
2
2
|
const { readFileSync } = require('node:fs')
|
|
3
|
-
const setupGuideCompilerNuxt2 = require('
|
|
3
|
+
const setupGuideCompilerNuxt2 = require('./dist/compiler/nuxt2.cjs')
|
|
4
4
|
|
|
5
5
|
const APP_KEY_ENV = 'SA_GUIDE_APP_KEY'
|
|
6
6
|
const GUIDE_COMPILER_WEBPACK_MARKER = Symbol.for('system-assistant.guide-compiler.webpack')
|
|
@@ -67,7 +67,7 @@ function installGuideCompilerWebpack(config, options) {
|
|
|
67
67
|
|
|
68
68
|
async function installGuideCompilerVite(config, options) {
|
|
69
69
|
if (!config || config[GUIDE_COMPILER_VITE_MARKER]) return config
|
|
70
|
-
const { systemAssistantGuideVitePlugin } = await import('
|
|
70
|
+
const { systemAssistantGuideVitePlugin } = await import('./dist/compiler/vite.js')
|
|
71
71
|
config.plugins = config.plugins || []
|
|
72
72
|
if (!config.plugins.some((plugin) => plugin && plugin.name === 'system-assistant-guide-compiler')) {
|
|
73
73
|
config.plugins.push(systemAssistantGuideVitePlugin(options))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hhhpyb/system-assistant-client",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"description": "Nuxt 2 client plugin for System Assistant smart guide SDK",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -33,12 +33,9 @@
|
|
|
33
33
|
"optional": true
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"@hhhpyb/guide-compiler": "0.1.0"
|
|
38
|
-
},
|
|
39
36
|
"scripts": {
|
|
40
37
|
"build": "node scripts/copy-dist.js",
|
|
41
|
-
"test": "node --test test/nuxt.test.cjs",
|
|
38
|
+
"test": "npm run build && node --test test/nuxt.test.cjs",
|
|
42
39
|
"prepublishOnly": "npm run build"
|
|
43
40
|
}
|
|
44
41
|
}
|