design-learn-server 0.1.1

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.
Files changed (37) hide show
  1. package/README.md +123 -0
  2. package/package.json +29 -0
  3. package/src/cli.js +152 -0
  4. package/src/mcp/index.js +556 -0
  5. package/src/pipeline/index.js +335 -0
  6. package/src/playwrightSupport.js +65 -0
  7. package/src/preview/index.js +204 -0
  8. package/src/server.js +1385 -0
  9. package/src/stdio.js +464 -0
  10. package/src/storage/fileStore.js +45 -0
  11. package/src/storage/index.js +983 -0
  12. package/src/storage/paths.js +113 -0
  13. package/src/storage/sqliteStore.js +114 -0
  14. package/src/uipro/bm25.js +121 -0
  15. package/src/uipro/config.js +264 -0
  16. package/src/uipro/csv.js +90 -0
  17. package/src/uipro/data/charts.csv +26 -0
  18. package/src/uipro/data/colors.csv +97 -0
  19. package/src/uipro/data/icons.csv +101 -0
  20. package/src/uipro/data/landing.csv +31 -0
  21. package/src/uipro/data/products.csv +97 -0
  22. package/src/uipro/data/prompts.csv +24 -0
  23. package/src/uipro/data/stacks/flutter.csv +53 -0
  24. package/src/uipro/data/stacks/html-tailwind.csv +56 -0
  25. package/src/uipro/data/stacks/nextjs.csv +53 -0
  26. package/src/uipro/data/stacks/nuxt-ui.csv +51 -0
  27. package/src/uipro/data/stacks/nuxtjs.csv +59 -0
  28. package/src/uipro/data/stacks/react-native.csv +52 -0
  29. package/src/uipro/data/stacks/react.csv +54 -0
  30. package/src/uipro/data/stacks/shadcn.csv +61 -0
  31. package/src/uipro/data/stacks/svelte.csv +54 -0
  32. package/src/uipro/data/stacks/swiftui.csv +51 -0
  33. package/src/uipro/data/stacks/vue.csv +50 -0
  34. package/src/uipro/data/styles.csv +59 -0
  35. package/src/uipro/data/typography.csv +58 -0
  36. package/src/uipro/data/ux-guidelines.csv +100 -0
  37. package/src/uipro/index.js +581 -0
