@xenonbyte/da-vinci-workflow 0.1.21 → 0.1.22
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/CHANGELOG.md +17 -0
- package/README.md +19 -0
- package/README.zh-CN.md +19 -0
- package/docs/constraint-files.md +109 -0
- package/docs/dv-command-reference.md +17 -0
- package/docs/workflow-examples.md +29 -13
- package/docs/workflow-overview.md +2 -0
- package/docs/zh-CN/constraint-files.md +111 -0
- package/docs/zh-CN/dv-command-reference.md +17 -0
- package/docs/zh-CN/workflow-examples.md +29 -13
- package/docs/zh-CN/workflow-overview.md +2 -0
- package/examples/greenfield-spec-markupflow/DA-VINCI.md +9 -0
- package/examples/greenfield-spec-markupflow/README.md +7 -0
- package/examples/greenfield-spec-markupflow/pencil-design.md +5 -0
- package/lib/cli.js +188 -1
- package/lib/icon-aliases.js +165 -0
- package/lib/icon-search.js +370 -0
- package/lib/icon-sync.js +361 -0
- package/package.json +5 -2
- package/references/artifact-templates.md +24 -0
- package/references/icon-aliases.example.json +12 -0
- package/scripts/fixtures/mock-pencil.js +49 -0
- package/scripts/test-icon-aliases.js +87 -0
- package/scripts/test-icon-search.js +72 -0
- package/scripts/test-icon-sync.js +178 -0
- package/scripts/test-pen-persistence.js +7 -3
package/lib/icon-sync.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const https = require("https");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const {
|
|
6
|
+
MATERIAL_ROUNDED,
|
|
7
|
+
MATERIAL_OUTLINED,
|
|
8
|
+
MATERIAL_SHARP
|
|
9
|
+
} = require("./icon-search");
|
|
10
|
+
|
|
11
|
+
const MATERIAL_METADATA_URL = "https://fonts.google.com/metadata/icons";
|
|
12
|
+
const LUCIDE_TREE_URL = "https://api.github.com/repos/lucide-icons/lucide/git/trees/main?recursive=1";
|
|
13
|
+
const FEATHER_TREE_URL = "https://api.github.com/repos/feathericons/feather/git/trees/main?recursive=1";
|
|
14
|
+
const PHOSPHOR_TREE_URL = "https://api.github.com/repos/phosphor-icons/core/git/trees/main?recursive=1";
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
16
|
+
|
|
17
|
+
function toBoolean(value) {
|
|
18
|
+
if (value === true || value === false) {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const normalized = value.trim().toLowerCase();
|
|
23
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Boolean(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureDir(filePath) {
|
|
34
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeName(name) {
|
|
38
|
+
return String(name || "").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeIconRecord(family, name) {
|
|
42
|
+
const normalized = normalizeName(name);
|
|
43
|
+
if (!family || !normalized) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
family,
|
|
48
|
+
name: normalized,
|
|
49
|
+
semantic: normalized.replace(/[_-]+/g, " "),
|
|
50
|
+
tags: []
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseGoogleMaterialMetadata(raw) {
|
|
55
|
+
const text = String(raw || "");
|
|
56
|
+
const objectStart = text.indexOf("{");
|
|
57
|
+
if (objectStart < 0) {
|
|
58
|
+
throw new Error("Material metadata payload is not valid JSON.");
|
|
59
|
+
}
|
|
60
|
+
const parsed = JSON.parse(text.slice(objectStart));
|
|
61
|
+
const icons = Array.isArray(parsed.icons) ? parsed.icons : [];
|
|
62
|
+
const roundedFamilyKey = "Material Icons Round";
|
|
63
|
+
const outlinedFamilyKey = "Material Icons Outlined";
|
|
64
|
+
const sharpFamilyKey = "Material Icons Sharp";
|
|
65
|
+
|
|
66
|
+
return icons.flatMap((item) => {
|
|
67
|
+
const name = normalizeName(item && item.name);
|
|
68
|
+
if (!name) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const unsupported = new Set(Array.isArray(item.unsupported_families) ? item.unsupported_families : []);
|
|
72
|
+
const records = [];
|
|
73
|
+
|
|
74
|
+
if (!unsupported.has(roundedFamilyKey)) {
|
|
75
|
+
records.push(makeIconRecord(MATERIAL_ROUNDED, name));
|
|
76
|
+
}
|
|
77
|
+
if (!unsupported.has(outlinedFamilyKey)) {
|
|
78
|
+
records.push(makeIconRecord(MATERIAL_OUTLINED, name));
|
|
79
|
+
}
|
|
80
|
+
if (!unsupported.has(sharpFamilyKey)) {
|
|
81
|
+
records.push(makeIconRecord(MATERIAL_SHARP, name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return records.filter(Boolean);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseGitHubTreeIcons(raw, options) {
|
|
89
|
+
const { family, prefix, suffix } = options;
|
|
90
|
+
const parsed = JSON.parse(String(raw || ""));
|
|
91
|
+
const tree = Array.isArray(parsed.tree) ? parsed.tree : [];
|
|
92
|
+
|
|
93
|
+
return tree
|
|
94
|
+
.flatMap((node) => {
|
|
95
|
+
const nodePath = normalizeName(node && node.path);
|
|
96
|
+
if (!nodePath || !nodePath.startsWith(prefix) || !nodePath.endsWith(suffix)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const name = nodePath.slice(prefix.length, nodePath.length - suffix.length);
|
|
100
|
+
if (!name || name.includes("/")) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const record = makeIconRecord(family, name);
|
|
104
|
+
return record ? [record] : [];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dedupeIconRecords(records) {
|
|
109
|
+
const map = new Map();
|
|
110
|
+
for (const record of records) {
|
|
111
|
+
if (!record) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const key = `${record.family}::${record.name}`;
|
|
115
|
+
if (!map.has(key)) {
|
|
116
|
+
map.set(key, record);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(map.values());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function summarizeSourceResults(sourceResults = {}) {
|
|
123
|
+
const entries = Object.values(sourceResults);
|
|
124
|
+
const total = entries.length;
|
|
125
|
+
const errorCount = entries.filter((source) => source && source.status === "error").length;
|
|
126
|
+
const okCount = entries.filter((source) => source && source.status === "ok").length;
|
|
127
|
+
return {
|
|
128
|
+
total,
|
|
129
|
+
okCount,
|
|
130
|
+
errorCount,
|
|
131
|
+
degraded: errorCount > 0
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fetchText(url, options = {}) {
|
|
136
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs))
|
|
137
|
+
? Number(options.timeoutMs)
|
|
138
|
+
: DEFAULT_TIMEOUT_MS;
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const request = https.get(
|
|
142
|
+
url,
|
|
143
|
+
{
|
|
144
|
+
headers: {
|
|
145
|
+
"User-Agent": "da-vinci-workflow/icon-sync",
|
|
146
|
+
Accept: "application/json,text/plain,*/*"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
(response) => {
|
|
150
|
+
let data = "";
|
|
151
|
+
response.setEncoding("utf8");
|
|
152
|
+
response.on("data", (chunk) => {
|
|
153
|
+
data += chunk;
|
|
154
|
+
});
|
|
155
|
+
response.on("end", () => {
|
|
156
|
+
if (response.statusCode && response.statusCode >= 400) {
|
|
157
|
+
reject(new Error(`HTTP ${response.statusCode} for ${url}`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
resolve(data);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
request.on("error", (error) => {
|
|
166
|
+
reject(error);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
request.setTimeout(timeoutMs, () => {
|
|
170
|
+
request.destroy(new Error(`Request timeout after ${timeoutMs}ms for ${url}`));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getDefaultCatalogPath(homeDir) {
|
|
176
|
+
const root = homeDir ? path.resolve(homeDir) : os.homedir();
|
|
177
|
+
return path.join(root, ".da-vinci", "icon-catalog.json");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveCatalogPath(options = {}) {
|
|
181
|
+
if (options.catalogPath) {
|
|
182
|
+
return path.resolve(options.catalogPath);
|
|
183
|
+
}
|
|
184
|
+
if (options.outputPath) {
|
|
185
|
+
return path.resolve(options.outputPath);
|
|
186
|
+
}
|
|
187
|
+
return path.resolve(getDefaultCatalogPath(options.homeDir));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function loadIconCatalog(options = {}) {
|
|
191
|
+
const catalogPath = resolveCatalogPath(options);
|
|
192
|
+
if (!fs.existsSync(catalogPath)) {
|
|
193
|
+
return {
|
|
194
|
+
catalogPath,
|
|
195
|
+
catalog: null
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parsed = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
|
|
200
|
+
if (!parsed || !Array.isArray(parsed.icons)) {
|
|
201
|
+
throw new Error(`Invalid icon catalog format: ${catalogPath}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
catalogPath,
|
|
206
|
+
catalog: parsed
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function syncIconCatalog(options = {}) {
|
|
211
|
+
const catalogPath = resolveCatalogPath(options);
|
|
212
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs))
|
|
213
|
+
? Number(options.timeoutMs)
|
|
214
|
+
: DEFAULT_TIMEOUT_MS;
|
|
215
|
+
const strict = toBoolean(options.strict);
|
|
216
|
+
const fetchTextImpl = typeof options.fetchText === "function" ? options.fetchText : fetchText;
|
|
217
|
+
|
|
218
|
+
const sourceSpecs = [
|
|
219
|
+
{
|
|
220
|
+
key: "material",
|
|
221
|
+
url: MATERIAL_METADATA_URL,
|
|
222
|
+
parse: parseGoogleMaterialMetadata
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
key: "lucide",
|
|
226
|
+
url: LUCIDE_TREE_URL,
|
|
227
|
+
parse: (raw) =>
|
|
228
|
+
parseGitHubTreeIcons(raw, {
|
|
229
|
+
family: "lucide",
|
|
230
|
+
prefix: "icons/",
|
|
231
|
+
suffix: ".json"
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: "feather",
|
|
236
|
+
url: FEATHER_TREE_URL,
|
|
237
|
+
parse: (raw) =>
|
|
238
|
+
parseGitHubTreeIcons(raw, {
|
|
239
|
+
family: "feather",
|
|
240
|
+
prefix: "icons/",
|
|
241
|
+
suffix: ".svg"
|
|
242
|
+
})
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
key: "phosphor",
|
|
246
|
+
url: PHOSPHOR_TREE_URL,
|
|
247
|
+
parse: (raw) =>
|
|
248
|
+
parseGitHubTreeIcons(raw, {
|
|
249
|
+
family: "phosphor",
|
|
250
|
+
prefix: "assets/regular/",
|
|
251
|
+
suffix: ".svg"
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
const sourceResults = {};
|
|
257
|
+
const collected = [];
|
|
258
|
+
|
|
259
|
+
await Promise.all(
|
|
260
|
+
sourceSpecs.map(async (spec) => {
|
|
261
|
+
try {
|
|
262
|
+
const raw = await fetchTextImpl(spec.url, {
|
|
263
|
+
timeoutMs
|
|
264
|
+
});
|
|
265
|
+
const records = spec.parse(raw);
|
|
266
|
+
sourceResults[spec.key] = {
|
|
267
|
+
status: "ok",
|
|
268
|
+
url: spec.url,
|
|
269
|
+
count: records.length
|
|
270
|
+
};
|
|
271
|
+
collected.push(...records);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
sourceResults[spec.key] = {
|
|
274
|
+
status: "error",
|
|
275
|
+
url: spec.url,
|
|
276
|
+
count: 0,
|
|
277
|
+
error: error.message || String(error)
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const icons = dedupeIconRecords(collected);
|
|
284
|
+
const sourceSummary = summarizeSourceResults(sourceResults);
|
|
285
|
+
if (icons.length === 0) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
[
|
|
288
|
+
"icon-sync failed: no icon records were fetched.",
|
|
289
|
+
...Object.entries(sourceResults).map(([key, value]) => `${key}: ${value.status}${value.error ? ` (${value.error})` : ""}`)
|
|
290
|
+
].join("\n")
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (strict && sourceSummary.errorCount > 0) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
[
|
|
296
|
+
"icon-sync strict mode failed: one or more upstream sources could not be fetched.",
|
|
297
|
+
...Object.entries(sourceResults).map(([key, value]) => `${key}: ${value.status}${value.error ? ` (${value.error})` : ""}`)
|
|
298
|
+
].join("\n")
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const catalog = {
|
|
303
|
+
schema: 1,
|
|
304
|
+
generatedAt: new Date().toISOString(),
|
|
305
|
+
sourceResults,
|
|
306
|
+
sourceSummary,
|
|
307
|
+
syncStatus: sourceSummary.degraded ? "degraded" : "ok",
|
|
308
|
+
iconCount: icons.length,
|
|
309
|
+
icons
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
ensureDir(catalogPath);
|
|
313
|
+
fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2));
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
catalogPath,
|
|
317
|
+
catalog
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatIconSyncReport(result) {
|
|
322
|
+
const sourceSummary = result.catalog.sourceSummary || summarizeSourceResults(result.catalog.sourceResults || {});
|
|
323
|
+
const statusLine = sourceSummary.degraded
|
|
324
|
+
? `DEGRADED (${sourceSummary.errorCount}/${sourceSummary.total} source failures)`
|
|
325
|
+
: "OK";
|
|
326
|
+
const lines = [
|
|
327
|
+
"Icon Sync",
|
|
328
|
+
`Catalog path: ${result.catalogPath}`,
|
|
329
|
+
`Generated at: ${result.catalog.generatedAt}`,
|
|
330
|
+
`Status: ${statusLine}`,
|
|
331
|
+
`Icon count: ${result.catalog.iconCount}`,
|
|
332
|
+
"Sources:"
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
for (const [key, source] of Object.entries(result.catalog.sourceResults || {})) {
|
|
336
|
+
const base = `- ${key}: ${source.status} (${source.count})`;
|
|
337
|
+
if (source.error) {
|
|
338
|
+
lines.push(`${base} ${source.error}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
lines.push(base);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return lines.join("\n");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
MATERIAL_METADATA_URL,
|
|
349
|
+
LUCIDE_TREE_URL,
|
|
350
|
+
FEATHER_TREE_URL,
|
|
351
|
+
PHOSPHOR_TREE_URL,
|
|
352
|
+
getDefaultCatalogPath,
|
|
353
|
+
resolveCatalogPath,
|
|
354
|
+
loadIconCatalog,
|
|
355
|
+
syncIconCatalog,
|
|
356
|
+
formatIconSyncReport,
|
|
357
|
+
parseGoogleMaterialMetadata,
|
|
358
|
+
parseGitHubTreeIcons,
|
|
359
|
+
dedupeIconRecords,
|
|
360
|
+
summarizeSourceResults
|
|
361
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenonbyte/da-vinci-workflow",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Requirement-to-design-to-code workflow skill for Codex, Claude, and Gemini",
|
|
5
5
|
"bin": {
|
|
6
6
|
"da-vinci": "bin/da-vinci.js"
|
|
@@ -30,7 +30,10 @@
|
|
|
30
30
|
"test:persistence-flows": "node scripts/test-persistence-flows.js",
|
|
31
31
|
"test:pencil-session": "node scripts/test-pencil-session.js",
|
|
32
32
|
"test:pencil-preflight": "node scripts/test-pencil-preflight.js",
|
|
33
|
-
"test:pen-persistence": "node scripts/test-pen-persistence.js"
|
|
33
|
+
"test:pen-persistence": "node scripts/test-pen-persistence.js",
|
|
34
|
+
"test:icon-search": "node scripts/test-icon-search.js",
|
|
35
|
+
"test:icon-sync": "node scripts/test-icon-sync.js",
|
|
36
|
+
"test:icon-aliases": "node scripts/test-icon-aliases.js"
|
|
34
37
|
},
|
|
35
38
|
"engines": {
|
|
36
39
|
"node": ">=18"
|
|
@@ -166,6 +166,17 @@ Use this structure:
|
|
|
166
166
|
- Require Adapter
|
|
167
167
|
- Require Supervisor Review
|
|
168
168
|
|
|
169
|
+
## Icon System Guidance (Advisory)
|
|
170
|
+
- Goal
|
|
171
|
+
- Source
|
|
172
|
+
- Allowed families
|
|
173
|
+
- Default family
|
|
174
|
+
- Default spec
|
|
175
|
+
- Reuse rule
|
|
176
|
+
- Placeholder policy
|
|
177
|
+
- Review requirement
|
|
178
|
+
- Checkpoint policy (`WARN` by default)
|
|
179
|
+
|
|
169
180
|
## Do
|
|
170
181
|
- approved stylistic moves
|
|
171
182
|
|
|
@@ -189,6 +200,19 @@ Field meaning:
|
|
|
189
200
|
- `Fallback`: what to do when preferred adapters are unavailable
|
|
190
201
|
- `Require Adapter`: whether missing adapters should block the workflow
|
|
191
202
|
- `Require Supervisor Review`: whether missing, blocked, or unaccepted `design-supervisor review` should block broad expansion, implementation-task handoff, or terminal completion
|
|
203
|
+
- `Icon System Guidance (Advisory)`: optional project-level icon consistency guidance; keep this non-blocking by default and escalate to hard gate only when explicit signoff is required
|
|
204
|
+
|
|
205
|
+
Icon System Guidance field meaning:
|
|
206
|
+
|
|
207
|
+
- `Goal`: icon-quality objective for this project (consistency, readability, brand fit)
|
|
208
|
+
- `Source`: expected icon source policy, typically preferring `icon_font` for functional icons
|
|
209
|
+
- `Allowed families`: approved icon families for this project
|
|
210
|
+
- `Default family`: preferred baseline family to reduce mixed-style drift
|
|
211
|
+
- `Default spec`: baseline size/weight/color-token rules for functional icons
|
|
212
|
+
- `Reuse rule`: whether icon variants should be consolidated as reusable components before broad expansion
|
|
213
|
+
- `Placeholder policy`: what counts as unacceptable placeholder icon usage
|
|
214
|
+
- `Review requirement`: icon checks reviewers must explicitly record per anchor review
|
|
215
|
+
- `Checkpoint policy`: whether unresolved icon issues default to `WARN` or `BLOCK`
|
|
192
216
|
|
|
193
217
|
Use this artifact as a project-level visual contract. Generate it when the project does not already have one.
|
|
194
218
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"aliases": {
|
|
3
|
+
"保险箱": ["vault", "safe box", "archive", "inventory_2"],
|
|
4
|
+
"解密": ["unlock", "lock_open", "key", "verified_user"],
|
|
5
|
+
"重试": ["refresh", "retry", "sync", "rotate-cw", "arrow-clockwise"],
|
|
6
|
+
"客服": ["headset", "support_agent", "message-circle", "chat"],
|
|
7
|
+
"工单": ["ticket", "file-text", "message-square"],
|
|
8
|
+
"风控": ["shield", "shield-check", "verified_user"],
|
|
9
|
+
"登录": ["login", "log-in", "sign-in", "person"],
|
|
10
|
+
"退出": ["logout", "log-out", "sign-out"]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
function getArg(flag) {
|
|
6
|
+
const index = process.argv.indexOf(flag);
|
|
7
|
+
if (index < 0) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return process.argv[index + 1];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeReadJson(filePath, fallback) {
|
|
14
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function main() {
|
|
25
|
+
const inputPath = getArg("-i");
|
|
26
|
+
const outputPath = getArg("-o");
|
|
27
|
+
const commandText = fs.readFileSync(0, "utf8");
|
|
28
|
+
const document = safeReadJson(inputPath, { children: [], variables: {} });
|
|
29
|
+
|
|
30
|
+
let payload;
|
|
31
|
+
if (/get_variables\s*\(/.test(commandText)) {
|
|
32
|
+
payload = {
|
|
33
|
+
variables: document && typeof document.variables === "object" ? document.variables : {}
|
|
34
|
+
};
|
|
35
|
+
} else {
|
|
36
|
+
payload = {
|
|
37
|
+
nodes: Array.isArray(document.children) ? document.children : []
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (outputPath) {
|
|
42
|
+
const data = inputPath && fs.existsSync(inputPath) ? fs.readFileSync(inputPath, "utf8") : '{"version":"2.9","children":[]}\n';
|
|
43
|
+
fs.writeFileSync(outputPath, data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const assert = require("assert/strict");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const {
|
|
6
|
+
DEFAULT_ALIASES,
|
|
7
|
+
getDefaultAliasPath,
|
|
8
|
+
resolveAliasPath,
|
|
9
|
+
loadIconAliases,
|
|
10
|
+
expandQueryWithAliases,
|
|
11
|
+
normalizeAliasMap
|
|
12
|
+
} = require("../lib/icon-aliases");
|
|
13
|
+
|
|
14
|
+
function runTest(name, fn) {
|
|
15
|
+
try {
|
|
16
|
+
fn();
|
|
17
|
+
console.log(`PASS ${name}`);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error(`FAIL ${name}`);
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
runTest("default aliases include vault semantics", () => {
|
|
25
|
+
assert.ok(Array.isArray(DEFAULT_ALIASES["保险箱"]));
|
|
26
|
+
assert.ok(DEFAULT_ALIASES["保险箱"].includes("vault"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
runTest("normalizeAliasMap normalizes keys and values", () => {
|
|
30
|
+
const map = normalizeAliasMap({
|
|
31
|
+
" 设 置 ": [" settings ", "tune"],
|
|
32
|
+
"": ["ignored"]
|
|
33
|
+
});
|
|
34
|
+
assert.ok(map["设 置"]);
|
|
35
|
+
assert.deepEqual(map["设 置"], ["settings", "tune"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
runTest("default alias path resolves under home", () => {
|
|
39
|
+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "da-vinci-alias-home-"));
|
|
40
|
+
const aliasPath = getDefaultAliasPath(tempHome);
|
|
41
|
+
assert.match(aliasPath, /\.da-vinci[\/\\]icon-aliases\.json$/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
runTest("loadIconAliases merges user file over defaults", () => {
|
|
45
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "da-vinci-alias-file-"));
|
|
46
|
+
const aliasPath = path.join(tempDir, "icon-aliases.json");
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
aliasPath,
|
|
49
|
+
JSON.stringify(
|
|
50
|
+
{
|
|
51
|
+
aliases: {
|
|
52
|
+
"保险箱": ["vault", "safe-box-custom"],
|
|
53
|
+
"工单": ["ticket", "message-square"]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
null,
|
|
57
|
+
2
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const loaded = loadIconAliases({
|
|
62
|
+
aliasPath
|
|
63
|
+
});
|
|
64
|
+
assert.equal(loaded.loaded, true);
|
|
65
|
+
assert.equal(loaded.source, "file");
|
|
66
|
+
assert.ok(loaded.aliases["保险箱"].includes("safe-box-custom"));
|
|
67
|
+
assert.ok(loaded.aliases["工单"].includes("ticket"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
runTest("expandQueryWithAliases adds mapped extra tokens", () => {
|
|
71
|
+
const expansion = expandQueryWithAliases("保险箱 解密", {
|
|
72
|
+
...normalizeAliasMap(DEFAULT_ALIASES),
|
|
73
|
+
工单: ["ticket"]
|
|
74
|
+
});
|
|
75
|
+
assert.ok(expansion.extraTokens.includes("vault"));
|
|
76
|
+
assert.ok(expansion.extraTokens.includes("unlock"));
|
|
77
|
+
assert.ok(expansion.matchedAliases.length >= 2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
runTest("resolveAliasPath returns absolute path", () => {
|
|
81
|
+
const resolved = resolveAliasPath({
|
|
82
|
+
aliasPath: "./tmp/icon-aliases.json"
|
|
83
|
+
});
|
|
84
|
+
assert.ok(path.isAbsolute(resolved));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
console.log("All icon-aliases tests passed.");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const assert = require("assert/strict");
|
|
2
|
+
const {
|
|
3
|
+
searchIconLibrary,
|
|
4
|
+
formatIconSearchReport
|
|
5
|
+
} = require("../lib/icon-search");
|
|
6
|
+
|
|
7
|
+
function runTest(name, fn) {
|
|
8
|
+
try {
|
|
9
|
+
fn();
|
|
10
|
+
console.log(`PASS ${name}`);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.error(`FAIL ${name}`);
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
runTest("exact settings query ranks material rounded first", () => {
|
|
18
|
+
const result = searchIconLibrary("settings", { top: 5 });
|
|
19
|
+
assert.ok(result.matches.length > 0);
|
|
20
|
+
assert.equal(result.matches[0].family, "Material Symbols Rounded");
|
|
21
|
+
assert.equal(result.matches[0].name, "settings");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
runTest("chinese query can resolve settings candidates", () => {
|
|
25
|
+
const result = searchIconLibrary("设置", { top: 5 });
|
|
26
|
+
assert.ok(result.matches.some((match) => match.name === "settings"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
runTest("family filter returns only the selected family", () => {
|
|
30
|
+
const result = searchIconLibrary("lock", { family: "lucide", top: 6 });
|
|
31
|
+
assert.ok(result.matches.length > 0);
|
|
32
|
+
for (const match of result.matches) {
|
|
33
|
+
assert.equal(match.family, "lucide");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
runTest("material family alias expands to all material variants", () => {
|
|
38
|
+
const result = searchIconLibrary("home", { family: "material", top: 6 });
|
|
39
|
+
const families = new Set(result.matches.map((match) => match.family));
|
|
40
|
+
assert.ok(families.has("Material Symbols Rounded"));
|
|
41
|
+
assert.ok(families.has("Material Symbols Outlined"));
|
|
42
|
+
assert.ok(families.has("Material Symbols Sharp"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
runTest("unknown family filter throws a clear error", () => {
|
|
46
|
+
assert.throws(() => searchIconLibrary("lock", { family: "unknown-family" }), /Unknown icon family filter/i);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
runTest("formatted report contains node payload hints", () => {
|
|
50
|
+
const result = searchIconLibrary("vault", { top: 3 });
|
|
51
|
+
const report = formatIconSearchReport(result);
|
|
52
|
+
assert.match(report, /Icon Search/);
|
|
53
|
+
assert.match(report, /node:/);
|
|
54
|
+
assert.match(report, /iconFontFamily/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
runTest("external catalog records are merged into search candidates", () => {
|
|
58
|
+
const result = searchIconLibrary("launch rocket", {
|
|
59
|
+
top: 5,
|
|
60
|
+
catalog: [
|
|
61
|
+
{
|
|
62
|
+
family: "lucide",
|
|
63
|
+
name: "rocket",
|
|
64
|
+
semantic: "rocket",
|
|
65
|
+
tags: ["launch"]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
assert.ok(result.matches.some((match) => match.family === "lucide" && match.name === "rocket"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
console.log("All icon-search tests passed.");
|