prd-to-flutter 0.1.0
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 +149 -0
- package/bin/p2f.mjs +18 -0
- package/dist/cli.js +203 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/clean.js +35 -0
- package/dist/commands/clean.js.map +1 -0
- package/dist/commands/doctor.js +148 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/generate.js +120 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.js +46 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/paths.js +58 -0
- package/dist/commands/paths.js.map +1 -0
- package/dist/commands/remove.js +23 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/screenshots-audit.js +39 -0
- package/dist/commands/screenshots-audit.js.map +1 -0
- package/dist/commands/skills-install.js +21 -0
- package/dist/commands/skills-install.js.map +1 -0
- package/dist/commands/sync.js +84 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.js +93 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/core/existing-page-detector.js +463 -0
- package/dist/core/existing-page-detector.js.map +1 -0
- package/dist/core/fail-fast.js +50 -0
- package/dist/core/fail-fast.js.map +1 -0
- package/dist/core/feature-coverage-builder.js +667 -0
- package/dist/core/feature-coverage-builder.js.map +1 -0
- package/dist/core/flutter-project-scanner.js +393 -0
- package/dist/core/flutter-project-scanner.js.map +1 -0
- package/dist/core/implementation-plan-writer.js +190 -0
- package/dist/core/implementation-plan-writer.js.map +1 -0
- package/dist/core/logger.js +39 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/package-info.js +33 -0
- package/dist/core/package-info.js.map +1 -0
- package/dist/core/paths.js +37 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/playwright-capture.js +539 -0
- package/dist/core/playwright-capture.js.map +1 -0
- package/dist/core/playwright-cli.js +26 -0
- package/dist/core/playwright-cli.js.map +1 -0
- package/dist/core/playwright-install.js +40 -0
- package/dist/core/playwright-install.js.map +1 -0
- package/dist/core/prd-clone.js +131 -0
- package/dist/core/prd-clone.js.map +1 -0
- package/dist/core/prd-install.js +108 -0
- package/dist/core/prd-install.js.map +1 -0
- package/dist/core/profile.js +149 -0
- package/dist/core/profile.js.map +1 -0
- package/dist/core/project-doc-reader.js +252 -0
- package/dist/core/project-doc-reader.js.map +1 -0
- package/dist/core/report-writer.js +90 -0
- package/dist/core/report-writer.js.map +1 -0
- package/dist/core/route-name.js +60 -0
- package/dist/core/route-name.js.map +1 -0
- package/dist/core/run-context.js +160 -0
- package/dist/core/run-context.js.map +1 -0
- package/dist/core/screenshot-auditor.js +405 -0
- package/dist/core/screenshot-auditor.js.map +1 -0
- package/dist/core/screenshot-exploration-plan-writer.js +200 -0
- package/dist/core/screenshot-exploration-plan-writer.js.map +1 -0
- package/dist/core/semantic-model-builder.js +922 -0
- package/dist/core/semantic-model-builder.js.map +1 -0
- package/dist/core/skill-install.js +78 -0
- package/dist/core/skill-install.js.map +1 -0
- package/dist/core/stage-stub.js +24 -0
- package/dist/core/stage-stub.js.map +1 -0
- package/dist/core/task-index-writer.js +149 -0
- package/dist/core/task-index-writer.js.map +1 -0
- package/dist/core/update-checker.js +155 -0
- package/dist/core/update-checker.js.map +1 -0
- package/dist/core/vue-page-locator.js +748 -0
- package/dist/core/vue-page-locator.js.map +1 -0
- package/dist/core/vue-project-reader.js +116 -0
- package/dist/core/vue-project-reader.js.map +1 -0
- package/docs/artifacts-and-agent.md +203 -0
- package/docs/development.md +118 -0
- package/docs/usage.md +246 -0
- package/package.json +50 -0
- package/skills/p2f/SKILL.md +303 -0
- package/skills/p2f/references/page-layout-patterns.md +120 -0
- package/skills/p2f/references/youfi-flutter-guidelines.md +71 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve, relative, isAbsolute, basename, extname } from 'node:path';
|
|
3
|
+
import { failFast } from './fail-fast.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
import { markComplete } from './run-context.js';
|
|
6
|
+
import { resolveProfilePath } from './profile.js';
|
|
7
|
+
/** Max files collected by the dependency DFS (safety bound, not a hard product spec). */
|
|
8
|
+
const MAX_RELATED = 60;
|
|
9
|
+
/** Max import-graph depth explored. */
|
|
10
|
+
const MAX_DEPTH = 3;
|
|
11
|
+
export function locateVuePage(ctx, vueDocs, prototypeUrl, profile) {
|
|
12
|
+
if (!vueDocs.viewsDir) {
|
|
13
|
+
failFast({
|
|
14
|
+
stage: 'vue-page-locate',
|
|
15
|
+
action: 'locate views/ directory',
|
|
16
|
+
reason: 'Vue 原型项目没有识别到 views 目录。',
|
|
17
|
+
completedSteps: [...ctx.completedSteps],
|
|
18
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
19
|
+
suggestions: [
|
|
20
|
+
'确认原型项目是否仍使用 profile.prototype.viewsDirs 中的目录存放页面。',
|
|
21
|
+
'若目录结构变更,在 p2f.config.json 中配置 prototype.viewsDirs。',
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const pathname = extractPathname(prototypeUrl);
|
|
26
|
+
if (!pathname) {
|
|
27
|
+
failFast({
|
|
28
|
+
stage: 'vue-page-locate',
|
|
29
|
+
action: `parse prototype URL ${prototypeUrl}`,
|
|
30
|
+
reason: '无法从 URL 中解析 pathname,请确认格式是否正确。',
|
|
31
|
+
completedSteps: [...ctx.completedSteps],
|
|
32
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
33
|
+
suggestions: [
|
|
34
|
+
'确认 URL 是完整的 http(s) 链接或以 / 开头的 pathname。',
|
|
35
|
+
'如果是 hash 路由(含 #/...),注意把 hash 部分也保留下来。',
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const registry = buildRegistry(vueDocs, profile);
|
|
40
|
+
if (registry.length === 0) {
|
|
41
|
+
failFast({
|
|
42
|
+
stage: 'vue-page-locate',
|
|
43
|
+
action: 'load page registries',
|
|
44
|
+
reason: '未能从 specScreens / prdPageRegistry / designScreens 解析到任何页面条目。',
|
|
45
|
+
completedSteps: [...ctx.completedSteps],
|
|
46
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
47
|
+
suggestions: [
|
|
48
|
+
'确认原型项目的 registry 文件存在,并匹配 profile.prototype.registryFiles。',
|
|
49
|
+
'若 registry 路径变更,在 p2f.config.json 中配置 prototype.registryFiles。',
|
|
50
|
+
'若 registry 语法变更,需要新增 prototype locator adapter。',
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const match = registry.find((entry) => entry.path === pathname);
|
|
55
|
+
if (!match) {
|
|
56
|
+
const suggestions = suggestNearest(registry, pathname, 5);
|
|
57
|
+
failFast({
|
|
58
|
+
stage: 'vue-page-locate',
|
|
59
|
+
action: `match pathname ${pathname} against registry`,
|
|
60
|
+
reason: `原型 registry 中找不到 path = ${pathname}。`,
|
|
61
|
+
completedSteps: [...ctx.completedSteps],
|
|
62
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
63
|
+
suggestions: [
|
|
64
|
+
'确认 URL 对应的是已登记的原型页面。',
|
|
65
|
+
'检查拼写或 hash 路由中 # 后的实际 pathname。',
|
|
66
|
+
...(suggestions.length
|
|
67
|
+
? [`相似候选:${suggestions.map((s) => s.path).join(', ')}`]
|
|
68
|
+
: []),
|
|
69
|
+
],
|
|
70
|
+
needsUserDecision: [
|
|
71
|
+
'若页面在 registry 中确实不存在,请先在原型项目登记后再还原。',
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const entryFile = join(vueDocs.viewsDir, match.kind, match.view);
|
|
76
|
+
if (!existsSync(entryFile)) {
|
|
77
|
+
failFast({
|
|
78
|
+
stage: 'vue-page-locate',
|
|
79
|
+
action: `resolve Vue entry ${entryFile}`,
|
|
80
|
+
reason: `registry 命中 ${match.path} 对应的 Vue 文件不存在:${entryFile}`,
|
|
81
|
+
completedSteps: [...ctx.completedSteps],
|
|
82
|
+
pendingSteps: [...ctx.pendingSteps],
|
|
83
|
+
suggestions: [
|
|
84
|
+
'可能 registry 与 views 目录不同步,等原型项目修复后重试。',
|
|
85
|
+
'手动确认文件是否被移动或重命名。',
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const relatedFiles = collectRelatedFiles(entryFile, vueDocs.root);
|
|
90
|
+
const sourceDir = join(ctx.pageDir, 'source');
|
|
91
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
92
|
+
const entryRelative = relative(vueDocs.root, entryFile);
|
|
93
|
+
const explicitNotes = extractNotesRefs(readFileSync(entryFile, 'utf8'));
|
|
94
|
+
const notesRefs = dedupeStrings([
|
|
95
|
+
...normalizeNotesRefs(match.notes),
|
|
96
|
+
...explicitNotes,
|
|
97
|
+
]);
|
|
98
|
+
const productDocs = copyProductDocs({
|
|
99
|
+
root: vueDocs.root,
|
|
100
|
+
sourceDir,
|
|
101
|
+
entryFile,
|
|
102
|
+
relatedFiles,
|
|
103
|
+
match,
|
|
104
|
+
notesRefs,
|
|
105
|
+
profile,
|
|
106
|
+
});
|
|
107
|
+
const i18nFiles = copyPrototypeI18nFiles({
|
|
108
|
+
root: vueDocs.root,
|
|
109
|
+
sourceDir,
|
|
110
|
+
screenId: match.screenId,
|
|
111
|
+
profile,
|
|
112
|
+
});
|
|
113
|
+
writeFileSync(join(sourceDir, 'vue-entry.txt'), entryRelative + '\n', 'utf8');
|
|
114
|
+
writeFileSync(join(sourceDir, 'related-files.txt'), relatedFiles.map((p) => relative(vueDocs.root, p)).join('\n') + '\n', 'utf8');
|
|
115
|
+
const routeInfo = {
|
|
116
|
+
path: match.path,
|
|
117
|
+
name: match.name,
|
|
118
|
+
label: match.label,
|
|
119
|
+
module: match.module,
|
|
120
|
+
kind: match.kind,
|
|
121
|
+
view: match.view,
|
|
122
|
+
screenId: match.screenId,
|
|
123
|
+
key: match.key,
|
|
124
|
+
notes: notesRefs.length <= 1 ? notesRefs[0] : notesRefs,
|
|
125
|
+
};
|
|
126
|
+
writeFileSync(join(sourceDir, 'route-info.json'), JSON.stringify(routeInfo, null, 2) + '\n', 'utf8');
|
|
127
|
+
logger.success(`Vue 页面命中 → ${match.path} · ${entryRelative} · related=${relatedFiles.length}`);
|
|
128
|
+
markComplete(ctx, 'locate-vue-page');
|
|
129
|
+
return {
|
|
130
|
+
entryFile,
|
|
131
|
+
entryRelative,
|
|
132
|
+
relatedFiles,
|
|
133
|
+
routeInfo,
|
|
134
|
+
productDocs,
|
|
135
|
+
i18nFiles,
|
|
136
|
+
sourceDir,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// URL handling
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
function extractPathname(raw) {
|
|
143
|
+
const trimmed = raw.trim();
|
|
144
|
+
if (!trimmed)
|
|
145
|
+
return null;
|
|
146
|
+
// Support leading fragment-only inputs: "/prototype/trade" or "#/prototype/trade".
|
|
147
|
+
if (trimmed.startsWith('#/'))
|
|
148
|
+
return trimmed.slice(1);
|
|
149
|
+
if (trimmed.startsWith('/'))
|
|
150
|
+
return trimmed;
|
|
151
|
+
try {
|
|
152
|
+
const u = new URL(trimmed);
|
|
153
|
+
if (u.hash.startsWith('#/'))
|
|
154
|
+
return u.hash.slice(1);
|
|
155
|
+
return u.pathname || '/';
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function buildRegistry(vueDocs, profile) {
|
|
162
|
+
const entries = [];
|
|
163
|
+
for (const registry of profile.prototype.registryFiles) {
|
|
164
|
+
entries.push(...parseRegistry(resolveProfilePath(vueDocs.root, registry.path), registry.kind));
|
|
165
|
+
}
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Parse registry files by walking through `modules[]` / `items[]` object literals
|
|
170
|
+
* with a lightweight regex. We deliberately avoid evaluating JS because these
|
|
171
|
+
* files import Vite/Vue-only helpers.
|
|
172
|
+
*
|
|
173
|
+
* Strategy:
|
|
174
|
+
* - Chunk file by `module: '...'` boundaries to carry module name along.
|
|
175
|
+
* - Inside each module chunk, look for object literals that contain BOTH
|
|
176
|
+
* a `path:` field and a `view:` field; pull their values plus any
|
|
177
|
+
* accompanying string fields we care about.
|
|
178
|
+
*/
|
|
179
|
+
function parseRegistry(file, kind) {
|
|
180
|
+
if (!existsSync(file))
|
|
181
|
+
return [];
|
|
182
|
+
const src = readFileSync(file, 'utf8');
|
|
183
|
+
const chunks = splitByModuleLabel(src);
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const chunk of chunks) {
|
|
186
|
+
for (const entry of extractItemsFromChunk(chunk.body)) {
|
|
187
|
+
out.push({ ...entry, kind, module: chunk.moduleName });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
function splitByModuleLabel(src) {
|
|
193
|
+
const re = /module:\s*(['"])(.*?)\1/g;
|
|
194
|
+
const positions = [];
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = re.exec(src)) !== null) {
|
|
197
|
+
positions.push({ index: m.index, name: m[2] });
|
|
198
|
+
}
|
|
199
|
+
if (positions.length === 0) {
|
|
200
|
+
return [{ moduleName: undefined, body: src }];
|
|
201
|
+
}
|
|
202
|
+
const chunks = [];
|
|
203
|
+
// Include a leading chunk for anything before the first `module: '...'`.
|
|
204
|
+
if (positions[0].index > 0) {
|
|
205
|
+
chunks.push({ moduleName: undefined, body: src.slice(0, positions[0].index) });
|
|
206
|
+
}
|
|
207
|
+
for (let i = 0; i < positions.length; i += 1) {
|
|
208
|
+
const start = positions[i].index;
|
|
209
|
+
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
|
|
210
|
+
chunks.push({ moduleName: positions[i].name, body: src.slice(start, end) });
|
|
211
|
+
}
|
|
212
|
+
return chunks;
|
|
213
|
+
}
|
|
214
|
+
function extractItemsFromChunk(body) {
|
|
215
|
+
// Find every object literal body (delimited by balanced braces) that mentions
|
|
216
|
+
// both a `path:` and `view:` string pair. We approximate balanced-brace
|
|
217
|
+
// scanning since our registries are flat enough.
|
|
218
|
+
const out = [];
|
|
219
|
+
const len = body.length;
|
|
220
|
+
let i = 0;
|
|
221
|
+
while (i < len) {
|
|
222
|
+
if (body[i] === '{') {
|
|
223
|
+
const end = findMatchingBrace(body, i);
|
|
224
|
+
if (end < 0)
|
|
225
|
+
break;
|
|
226
|
+
const objBody = body.slice(i + 1, end);
|
|
227
|
+
const path = pickString(objBody, 'path');
|
|
228
|
+
const view = pickString(objBody, 'view');
|
|
229
|
+
if (path && view) {
|
|
230
|
+
out.push({
|
|
231
|
+
path,
|
|
232
|
+
view,
|
|
233
|
+
name: pickString(objBody, 'name'),
|
|
234
|
+
label: pickString(objBody, 'label'),
|
|
235
|
+
key: pickString(objBody, 'key'),
|
|
236
|
+
screenId: pickString(objBody, 'screenId'),
|
|
237
|
+
notes: pickStringOrStringArray(objBody, 'notes'),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
i = end + 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
i += 1;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
function findMatchingBrace(src, open) {
|
|
248
|
+
let depth = 0;
|
|
249
|
+
let inStr = null;
|
|
250
|
+
let escape = false;
|
|
251
|
+
for (let i = open; i < src.length; i += 1) {
|
|
252
|
+
const c = src[i];
|
|
253
|
+
if (inStr) {
|
|
254
|
+
if (escape)
|
|
255
|
+
escape = false;
|
|
256
|
+
else if (c === '\\')
|
|
257
|
+
escape = true;
|
|
258
|
+
else if (c === inStr)
|
|
259
|
+
inStr = null;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
263
|
+
inStr = c;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (c === '{')
|
|
267
|
+
depth += 1;
|
|
268
|
+
else if (c === '}') {
|
|
269
|
+
depth -= 1;
|
|
270
|
+
if (depth === 0)
|
|
271
|
+
return i;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return -1;
|
|
275
|
+
}
|
|
276
|
+
function pickString(body, field) {
|
|
277
|
+
const re = new RegExp(`(?:^|[,{\\s])${field}\\s*:\\s*(['"])(.*?)\\1`);
|
|
278
|
+
const m = re.exec(body);
|
|
279
|
+
return m ? m[2] : undefined;
|
|
280
|
+
}
|
|
281
|
+
function pickStringOrStringArray(body, field) {
|
|
282
|
+
const single = pickString(body, field);
|
|
283
|
+
if (single)
|
|
284
|
+
return single;
|
|
285
|
+
const arrayRe = new RegExp(`(?:^|[,{\\s])${field}\\s*:\\s*\\[([\\s\\S]*?)\\]`);
|
|
286
|
+
const m = arrayRe.exec(body);
|
|
287
|
+
if (!m)
|
|
288
|
+
return undefined;
|
|
289
|
+
const values = extractStringLiterals(m[1] ?? '');
|
|
290
|
+
return values.length ? values : undefined;
|
|
291
|
+
}
|
|
292
|
+
function copyProductDocs(input) {
|
|
293
|
+
rmSync(join(input.sourceDir, 'product-docs'), { recursive: true, force: true });
|
|
294
|
+
const missing = [];
|
|
295
|
+
const docs = findProductDocs(input, missing);
|
|
296
|
+
writeProductDocsIndex(input.root, input.sourceDir, docs, input.notesRefs, missing);
|
|
297
|
+
return docs;
|
|
298
|
+
}
|
|
299
|
+
function findProductDocs(input, missing) {
|
|
300
|
+
const candidates = [];
|
|
301
|
+
for (const ref of input.notesRefs) {
|
|
302
|
+
const found = resolveNoteRef(input.root, ref, input.profile);
|
|
303
|
+
if (found) {
|
|
304
|
+
candidates.push({
|
|
305
|
+
source: found,
|
|
306
|
+
reason: '页面 usePageSetup 或 registry 显式声明 notes',
|
|
307
|
+
notesRef: ref,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
missing.push({
|
|
312
|
+
notesRef: ref,
|
|
313
|
+
reason: '页面显式声明了 notes,但没有找到对应 `.md` 文件',
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const inferred = inferNoteCandidates(input.root, input.match.view, input.profile);
|
|
318
|
+
for (const source of inferred) {
|
|
319
|
+
candidates.push({
|
|
320
|
+
source,
|
|
321
|
+
reason: '按页面 view 文件名自动匹配 notes',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const entryName = basename(input.entryFile, extname(input.entryFile));
|
|
325
|
+
for (const source of findNotesByBasename(input.root, entryName, input.profile)) {
|
|
326
|
+
candidates.push({
|
|
327
|
+
source,
|
|
328
|
+
reason: '按 Vue 组件名匹配 notes 文件',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
for (const vueFile of input.relatedFiles.filter((file) => extname(file).toLowerCase() === '.vue')) {
|
|
332
|
+
if (vueFile === input.entryFile)
|
|
333
|
+
continue;
|
|
334
|
+
const componentName = basename(vueFile, extname(vueFile));
|
|
335
|
+
for (const source of findNotesByBasename(input.root, componentName, input.profile)) {
|
|
336
|
+
candidates.push({
|
|
337
|
+
source,
|
|
338
|
+
reason: '按相关 Vue 组件名匹配 notes 文件',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const unique = dedupeBy(candidates, (item) => normalizeAbsPath(item.source));
|
|
343
|
+
const docs = [];
|
|
344
|
+
for (const item of unique) {
|
|
345
|
+
if (!existsSync(item.source))
|
|
346
|
+
continue;
|
|
347
|
+
const rel = relative(input.root, item.source);
|
|
348
|
+
const copiedTo = join(input.sourceDir, 'product-docs', rel);
|
|
349
|
+
mkdirSync(dirname(copiedTo), { recursive: true });
|
|
350
|
+
copyFileSync(item.source, copiedTo);
|
|
351
|
+
docs.push({
|
|
352
|
+
source: item.source,
|
|
353
|
+
copiedTo,
|
|
354
|
+
relativeSource: rel,
|
|
355
|
+
relativeCopiedTo: relative(input.sourceDir, copiedTo),
|
|
356
|
+
reason: item.reason,
|
|
357
|
+
notesRef: item.notesRef,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return docs;
|
|
361
|
+
}
|
|
362
|
+
function resolveNoteRef(root, ref, profile) {
|
|
363
|
+
const clean = ref.trim().replace(/^\/+/, '').replace(/\.md$/i, '');
|
|
364
|
+
if (!clean || clean.includes('..'))
|
|
365
|
+
return null;
|
|
366
|
+
for (const notesRoot of profile.prototype.notesRoots) {
|
|
367
|
+
const path = join(resolveProfilePath(root, notesRoot), clean + '.md');
|
|
368
|
+
if (existsSync(path))
|
|
369
|
+
return path;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
function inferNoteCandidates(root, view, profile) {
|
|
374
|
+
const withoutExt = view.replace(/\.[^.]+$/, '');
|
|
375
|
+
const candidates = profile.prototype.notesRoots.flatMap((notesRoot) => {
|
|
376
|
+
const base = resolveProfilePath(root, notesRoot);
|
|
377
|
+
return [
|
|
378
|
+
join(base, withoutExt + '.md'),
|
|
379
|
+
join(base, basename(withoutExt) + '.md'),
|
|
380
|
+
];
|
|
381
|
+
});
|
|
382
|
+
return candidates.filter((path) => existsSync(path));
|
|
383
|
+
}
|
|
384
|
+
function findNotesByBasename(root, componentName, profile) {
|
|
385
|
+
const out = [];
|
|
386
|
+
for (const notesRoot of profile.prototype.notesRoots) {
|
|
387
|
+
walkFiles(resolveProfilePath(root, notesRoot), out, {
|
|
388
|
+
maxDepth: 5,
|
|
389
|
+
include: (path) => basename(path).toLowerCase() === `${componentName.toLowerCase()}.md`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
function writeProductDocsIndex(root, sourceDir, docs, notesRefs, missing) {
|
|
395
|
+
const jsonPath = join(sourceDir, 'product-docs-index.json');
|
|
396
|
+
const mdPath = join(sourceDir, 'product-docs-index.md');
|
|
397
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
398
|
+
generatedAt: new Date().toISOString(),
|
|
399
|
+
notesRefs,
|
|
400
|
+
missing,
|
|
401
|
+
files: docs.map((doc) => ({
|
|
402
|
+
source: doc.relativeSource,
|
|
403
|
+
copiedTo: doc.relativeCopiedTo,
|
|
404
|
+
reason: doc.reason,
|
|
405
|
+
...(doc.notesRef ? { notesRef: doc.notesRef } : {}),
|
|
406
|
+
})),
|
|
407
|
+
}, null, 2) + '\n', 'utf8');
|
|
408
|
+
const lines = [];
|
|
409
|
+
lines.push('# 产品文档源码产物');
|
|
410
|
+
lines.push('');
|
|
411
|
+
lines.push('该文件记录本次页面定位阶段从原型项目同步出来的产品文档。');
|
|
412
|
+
lines.push('');
|
|
413
|
+
lines.push(`- 原型根目录:\`${root}\``);
|
|
414
|
+
lines.push(`- 文档数量:${docs.length}`);
|
|
415
|
+
if (notesRefs.length)
|
|
416
|
+
lines.push(`- notes 引用:${notesRefs.map((ref) => `\`${ref}\``).join(', ')}`);
|
|
417
|
+
lines.push('');
|
|
418
|
+
if (missing.length) {
|
|
419
|
+
lines.push('## 未命中的 notes');
|
|
420
|
+
lines.push('');
|
|
421
|
+
for (const item of missing) {
|
|
422
|
+
lines.push(`- \`${item.notesRef}\`:${item.reason}`);
|
|
423
|
+
}
|
|
424
|
+
lines.push('');
|
|
425
|
+
}
|
|
426
|
+
if (!docs.length) {
|
|
427
|
+
lines.push('## 结果');
|
|
428
|
+
lines.push('');
|
|
429
|
+
lines.push('- 未找到当前页面对应的 `.md` 产品文档。');
|
|
430
|
+
lines.push('- 查找规则:优先读取页面显式 `notes`,其次按 `view` 和 Vue 组件名匹配 profile.prototype.notesRoots。');
|
|
431
|
+
lines.push('');
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
lines.push('## 文件');
|
|
435
|
+
lines.push('');
|
|
436
|
+
for (const doc of docs) {
|
|
437
|
+
lines.push(`- \`${doc.relativeCopiedTo}\``);
|
|
438
|
+
lines.push(` - 来源:\`${doc.relativeSource}\``);
|
|
439
|
+
lines.push(` - 命中原因:${doc.reason}${doc.notesRef ? `(notes: \`${doc.notesRef}\`)` : ''}`);
|
|
440
|
+
}
|
|
441
|
+
lines.push('');
|
|
442
|
+
}
|
|
443
|
+
writeFileSync(mdPath, lines.join('\n'), 'utf8');
|
|
444
|
+
}
|
|
445
|
+
function copyPrototypeI18nFiles(input) {
|
|
446
|
+
rmSync(join(input.sourceDir, 'i18n'), { recursive: true, force: true });
|
|
447
|
+
const files = [];
|
|
448
|
+
if (input.screenId) {
|
|
449
|
+
for (const i18nRoot of input.profile.prototype.i18nPrototypeRoots) {
|
|
450
|
+
const source = join(resolveProfilePath(input.root, i18nRoot), `${input.screenId}.json`);
|
|
451
|
+
if (!existsSync(source))
|
|
452
|
+
continue;
|
|
453
|
+
const copiedTo = join(input.sourceDir, 'i18n', `${input.screenId}.json`);
|
|
454
|
+
mkdirSync(dirname(copiedTo), { recursive: true });
|
|
455
|
+
copyFileSync(source, copiedTo);
|
|
456
|
+
const summary = summarizeI18nFile(source);
|
|
457
|
+
files.push({
|
|
458
|
+
source,
|
|
459
|
+
copiedTo,
|
|
460
|
+
relativeSource: relative(input.root, source),
|
|
461
|
+
relativeCopiedTo: relative(input.sourceDir, copiedTo),
|
|
462
|
+
screenId: input.screenId,
|
|
463
|
+
locales: summary.locales,
|
|
464
|
+
keyCount: summary.keys.length,
|
|
465
|
+
});
|
|
466
|
+
writePrototypeI18nMarkdown(input.sourceDir, input.root, files[0], summary);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
writePrototypeI18nIndex(input.sourceDir, input.root, input.screenId, files);
|
|
471
|
+
if (files.length === 0) {
|
|
472
|
+
writePrototypeI18nMarkdown(input.sourceDir, input.root, undefined, emptyI18nSummary());
|
|
473
|
+
}
|
|
474
|
+
return files;
|
|
475
|
+
}
|
|
476
|
+
function summarizeI18nFile(path) {
|
|
477
|
+
try {
|
|
478
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
479
|
+
if (!isRecord(raw))
|
|
480
|
+
return emptyI18nSummary();
|
|
481
|
+
const locales = Object.keys(raw).filter((key) => isRecord(raw[key]));
|
|
482
|
+
const keySet = new Set();
|
|
483
|
+
for (const locale of locales) {
|
|
484
|
+
for (const key of Object.keys(raw[locale]))
|
|
485
|
+
keySet.add(key);
|
|
486
|
+
}
|
|
487
|
+
const keys = [...keySet].sort();
|
|
488
|
+
const rows = keys.slice(0, 120).map((key) => {
|
|
489
|
+
const values = {};
|
|
490
|
+
for (const locale of locales) {
|
|
491
|
+
const value = raw[locale][key];
|
|
492
|
+
values[locale] = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
493
|
+
}
|
|
494
|
+
return { key, values };
|
|
495
|
+
});
|
|
496
|
+
return { locales, keys, rows };
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return emptyI18nSummary();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
function emptyI18nSummary() {
|
|
503
|
+
return { locales: [], keys: [], rows: [] };
|
|
504
|
+
}
|
|
505
|
+
function writePrototypeI18nIndex(sourceDir, root, screenId, files) {
|
|
506
|
+
writeFileSync(join(sourceDir, 'prototype-i18n-index.json'), JSON.stringify({
|
|
507
|
+
generatedAt: new Date().toISOString(),
|
|
508
|
+
screenId,
|
|
509
|
+
root,
|
|
510
|
+
files: files.map((file) => ({
|
|
511
|
+
source: file.relativeSource,
|
|
512
|
+
copiedTo: file.relativeCopiedTo,
|
|
513
|
+
screenId: file.screenId,
|
|
514
|
+
locales: file.locales,
|
|
515
|
+
keyCount: file.keyCount,
|
|
516
|
+
})),
|
|
517
|
+
}, null, 2) + '\n', 'utf8');
|
|
518
|
+
}
|
|
519
|
+
function writePrototypeI18nMarkdown(sourceDir, root, file, summary) {
|
|
520
|
+
const lines = [];
|
|
521
|
+
lines.push('# 多语言源码产物');
|
|
522
|
+
lines.push('');
|
|
523
|
+
lines.push('该文件记录当前原型页面的 prototype i18n 数据,供 agent 实现 Flutter 文案和翻译时参考。');
|
|
524
|
+
lines.push('');
|
|
525
|
+
lines.push(`- 原型根目录:\`${root}\``);
|
|
526
|
+
if (!file) {
|
|
527
|
+
lines.push('- 结果:未找到当前页面对应的多语言 JSON。');
|
|
528
|
+
lines.push('');
|
|
529
|
+
lines.push('查找规则:使用 registry 中的 `screenId` 匹配 profile.prototype.i18nPrototypeRoots。');
|
|
530
|
+
lines.push('');
|
|
531
|
+
writeFileSync(join(sourceDir, 'prototype-i18n.md'), lines.join('\n'), 'utf8');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
lines.push(`- screenId:\`${file.screenId}\``);
|
|
535
|
+
lines.push(`- JSON 副本:\`${file.relativeCopiedTo}\``);
|
|
536
|
+
lines.push(`- 来源:\`${file.relativeSource}\``);
|
|
537
|
+
lines.push(`- locales:${summary.locales.map((locale) => `\`${locale}\``).join(', ') || '(无)'}`);
|
|
538
|
+
lines.push(`- key 数:${summary.keys.length}`);
|
|
539
|
+
lines.push('');
|
|
540
|
+
lines.push('## 文案对照');
|
|
541
|
+
lines.push('');
|
|
542
|
+
if (!summary.rows.length || !summary.locales.length) {
|
|
543
|
+
lines.push('- (无可展示条目,请直接查看 JSON 副本)');
|
|
544
|
+
lines.push('');
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
lines.push(`| key | ${summary.locales.join(' | ')} |`);
|
|
548
|
+
lines.push(`| --- | ${summary.locales.map(() => '---').join(' | ')} |`);
|
|
549
|
+
for (const row of summary.rows) {
|
|
550
|
+
lines.push(`| \`${escapeMarkdownTable(row.key)}\` | ${summary.locales
|
|
551
|
+
.map((locale) => escapeMarkdownTable(row.values[locale] ?? ''))
|
|
552
|
+
.join(' | ')} |`);
|
|
553
|
+
}
|
|
554
|
+
if (summary.keys.length > summary.rows.length) {
|
|
555
|
+
lines.push('');
|
|
556
|
+
lines.push(`仅展示前 ${summary.rows.length} 个 key,其余请查看 \`${file.relativeCopiedTo}\`。`);
|
|
557
|
+
}
|
|
558
|
+
lines.push('');
|
|
559
|
+
}
|
|
560
|
+
writeFileSync(join(sourceDir, 'prototype-i18n.md'), lines.join('\n'), 'utf8');
|
|
561
|
+
}
|
|
562
|
+
function extractNotesRefs(source) {
|
|
563
|
+
const refs = [];
|
|
564
|
+
const singleRe = /notes\s*:\s*(['"])(.*?)\1/g;
|
|
565
|
+
let m;
|
|
566
|
+
while ((m = singleRe.exec(source)) !== null) {
|
|
567
|
+
const value = m[2]?.trim();
|
|
568
|
+
if (value)
|
|
569
|
+
refs.push(value);
|
|
570
|
+
}
|
|
571
|
+
const arrayRe = /notes\s*:\s*\[([\s\S]*?)\]/g;
|
|
572
|
+
while ((m = arrayRe.exec(source)) !== null) {
|
|
573
|
+
refs.push(...extractStringLiterals(m[1] ?? ''));
|
|
574
|
+
}
|
|
575
|
+
return dedupeStrings(refs);
|
|
576
|
+
}
|
|
577
|
+
function normalizeNotesRefs(value) {
|
|
578
|
+
if (!value)
|
|
579
|
+
return [];
|
|
580
|
+
return Array.isArray(value) ? value : [value];
|
|
581
|
+
}
|
|
582
|
+
function extractStringLiterals(source) {
|
|
583
|
+
const out = [];
|
|
584
|
+
const re = /(['"])(.*?)\1/g;
|
|
585
|
+
let m;
|
|
586
|
+
while ((m = re.exec(source)) !== null) {
|
|
587
|
+
const value = m[2]?.trim();
|
|
588
|
+
if (value)
|
|
589
|
+
out.push(value);
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
function walkFiles(dir, out, options, depth = 0) {
|
|
594
|
+
if (depth > options.maxDepth)
|
|
595
|
+
return;
|
|
596
|
+
let entries;
|
|
597
|
+
try {
|
|
598
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const entry of entries) {
|
|
604
|
+
if (entry.name.startsWith('.'))
|
|
605
|
+
continue;
|
|
606
|
+
const path = join(dir, entry.name);
|
|
607
|
+
if (entry.isDirectory()) {
|
|
608
|
+
walkFiles(path, out, options, depth + 1);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (entry.isFile() && options.include(path))
|
|
612
|
+
out.push(path);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function dedupeStrings(values) {
|
|
616
|
+
return dedupeBy(values.map((value) => value.trim()).filter(Boolean), (value) => value);
|
|
617
|
+
}
|
|
618
|
+
function dedupeBy(items, keyOf) {
|
|
619
|
+
const out = [];
|
|
620
|
+
const seen = new Set();
|
|
621
|
+
for (const item of items) {
|
|
622
|
+
const key = keyOf(item);
|
|
623
|
+
if (seen.has(key))
|
|
624
|
+
continue;
|
|
625
|
+
seen.add(key);
|
|
626
|
+
out.push(item);
|
|
627
|
+
}
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
function normalizeAbsPath(path) {
|
|
631
|
+
return resolve(path);
|
|
632
|
+
}
|
|
633
|
+
function isRecord(value) {
|
|
634
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
635
|
+
}
|
|
636
|
+
function escapeMarkdownTable(value) {
|
|
637
|
+
return value.replace(/\r?\n/g, '<br>').replace(/\|/g, '\\|');
|
|
638
|
+
}
|
|
639
|
+
function suggestNearest(registry, target, limit) {
|
|
640
|
+
return registry
|
|
641
|
+
.map((e) => ({ e, score: similarity(e.path, target) }))
|
|
642
|
+
.sort((a, b) => b.score - a.score)
|
|
643
|
+
.slice(0, limit)
|
|
644
|
+
.map((x) => x.e);
|
|
645
|
+
}
|
|
646
|
+
function similarity(a, b) {
|
|
647
|
+
if (a === b)
|
|
648
|
+
return 1;
|
|
649
|
+
const ap = a.split('/').filter(Boolean);
|
|
650
|
+
const bp = b.split('/').filter(Boolean);
|
|
651
|
+
let hit = 0;
|
|
652
|
+
for (let i = 0; i < Math.min(ap.length, bp.length); i += 1) {
|
|
653
|
+
if (ap[i] === bp[i])
|
|
654
|
+
hit += 1;
|
|
655
|
+
else
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
return hit / Math.max(ap.length, bp.length);
|
|
659
|
+
}
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
// Related-file DFS
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
function collectRelatedFiles(entry, projectRoot) {
|
|
664
|
+
const collected = new Map();
|
|
665
|
+
collected.set(entry, 0);
|
|
666
|
+
const queue = [{ file: entry, depth: 0 }];
|
|
667
|
+
while (queue.length > 0 && collected.size < MAX_RELATED) {
|
|
668
|
+
const { file, depth } = queue.shift();
|
|
669
|
+
if (depth >= MAX_DEPTH)
|
|
670
|
+
continue;
|
|
671
|
+
let body;
|
|
672
|
+
try {
|
|
673
|
+
body = readFileSync(file, 'utf8');
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
for (const target of extractImportTargets(body)) {
|
|
679
|
+
const resolved = resolveImport(target, file, projectRoot);
|
|
680
|
+
if (!resolved)
|
|
681
|
+
continue;
|
|
682
|
+
if (collected.has(resolved))
|
|
683
|
+
continue;
|
|
684
|
+
collected.set(resolved, depth + 1);
|
|
685
|
+
queue.push({ file: resolved, depth: depth + 1 });
|
|
686
|
+
if (collected.size >= MAX_RELATED)
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return [...collected.keys()];
|
|
691
|
+
}
|
|
692
|
+
function extractImportTargets(src) {
|
|
693
|
+
const out = new Set();
|
|
694
|
+
// `import ... from 'target'`
|
|
695
|
+
const reImportFrom = /import\s+(?:[^;'"`]+?\s+from\s+)?(['"])(.*?)\1/g;
|
|
696
|
+
// dynamic `import('target')`
|
|
697
|
+
const reDynamic = /import\s*\(\s*(['"])(.*?)\1\s*\)/g;
|
|
698
|
+
let m;
|
|
699
|
+
while ((m = reImportFrom.exec(src)) !== null)
|
|
700
|
+
out.add(m[2]);
|
|
701
|
+
while ((m = reDynamic.exec(src)) !== null)
|
|
702
|
+
out.add(m[2]);
|
|
703
|
+
// Bare `import 'target'` without from
|
|
704
|
+
const reBare = /import\s+(['"])(.*?)\1/g;
|
|
705
|
+
while ((m = reBare.exec(src)) !== null)
|
|
706
|
+
out.add(m[2]);
|
|
707
|
+
return [...out];
|
|
708
|
+
}
|
|
709
|
+
function resolveImport(target, fromFile, projectRoot) {
|
|
710
|
+
// Only follow relative imports; package imports are out of scope for DFS.
|
|
711
|
+
if (!target.startsWith('.') && !target.startsWith('/'))
|
|
712
|
+
return null;
|
|
713
|
+
const base = isAbsolute(target) ? join(projectRoot, target.slice(1)) : resolve(dirname(fromFile), target);
|
|
714
|
+
// Must stay inside the project root.
|
|
715
|
+
const rel = relative(projectRoot, base);
|
|
716
|
+
if (rel.startsWith('..'))
|
|
717
|
+
return null;
|
|
718
|
+
// Try candidate paths (direct, with suffix, index).
|
|
719
|
+
const candidates = [
|
|
720
|
+
base,
|
|
721
|
+
base + '.vue',
|
|
722
|
+
base + '.ts',
|
|
723
|
+
base + '.js',
|
|
724
|
+
base + '.mjs',
|
|
725
|
+
base + '.scss',
|
|
726
|
+
base + '.css',
|
|
727
|
+
join(base, 'index.vue'),
|
|
728
|
+
join(base, 'index.ts'),
|
|
729
|
+
join(base, 'index.js'),
|
|
730
|
+
join(base, 'index.mjs'),
|
|
731
|
+
];
|
|
732
|
+
for (const c of candidates) {
|
|
733
|
+
if (existsSync(c) && isFile(c))
|
|
734
|
+
return c;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
function isFile(p) {
|
|
739
|
+
try {
|
|
740
|
+
const entries = readdirSync(dirname(p), { withFileTypes: true });
|
|
741
|
+
const name = p.slice(dirname(p).length + 1);
|
|
742
|
+
return entries.some((e) => e.name === name && e.isFile());
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
//# sourceMappingURL=vue-page-locator.js.map
|