@@ -0,0 +1,581 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const { BM25 } = require('./bm25');
5
+ const { parseCsvFile } = require('./csv');
6
+ const {
7
+ DOMAIN_CONFIG,
8
+ STACK_CONFIG,
9
+ STACK_SEARCH_COLUMNS,
10
+ STACK_OUTPUT_COLUMNS,
11
+ detectDomain,
12
+ AVAILABLE_DOMAINS,
13
+ AVAILABLE_STACKS,
14
+ } = require('./config');
15
+
16
+ function expandQuery(query) {
17
+ const raw = String(query ?? '').trim();
18
+ if (!raw) return '';
19
+
20
+ const expansions = [];
21
+ const lower = raw.toLowerCase();
22
+
23
+ const synonymPairs = [
24
+ ['仪表盘', 'dashboard analytics admin panel'],
25
+ ['看板', 'dashboard kanban board'],
26
+ ['按钮', 'button cta'],
27
+ ['表单', 'form input validation'],
28
+ ['弹窗', 'modal dialog'],
29
+ ['对话框', 'dialog modal'],
30
+ ['导航', 'navigation navbar sidebar'],
31
+ ['侧边栏', 'sidebar navigation'],
32
+ ['卡片', 'card'],
33
+ ['列表', 'list table'],
34
+ ['表格', 'table grid'],
35
+ ['搜索', 'search autocomplete'],
36
+ ['登录', 'login auth'],
37
+ ['注册', 'signup registration'],
38
+ ['定价', 'pricing'],
39
+ ['落地页', 'landing hero cta'],
40
+ ['排版', 'typography font'],
41
+ ['配色', 'color palette'],
42
+ ['图表', 'chart graph visualization'],
43
+ ['无障碍', 'accessibility wcag'],
44
+ ];
45
+
46
+ for (const [needle, expansion] of synonymPairs) {
47
+ if (raw.includes(needle) || lower.includes(needle.toLowerCase())) {
48
+ expansions.push(expansion);
49
+ }
50
+ }
51
+
52
+ // 少量“伪语义”扩展:常用英文词补齐同类词
53
+ const englishPairs = [
54
+ ['dashboard', 'analytics admin panel'],
55
+ ['button', 'cta primary secondary'],
56
+ ['modal', 'dialog overlay'],
57
+ ['form', 'input validation error'],
58
+ ['landing', 'hero cta section'],
59
+ ['typography', 'font heading body'],
60
+ ['color', 'palette hex rgb'],
61
+ ['chart', 'graph visualization'],
62
+ ];
63
+ for (const [needle, expansion] of englishPairs) {
64
+ if (lower.includes(needle)) expansions.push(expansion);
65
+ }
66
+
67
+ const extra = expansions.join(' ').trim();
68
+ return extra ? `${raw} ${extra}` : raw;
69
+ }
70
+
71
+ function resolveDataDir(options = {}) {
72
+ const override = process.env.DESIGN_LEARN_UIPRO_DATA_DIR;
73
+ if (override) {
74
+ return override;
75
+ }
76
+
77
+ const baseDataDir = options.dataDir;
78
+ if (baseDataDir) {
79
+ const candidate = path.join(baseDataDir, 'uipro');
80
+ try {
81
+ if (fs.statSync(candidate).isDirectory()) {
82
+ return candidate;
83
+ }
84
+ } catch {
85
+ // ignore
86
+ }
87
+ }
88
+
89
+ return path.join(__dirname, 'data');
90
+ }
91
+
92
+ function normalizeLimit(limit, fallback = 5) {
93
+ if (!Number.isFinite(limit)) {
94
+ return fallback;
95
+ }
96
+ return Math.min(Math.max(Math.trunc(limit), 1), 20);
97
+ }
98
+
99
+ function createUipro(options = {}) {
100
+ const dataDir = resolveDataDir(options);
101
+ const domainCache = new Map();
102
+ const stackCache = new Map();
103
+ const domainSuggestCache = new Map();
104
+ const stackSuggestCache = new Map();
105
+
106
+ function loadCsvIndex({ file, searchColumns }) {
107
+ const filePath = path.join(dataDir, file);
108
+ let parsed;
109
+ try {
110
+ parsed = parseCsvFile(filePath);
111
+ } catch {
112
+ return {
113
+ error: 'uipro_data_unavailable',
114
+ reason: 'csv_read_failed',
115
+ file,
116
+ };
117
+ }
118
+
119
+ const { headers, records } = parsed || {};
120
+ if (!Array.isArray(headers) || headers.length === 0) {
121
+ return {
122
+ error: 'invalid_csv',
123
+ reason: 'missing_headers',
124
+ file,
125
+ };
126
+ }
127
+ if (!Array.isArray(records) || records.length === 0) {
128
+ return {
129
+ error: 'invalid_csv',
130
+ reason: 'empty_records',
131
+ file,
132
+ };
133
+ }
134
+
135
+ const missingColumns = (searchColumns || []).filter((column) => !headers.includes(column));
136
+ if (missingColumns.length > 0) {
137
+ return {
138
+ error: 'invalid_csv',
139
+ reason: 'missing_required_columns',
140
+ file,
141
+ missingColumns,
142
+ };
143
+ }
144
+
145
+ const documents = records.map((row) =>
146
+ searchColumns.map((column) => row[column] || '').join(' ')
147
+ );
148
+ const bm25 = new BM25();
149
+ bm25.fit(documents);
150
+ return { file, filePath, records, documents, bm25 };
151
+ }
152
+
153
+ function pickKeywordColumnsForDomain(domain) {
154
+ const config = DOMAIN_CONFIG[domain];
155
+ if (!config) return [];
156
+ // 这些列通常包含可用于“推荐/浏览”的关键词
157
+ const candidates = [
158
+ 'Keywords',
159
+ 'Mood/Style Keywords',
160
+ 'Best For',
161
+ 'Type',
162
+ 'Style Category',
163
+ 'Product Type',
164
+ 'Pattern Name',
165
+ 'Category',
166
+ 'Issue',
167
+ 'Icon Name',
168
+ 'Data Type',
169
+ 'AI Prompt Keywords (Copy-Paste Ready)',
170
+ 'CSS/Technical Keywords',
171
+ ];
172
+ return candidates.filter((col) => config.searchColumns.includes(col) || config.outputColumns.includes(col));
173
+ }
174
+
175
+ function extractTopKeywords(records, columns, limit) {
176
+ const counts = new Map();
177
+ const max = normalizeLimit(limit, 20);
178
+
179
+ const normalizePiece = (value) =>
180
+ String(value ?? '')
181
+ .toLowerCase()
182
+ .replace(/[^\p{L}\p{N}\s,/-]+/gu, ' ')
183
+ .trim();
184
+
185
+ const bump = (token) => {
186
+ const t = String(token ?? '').trim();
187
+ if (!t) return;
188
+ // 太短的词大多没信息量,但保留 ui/ux/cta
189
+ if (t.length < 2) return;
190
+ if (t.length === 2 && !['ui', 'ux'].includes(t)) return;
191
+ counts.set(t, (counts.get(t) || 0) + 1);
192
+ };
193
+
194
+ for (const row of records) {
195
+ for (const col of columns) {
196
+ const raw = normalizePiece(row?.[col]);
197
+ if (!raw) continue;
198
+ // 兼容 “a, b, c” 或 “a / b” 这种分隔
199
+ const parts = raw
200
+ .split(/[,\n/]+/g)
201
+ .map((p) => p.trim())
202
+ .filter(Boolean);
203
+ for (const part of parts) {
204
+ // 再按空格拆,得到更细的 token(BM25 里也会拆)
205
+ const words = part.split(/\s+/).filter(Boolean);
206
+ if (words.length <= 1) {
207
+ bump(part);
208
+ } else {
209
+ for (const w of words) bump(w);
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
216
+ return sorted.slice(0, max).map(([token]) => token);
217
+ }
218
+
219
+ function getDomainIndex(domain) {
220
+ const cached = domainCache.get(domain);
221
+ if (cached) {
222
+ return cached;
223
+ }
224
+ const config = DOMAIN_CONFIG[domain];
225
+ if (!config) {
226
+ return null;
227
+ }
228
+ const index = loadCsvIndex({ file: config.file, searchColumns: config.searchColumns });
229
+ const entry = { ...index, config };
230
+ domainCache.set(domain, entry);
231
+ return entry;
232
+ }
233
+
234
+ function getStackIndex(stack) {
235
+ const cached = stackCache.get(stack);
236
+ if (cached) {
237
+ return cached;
238
+ }
239
+ const config = STACK_CONFIG[stack];
240
+ if (!config) {
241
+ return null;
242
+ }
243
+ const index = loadCsvIndex({ file: config.file, searchColumns: STACK_SEARCH_COLUMNS });
244
+ const entry = { ...index, stack };
245
+ stackCache.set(stack, entry);
246
+ return entry;
247
+ }
248
+
249
+ function search({ query, domain, limit } = {}) {
250
+ if (typeof query !== 'string' || query.trim() === '') {
251
+ return { error: 'missing_query' };
252
+ }
253
+
254
+ const resolvedDomain = domain || detectDomain(query);
255
+ const entry = getDomainIndex(resolvedDomain);
256
+ if (!entry) {
257
+ return {
258
+ error: `unknown_domain:${resolvedDomain}`,
259
+ availableDomains: AVAILABLE_DOMAINS,
260
+ };
261
+ }
262
+ if (entry.error) {
263
+ const errorResult = {
264
+ error: entry.error,
265
+ reason: entry.reason,
266
+ domain: resolvedDomain,
267
+ file: entry.file,
268
+ };
269
+ if (Array.isArray(entry.missingColumns) && entry.missingColumns.length > 0) {
270
+ errorResult.missingColumns = entry.missingColumns;
271
+ }
272
+ return errorResult;
273
+ }
274
+
275
+ const maxResults = normalizeLimit(limit, 5);
276
+ const effectiveQuery = expandQuery(query);
277
+ const scored = entry.bm25.score(effectiveQuery);
278
+ const results = [];
279
+ for (const [rowIndex, score] of scored) {
280
+ if (score <= 0) {
281
+ continue;
282
+ }
283
+ const row = entry.records[rowIndex];
284
+ const output = {};
285
+ for (const col of entry.config.outputColumns) {
286
+ if (col in row) {
287
+ output[col] = row[col];
288
+ }
289
+ }
290
+ output._score = Number(score.toFixed(4));
291
+ results.push(output);
292
+ if (results.length >= maxResults) {
293
+ break;
294
+ }
295
+ }
296
+
297
+ // 兜底:BM25 没命中时做轻量 contains 匹配(提升可用性,不追求严格语义)
298
+ if (results.length === 0) {
299
+ const normalize = (value) =>
300
+ String(value ?? '')
301
+ .toLowerCase()
302
+ .replace(/[^\p{L}\p{N}\s]+/gu, ' ')
303
+ .trim();
304
+ const q = normalize(effectiveQuery);
305
+ const qTokens = q.split(/\s+/).filter((t) => t.length >= 2).slice(0, 6);
306
+
307
+ if (qTokens.length > 0 && Array.isArray(entry.documents)) {
308
+ for (let rowIndex = 0; rowIndex < entry.documents.length; rowIndex += 1) {
309
+ const doc = normalize(entry.documents[rowIndex]);
310
+ if (!doc) continue;
311
+ const hit = qTokens.some((t) => doc.includes(t));
312
+ if (!hit) continue;
313
+
314
+ const row = entry.records[rowIndex];
315
+ const output = {};
316
+ for (const col of entry.config.outputColumns) {
317
+ if (col in row) output[col] = row[col];
318
+ }
319
+ output._score = 0.0001;
320
+ results.push(output);
321
+ if (results.length >= maxResults) break;
322
+ }
323
+ }
324
+ }
325
+
326
+ return {
327
+ source: 'ui-ux-pro-max',
328
+ domain: resolvedDomain,
329
+ query,
330
+ file: entry.file,
331
+ count: results.length,
332
+ results,
333
+ ...(results.length === 0 && domain
334
+ ? (() => {
335
+ const detected = detectDomain(query);
336
+ if (detected && detected !== resolvedDomain) {
337
+ return { hint: `当前 domain=${resolvedDomain} 无结果,试试 domain=${detected} 或切回 auto` };
338
+ }
339
+ return {};
340
+ })()
341
+ : {}),
342
+ };
343
+ }
344
+
345
+ function browse({ domain, limit, offset } = {}) {
346
+ const resolvedDomain = domain || 'style';
347
+ const entry = getDomainIndex(resolvedDomain);
348
+ if (!entry) {
349
+ return { error: `unknown_domain:${resolvedDomain}`, availableDomains: AVAILABLE_DOMAINS };
350
+ }
351
+ if (entry.error) {
352
+ return {
353
+ error: entry.error,
354
+ reason: entry.reason,
355
+ domain: resolvedDomain,
356
+ file: entry.file,
357
+ };
358
+ }
359
+
360
+ const maxResults = normalizeLimit(limit, 20);
361
+ const start = Math.max(Number.isFinite(offset) ? Math.trunc(offset) : 0, 0);
362
+ const end = Math.min(start + maxResults, entry.records.length);
363
+ const rows = entry.records.slice(start, end);
364
+ const items = rows.map((row) => {
365
+ const output = {};
366
+ for (const col of entry.config.outputColumns) {
367
+ if (col in row) output[col] = row[col];
368
+ }
369
+ return output;
370
+ });
371
+
372
+ return {
373
+ source: 'ui-ux-pro-max',
374
+ domain: resolvedDomain,
375
+ file: entry.file,
376
+ total: entry.records.length,
377
+ offset: start,
378
+ count: items.length,
379
+ results: items,
380
+ };
381
+ }
382
+
383
+ function suggest({ domain, limit } = {}) {
384
+ const resolvedDomain = domain || 'style';
385
+ const cached = domainSuggestCache.get(resolvedDomain);
386
+ if (cached && typeof cached === 'object') return cached;
387
+
388
+ const entry = getDomainIndex(resolvedDomain);
389
+ if (!entry) {
390
+ return { error: `unknown_domain:${resolvedDomain}`, availableDomains: AVAILABLE_DOMAINS };
391
+ }
392
+ if (entry.error) {
393
+ return {
394
+ error: entry.error,
395
+ reason: entry.reason,
396
+ domain: resolvedDomain,
397
+ file: entry.file,
398
+ };
399
+ }
400
+
401
+ const columns = pickKeywordColumnsForDomain(resolvedDomain);
402
+ const keywords = extractTopKeywords(entry.records, columns, limit);
403
+ const result = {
404
+ source: 'ui-ux-pro-max',
405
+ domain: resolvedDomain,
406
+ file: entry.file,
407
+ keywords,
408
+ count: keywords.length,
409
+ };
410
+ domainSuggestCache.set(resolvedDomain, result);
411
+ return result;
412
+ }
413
+
414
+ function searchStack({ query, stack, limit } = {}) {
415
+ if (typeof query !== 'string' || query.trim() === '') {
416
+ return { error: 'missing_query' };
417
+ }
418
+ if (typeof stack !== 'string' || stack.trim() === '') {
419
+ return { error: 'missing_stack', availableStacks: AVAILABLE_STACKS };
420
+ }
421
+
422
+ const entry = getStackIndex(stack);
423
+ if (!entry) {
424
+ return { error: `unknown_stack:${stack}`, availableStacks: AVAILABLE_STACKS };
425
+ }
426
+ if (entry.error) {
427
+ const errorResult = {
428
+ error: entry.error,
429
+ reason: entry.reason,
430
+ stack,
431
+ file: entry.file,
432
+ };
433
+ if (Array.isArray(entry.missingColumns) && entry.missingColumns.length > 0) {
434
+ errorResult.missingColumns = entry.missingColumns;
435
+ }
436
+ return errorResult;
437
+ }
438
+
439
+ const maxResults = normalizeLimit(limit, 5);
440
+ const effectiveQuery = expandQuery(query);
441
+ const scored = entry.bm25.score(effectiveQuery);
442
+ const results = [];
443
+ for (const [rowIndex, score] of scored) {
444
+ if (score <= 0) {
445
+ continue;
446
+ }
447
+ const row = entry.records[rowIndex];
448
+ const output = {};
449
+ for (const col of STACK_OUTPUT_COLUMNS) {
450
+ if (col in row) {
451
+ output[col] = row[col];
452
+ }
453
+ }
454
+ output._score = Number(score.toFixed(4));
455
+ results.push(output);
456
+ if (results.length >= maxResults) {
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (results.length === 0) {
462
+ const normalize = (value) =>
463
+ String(value ?? '')
464
+ .toLowerCase()
465
+ .replace(/[^\p{L}\p{N}\s]+/gu, ' ')
466
+ .trim();
467
+ const q = normalize(effectiveQuery);
468
+ const qTokens = q.split(/\s+/).filter((t) => t.length >= 2).slice(0, 6);
469
+
470
+ if (qTokens.length > 0 && Array.isArray(entry.documents)) {
471
+ for (let rowIndex = 0; rowIndex < entry.documents.length; rowIndex += 1) {
472
+ const doc = normalize(entry.documents[rowIndex]);
473
+ if (!doc) continue;
474
+ const hit = qTokens.some((t) => doc.includes(t));
475
+ if (!hit) continue;
476
+
477
+ const row = entry.records[rowIndex];
478
+ const output = {};
479
+ for (const col of STACK_OUTPUT_COLUMNS) {
480
+ if (col in row) output[col] = row[col];
481
+ }
482
+ output._score = 0.0001;
483
+ results.push(output);
484
+ if (results.length >= maxResults) break;
485
+ }
486
+ }
487
+ }
488
+
489
+ return {
490
+ source: 'ui-ux-pro-max',
491
+ domain: 'stack',
492
+ stack,
493
+ query,
494
+ file: entry.file,
495
+ count: results.length,
496
+ results,
497
+ };
498
+ }
499
+
500
+ function browseStack({ stack, limit, offset } = {}) {
501
+ if (typeof stack !== 'string' || stack.trim() === '') {
502
+ return { error: 'missing_stack', availableStacks: AVAILABLE_STACKS };
503
+ }
504
+ const entry = getStackIndex(stack);
505
+ if (!entry) {
506
+ return { error: `unknown_stack:${stack}`, availableStacks: AVAILABLE_STACKS };
507
+ }
508
+ if (entry.error) {
509
+ return { error: entry.error, reason: entry.reason, stack, file: entry.file };
510
+ }
511
+
512
+ const maxResults = normalizeLimit(limit, 20);
513
+ const start = Math.max(Number.isFinite(offset) ? Math.trunc(offset) : 0, 0);
514
+ const end = Math.min(start + maxResults, entry.records.length);
515
+ const rows = entry.records.slice(start, end);
516
+ const items = rows.map((row) => {
517
+ const output = {};
518
+ for (const col of STACK_OUTPUT_COLUMNS) {
519
+ if (col in row) output[col] = row[col];
520
+ }
521
+ return output;
522
+ });
523
+
524
+ return {
525
+ source: 'ui-ux-pro-max',
526
+ domain: 'stack',
527
+ stack,
528
+ file: entry.file,
529
+ total: entry.records.length,
530
+ offset: start,
531
+ count: items.length,
532
+ results: items,
533
+ };
534
+ }
535
+
536
+ function suggestStack({ stack, limit } = {}) {
537
+ if (typeof stack !== 'string' || stack.trim() === '') {
538
+ return { error: 'missing_stack', availableStacks: AVAILABLE_STACKS };
539
+ }
540
+
541
+ const cacheKey = `${stack}::${limit ?? ''}`;
542
+ const cached = stackSuggestCache.get(cacheKey);
543
+ if (cached && typeof cached === 'object') return cached;
544
+
545
+ const entry = getStackIndex(stack);
546
+ if (!entry) {
547
+ return { error: `unknown_stack:${stack}`, availableStacks: AVAILABLE_STACKS };
548
+ }
549
+ if (entry.error) {
550
+ return { error: entry.error, reason: entry.reason, stack, file: entry.file };
551
+ }
552
+
553
+ const keywords = extractTopKeywords(entry.records, STACK_SEARCH_COLUMNS, limit);
554
+ const result = {
555
+ source: 'ui-ux-pro-max',
556
+ domain: 'stack',
557
+ stack,
558
+ file: entry.file,
559
+ keywords,
560
+ count: keywords.length,
561
+ };
562
+ stackSuggestCache.set(cacheKey, result);
563
+ return result;
564
+ }
565
+
566
+ return {
567
+ dataDir,
568
+ domains: AVAILABLE_DOMAINS,
569
+ stacks: AVAILABLE_STACKS,
570
+ search,
571
+ browse,
572
+ suggest,
573
+ searchStack,
574
+ browseStack,
575
+ suggestStack,
576
+ };
577
+ }
578
+
579
+ module.exports = {
580
+ createUipro,
581
+ };