@urbicon-ui/mcp-server 6.1.4
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 +161 -0
- package/dist/data/catalog-loader.d.ts +37 -0
- package/dist/data/catalog-loader.d.ts.map +1 -0
- package/dist/data/catalog-loader.js +15 -0
- package/dist/data/catalog-loader.js.map +1 -0
- package/dist/data/component-loader.d.ts +2 -0
- package/dist/data/component-loader.d.ts.map +1 -0
- package/dist/data/component-loader.js +17 -0
- package/dist/data/component-loader.js.map +1 -0
- package/dist/data/recipe-loader.d.ts +4 -0
- package/dist/data/recipe-loader.d.ts.map +1 -0
- package/dist/data/recipe-loader.js +102 -0
- package/dist/data/recipe-loader.js.map +1 -0
- package/dist/data/template-loader.d.ts +8 -0
- package/dist/data/template-loader.d.ts.map +1 -0
- package/dist/data/template-loader.js +33 -0
- package/dist/data/template-loader.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/catalog.d.ts +3 -0
- package/dist/resources/catalog.d.ts.map +1 -0
- package/dist/resources/catalog.js +20 -0
- package/dist/resources/catalog.js.map +1 -0
- package/dist/resources/component.d.ts +3 -0
- package/dist/resources/component.d.ts.map +1 -0
- package/dist/resources/component.js +29 -0
- package/dist/resources/component.js.map +1 -0
- package/dist/resources/guides.d.ts +3 -0
- package/dist/resources/guides.d.ts.map +1 -0
- package/dist/resources/guides.js +36 -0
- package/dist/resources/guides.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/find-components.d.ts +3 -0
- package/dist/tools/find-components.d.ts.map +1 -0
- package/dist/tools/find-components.js +21 -0
- package/dist/tools/find-components.js.map +1 -0
- package/dist/tools/get-recipe.d.ts +3 -0
- package/dist/tools/get-recipe.d.ts.map +1 -0
- package/dist/tools/get-recipe.js +48 -0
- package/dist/tools/get-recipe.js.map +1 -0
- package/dist/tools/suggest-implementation.d.ts +3 -0
- package/dist/tools/suggest-implementation.d.ts.map +1 -0
- package/dist/tools/suggest-implementation.js +178 -0
- package/dist/tools/suggest-implementation.js.map +1 -0
- package/dist/transports/http.d.ts +2 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +77 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/stdio.d.ts +3 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +6 -0
- package/dist/transports/stdio.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/utils/format-catalog.d.ts +7 -0
- package/dist/utils/format-catalog.d.ts.map +1 -0
- package/dist/utils/format-catalog.js +93 -0
- package/dist/utils/format-catalog.js.map +1 -0
- package/dist/utils/paths.d.ts +7 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +23 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/search.d.ts +3 -0
- package/dist/utils/search.d.ts.map +1 -0
- package/dist/utils/search.js +44 -0
- package/dist/utils/search.js.map +1 -0
- package/package.json +42 -0
- package/src/data/catalog-loader.test.ts +42 -0
- package/src/data/catalog-loader.ts +78 -0
- package/src/data/component-loader.ts +68 -0
- package/src/data/design-system-loader.test.ts +82 -0
- package/src/data/design-system-loader.ts +125 -0
- package/src/data/icon-loader.test.ts +85 -0
- package/src/data/icon-loader.ts +90 -0
- package/src/data/recipe-loader.test.ts +49 -0
- package/src/data/recipe-loader.ts +131 -0
- package/src/data/template-loader.ts +55 -0
- package/src/design-linter/heuristics.ts +162 -0
- package/src/design-linter/index.ts +14 -0
- package/src/design-linter/linter.test.ts +257 -0
- package/src/design-linter/linter.ts +62 -0
- package/src/design-linter/rules.ts +348 -0
- package/src/design-linter/tokens.test.ts +80 -0
- package/src/design-linter/tokens.ts +203 -0
- package/src/design-linter/types.ts +66 -0
- package/src/design-manifest/index.ts +20 -0
- package/src/design-manifest/manifest.test.ts +175 -0
- package/src/design-manifest/manifest.ts +250 -0
- package/src/design-manifest/scan.test.ts +51 -0
- package/src/design-manifest/scan.ts +74 -0
- package/src/design-manifest/types.ts +40 -0
- package/src/design-rubric/rubric.test.ts +43 -0
- package/src/design-rubric/rubric.ts +140 -0
- package/src/eval/briefs.ts +104 -0
- package/src/eval/eval.test.ts +99 -0
- package/src/eval/index.ts +11 -0
- package/src/eval/score.ts +112 -0
- package/src/index.ts +75 -0
- package/src/prompts/design-prompts.test.ts +51 -0
- package/src/prompts/design-prompts.ts +127 -0
- package/src/resources/catalog.ts +23 -0
- package/src/resources/guides.ts +60 -0
- package/src/server.test.ts +69 -0
- package/src/server.ts +48 -0
- package/src/tools/find-components.ts +83 -0
- package/src/tools/find-icons.ts +77 -0
- package/src/tools/get-checklist.ts +139 -0
- package/src/tools/get-component.ts +204 -0
- package/src/tools/get-css-reference.ts +446 -0
- package/src/tools/get-design-context.ts +43 -0
- package/src/tools/get-design-principles.ts +72 -0
- package/src/tools/get-pattern.ts +69 -0
- package/src/tools/get-recipe.ts +80 -0
- package/src/tools/record-design-decision.ts +99 -0
- package/src/tools/suggest-implementation.ts +251 -0
- package/src/tools/sync-design-manifest.ts +92 -0
- package/src/tools/validate-design.ts +84 -0
- package/src/transports/http.ts +79 -0
- package/src/transports/stdio.ts +7 -0
- package/src/utils/format-catalog.test.ts +144 -0
- package/src/utils/format-catalog.ts +130 -0
- package/src/utils/paths.test.ts +101 -0
- package/src/utils/paths.ts +78 -0
- package/src/utils/search.test.ts +141 -0
- package/src/utils/search.ts +106 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getComponentLlmPath } from '../utils/paths.js';
|
|
3
|
+
|
|
4
|
+
const SEARCH_GROUPS = [
|
|
5
|
+
'blocks/primitives',
|
|
6
|
+
'blocks/components',
|
|
7
|
+
'docs/components',
|
|
8
|
+
'table',
|
|
9
|
+
'auth/components'
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export async function loadComponentLlmTxt(slug: string): Promise<string | null> {
|
|
13
|
+
for (const group of SEARCH_GROUPS) {
|
|
14
|
+
const path = getComponentLlmPath(group, slug);
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(path, 'utf-8');
|
|
17
|
+
} catch (err) {
|
|
18
|
+
// Read-tolerant: "not in this group" (file absent) → try the next group.
|
|
19
|
+
// Anything else (permission, corrupt mount, …) is a real fault we must
|
|
20
|
+
// not mask as "component not found" — surface it.
|
|
21
|
+
if ((err as { code?: string }).code === 'ENOENT') continue;
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Absent in every group: a genuine "unknown component". The caller turns this
|
|
26
|
+
// null into a clear not-found message rather than a thrown error.
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LlmTxtSection = 'overview' | 'examples' | 'variants' | 'api' | 'slots';
|
|
31
|
+
|
|
32
|
+
const SECTION_HEADING_MAP: Record<string, LlmTxtSection> = {
|
|
33
|
+
examples: 'examples',
|
|
34
|
+
variants: 'variants',
|
|
35
|
+
api: 'api',
|
|
36
|
+
'slots (slotclasses keys)': 'slots'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function extractSection(content: string, section: LlmTxtSection): string | null {
|
|
40
|
+
if (section === 'overview') {
|
|
41
|
+
const firstH3 = content.indexOf('\n### ');
|
|
42
|
+
if (firstH3 === -1) return content.trim();
|
|
43
|
+
return content.slice(0, firstH3).trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lines = content.split('\n');
|
|
47
|
+
let capturing = false;
|
|
48
|
+
const result: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (line.startsWith('### ')) {
|
|
52
|
+
const heading = line.slice(4).trim().toLowerCase();
|
|
53
|
+
const mapped = SECTION_HEADING_MAP[heading];
|
|
54
|
+
if (mapped === section) {
|
|
55
|
+
capturing = true;
|
|
56
|
+
result.push(line);
|
|
57
|
+
continue;
|
|
58
|
+
} else if (capturing) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (capturing) {
|
|
63
|
+
result.push(line);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result.length > 0 ? result.join('\n').trim() : null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { getDesignSystemDir } from '../utils/paths.js';
|
|
5
|
+
import {
|
|
6
|
+
extractPrincipleSection,
|
|
7
|
+
getPatternByName,
|
|
8
|
+
loadPatterns,
|
|
9
|
+
loadPrinciples
|
|
10
|
+
} from './design-system-loader.js';
|
|
11
|
+
|
|
12
|
+
const dsDir = getDesignSystemDir();
|
|
13
|
+
const principlesAvailable = existsSync(resolve(dsDir, 'principles.md'));
|
|
14
|
+
const patternsAvailable = existsSync(resolve(dsDir, 'patterns'));
|
|
15
|
+
|
|
16
|
+
describe.skipIf(!principlesAvailable)('loadPrinciples', () => {
|
|
17
|
+
it('loads principles as a non-empty string', async () => {
|
|
18
|
+
const principles = await loadPrinciples();
|
|
19
|
+
expect(principles.length).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('contains expected topic headings', async () => {
|
|
23
|
+
const principles = await loadPrinciples();
|
|
24
|
+
expect(principles).toContain('## Visual Hierarchy');
|
|
25
|
+
expect(principles).toContain('## Theming');
|
|
26
|
+
expect(principles).toContain('## Component Selection');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe.skipIf(!principlesAvailable)('extractPrincipleSection', () => {
|
|
31
|
+
it('extracts a specific topic section', async () => {
|
|
32
|
+
const principles = await loadPrinciples();
|
|
33
|
+
const section = extractPrincipleSection(principles, 'theming');
|
|
34
|
+
expect(section).not.toBeNull();
|
|
35
|
+
expect(section).toContain('## Theming');
|
|
36
|
+
expect(section).not.toContain('## Visual Hierarchy');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null for unknown topics', async () => {
|
|
40
|
+
const principles = await loadPrinciples();
|
|
41
|
+
// @ts-expect-error testing invalid input
|
|
42
|
+
const section = extractPrincipleSection(principles, 'nonexistent');
|
|
43
|
+
expect(section).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe.skipIf(!patternsAvailable)('loadPatterns', () => {
|
|
48
|
+
it('loads at least one pattern', async () => {
|
|
49
|
+
const patterns = await loadPatterns();
|
|
50
|
+
expect(patterns.length).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('each pattern has required fields', async () => {
|
|
54
|
+
const patterns = await loadPatterns();
|
|
55
|
+
for (const p of patterns) {
|
|
56
|
+
expect(p.name).toBeTruthy();
|
|
57
|
+
expect(p.title).toBeTruthy();
|
|
58
|
+
expect(p.description).toBeTruthy();
|
|
59
|
+
expect(p.content.length).toBeGreaterThan(0);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('patterns are sorted by name', async () => {
|
|
64
|
+
const patterns = await loadPatterns();
|
|
65
|
+
const names = patterns.map((p) => p.name);
|
|
66
|
+
expect(names).toEqual([...names].sort());
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe.skipIf(!patternsAvailable)('getPatternByName', () => {
|
|
71
|
+
it('finds an existing pattern', async () => {
|
|
72
|
+
const pattern = await getPatternByName('settings-page');
|
|
73
|
+
expect(pattern).not.toBeNull();
|
|
74
|
+
expect(pattern?.name).toBe('settings-page');
|
|
75
|
+
expect(pattern?.title).toBe('Settings Page');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns null for unknown patterns', async () => {
|
|
79
|
+
const pattern = await getPatternByName('nonexistent');
|
|
80
|
+
expect(pattern).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { getDesignSystemDir } from '../utils/paths.js';
|
|
4
|
+
|
|
5
|
+
export interface PatternEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PRINCIPLE_TOPICS = [
|
|
13
|
+
'visual-hierarchy',
|
|
14
|
+
'interaction',
|
|
15
|
+
'component-selection',
|
|
16
|
+
'layout',
|
|
17
|
+
'accessibility',
|
|
18
|
+
'theming'
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type PrincipleTopic = (typeof PRINCIPLE_TOPICS)[number];
|
|
22
|
+
|
|
23
|
+
let cachedPrinciples: string | null = null;
|
|
24
|
+
let cachedPatterns: PatternEntry[] | null = null;
|
|
25
|
+
|
|
26
|
+
export async function loadPrinciples(): Promise<string> {
|
|
27
|
+
if (cachedPrinciples !== null) return cachedPrinciples;
|
|
28
|
+
|
|
29
|
+
const filePath = resolve(getDesignSystemDir(), 'principles.md');
|
|
30
|
+
try {
|
|
31
|
+
cachedPrinciples = await readFile(filePath, 'utf-8');
|
|
32
|
+
} catch {
|
|
33
|
+
cachedPrinciples = '';
|
|
34
|
+
}
|
|
35
|
+
return cachedPrinciples;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadPatterns(): Promise<PatternEntry[]> {
|
|
39
|
+
if (cachedPatterns) return cachedPatterns;
|
|
40
|
+
|
|
41
|
+
const patternsDir = resolve(getDesignSystemDir(), 'patterns');
|
|
42
|
+
const entries: PatternEntry[] = [];
|
|
43
|
+
|
|
44
|
+
let files: string[];
|
|
45
|
+
try {
|
|
46
|
+
files = await readdir(patternsDir);
|
|
47
|
+
} catch {
|
|
48
|
+
cachedPatterns = [];
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (!file.endsWith('.md')) continue;
|
|
54
|
+
|
|
55
|
+
const filePath = resolve(patternsDir, file);
|
|
56
|
+
let content: string;
|
|
57
|
+
try {
|
|
58
|
+
content = await readFile(filePath, 'utf-8');
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const name = file.replace(/\.md$/, '');
|
|
64
|
+
const title = extractTitle(content);
|
|
65
|
+
const description = extractDescription(content);
|
|
66
|
+
|
|
67
|
+
entries.push({ name, title, description, content });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
71
|
+
cachedPatterns = entries;
|
|
72
|
+
return entries;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getPatternByName(name: string): Promise<PatternEntry | null> {
|
|
76
|
+
const patterns = await loadPatterns();
|
|
77
|
+
return patterns.find((p) => p.name === name) ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractTitle(content: string): string {
|
|
81
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
82
|
+
return match?.[1]?.trim() ?? '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractDescription(content: string): string {
|
|
86
|
+
const lines = content.split('\n');
|
|
87
|
+
const titleIdx = lines.findIndex((l) => /^#\s+/.test(l));
|
|
88
|
+
if (titleIdx === -1) return '';
|
|
89
|
+
|
|
90
|
+
for (let i = titleIdx + 1; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i]!.trim();
|
|
92
|
+
if (line === '') continue;
|
|
93
|
+
if (line.startsWith('#')) break;
|
|
94
|
+
return line;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TOPIC_HEADINGS: Record<PrincipleTopic, string> = {
|
|
100
|
+
'visual-hierarchy': '## Visual Hierarchy',
|
|
101
|
+
interaction: '## Interaction',
|
|
102
|
+
'component-selection': '## Component Selection',
|
|
103
|
+
layout: '## Layout',
|
|
104
|
+
accessibility: '## Accessibility',
|
|
105
|
+
theming: '## Theming'
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function extractPrincipleSection(content: string, topic: PrincipleTopic): string | null {
|
|
109
|
+
const heading = TOPIC_HEADINGS[topic];
|
|
110
|
+
if (!heading) return null;
|
|
111
|
+
|
|
112
|
+
const lines = content.split('\n');
|
|
113
|
+
const startIdx = lines.findIndex((l) => l.trim() === heading);
|
|
114
|
+
if (startIdx === -1) return null;
|
|
115
|
+
|
|
116
|
+
let endIdx = lines.length;
|
|
117
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
118
|
+
if (/^## /.test(lines[i]!)) {
|
|
119
|
+
endIdx = i;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.slice(startIdx, endIdx).join('\n').trim();
|
|
125
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseComponentNames, parseIconMetadata } from './icon-loader.js';
|
|
3
|
+
|
|
4
|
+
const FIXTURE = `
|
|
5
|
+
// Simulated icon.context.ts content
|
|
6
|
+
import type { Component } from 'svelte';
|
|
7
|
+
import HomeIcon from './HomeIcon.svelte';
|
|
8
|
+
import SearchIcon from './SearchIcon.svelte';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ICONS = {
|
|
11
|
+
home: HomeIcon,
|
|
12
|
+
search: SearchIcon,
|
|
13
|
+
arrowRight: ArrowRightIcon
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const ICON_METADATA = {
|
|
17
|
+
home: { label: 'Home', categories: ['navigation'], keywords: ['house', 'start'] },
|
|
18
|
+
search: { label: 'Search', categories: ['navigation', 'ui'], keywords: ['find', 'lookup'] },
|
|
19
|
+
arrowRight: { label: 'Arrow Right', categories: ['navigation'], keywords: [] }
|
|
20
|
+
};
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
describe('parseComponentNames', () => {
|
|
24
|
+
it('builds a name → ComponentName map from DEFAULT_ICONS', () => {
|
|
25
|
+
const map = parseComponentNames(FIXTURE);
|
|
26
|
+
expect(map.get('home')).toBe('HomeIcon');
|
|
27
|
+
expect(map.get('search')).toBe('SearchIcon');
|
|
28
|
+
expect(map.get('arrowRight')).toBe('ArrowRightIcon');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns an empty map when the DEFAULT_ICONS block is missing', () => {
|
|
32
|
+
const map = parseComponentNames('const NOT_IT = {};');
|
|
33
|
+
expect(map.size).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('parseIconMetadata', () => {
|
|
38
|
+
it('extracts label, categories, and keywords per entry', () => {
|
|
39
|
+
const entries = parseIconMetadata(FIXTURE);
|
|
40
|
+
expect(entries).toHaveLength(3);
|
|
41
|
+
|
|
42
|
+
const home = entries.find((e) => e.name === 'home');
|
|
43
|
+
expect(home).toMatchObject({
|
|
44
|
+
name: 'home',
|
|
45
|
+
componentName: 'HomeIcon',
|
|
46
|
+
label: 'Home',
|
|
47
|
+
categories: ['navigation'],
|
|
48
|
+
keywords: ['house', 'start']
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('uses the DEFAULT_ICONS mapping for componentName when available', () => {
|
|
53
|
+
const entries = parseIconMetadata(FIXTURE);
|
|
54
|
+
const search = entries.find((e) => e.name === 'search');
|
|
55
|
+
expect(search?.componentName).toBe('SearchIcon');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('falls back to a capitalised name + "Icon" suffix when no mapping exists', () => {
|
|
59
|
+
const withoutMapping = `
|
|
60
|
+
export const ICON_METADATA = {
|
|
61
|
+
custom: { label: 'Custom', categories: ['x'], keywords: ['y'] }
|
|
62
|
+
};
|
|
63
|
+
`;
|
|
64
|
+
const entries = parseIconMetadata(withoutMapping);
|
|
65
|
+
expect(entries[0]?.componentName).toBe('CustomIcon');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns an empty array when ICON_METADATA block is missing', () => {
|
|
69
|
+
expect(parseIconMetadata('// nothing to see')).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles empty categories and keywords arrays gracefully', () => {
|
|
73
|
+
const fixture = `
|
|
74
|
+
export const ICON_METADATA = {
|
|
75
|
+
minimal: { label: 'Minimal', categories: [], keywords: [] }
|
|
76
|
+
};
|
|
77
|
+
`;
|
|
78
|
+
const entries = parseIconMetadata(fixture);
|
|
79
|
+
expect(entries[0]).toMatchObject({
|
|
80
|
+
name: 'minimal',
|
|
81
|
+
categories: [],
|
|
82
|
+
keywords: []
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const packageRoot = resolve(__dirname, '..', '..');
|
|
7
|
+
|
|
8
|
+
export interface IconEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
componentName: string;
|
|
11
|
+
label: string;
|
|
12
|
+
categories: string[];
|
|
13
|
+
keywords: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let cachedIcons: IconEntry[] | null = null;
|
|
17
|
+
|
|
18
|
+
function getIconRegistryPath(): string {
|
|
19
|
+
// DEFAULT_ICONS + ICON_METADATA live in icon-registry.ts since the icon module
|
|
20
|
+
// split (icon.context.ts now holds only the override context + resolveIcon).
|
|
21
|
+
return resolve(packageRoot, '..', 'blocks', 'src', 'lib', 'icons', 'icon-registry.ts');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse DEFAULT_ICONS to get the real name → ComponentName mapping */
|
|
25
|
+
export function parseComponentNames(content: string): Map<string, string> {
|
|
26
|
+
const map = new Map<string, string>();
|
|
27
|
+
const start = content.indexOf('DEFAULT_ICONS');
|
|
28
|
+
if (start === -1) return map;
|
|
29
|
+
|
|
30
|
+
const end = content.indexOf('};', start);
|
|
31
|
+
if (end === -1) return map;
|
|
32
|
+
|
|
33
|
+
const block = content.slice(start, end);
|
|
34
|
+
const regex = /(\w+):\s*(\w+Icon)/g;
|
|
35
|
+
for (const match of block.matchAll(regex)) {
|
|
36
|
+
map.set(match[1]!, match[2]!);
|
|
37
|
+
}
|
|
38
|
+
return map;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseIconMetadata(content: string): IconEntry[] {
|
|
42
|
+
const componentNames = parseComponentNames(content);
|
|
43
|
+
const entries: IconEntry[] = [];
|
|
44
|
+
|
|
45
|
+
const metadataStart = content.indexOf('ICON_METADATA');
|
|
46
|
+
if (metadataStart === -1) return [];
|
|
47
|
+
|
|
48
|
+
const entryRegex =
|
|
49
|
+
/(\w+):\s*\{\s*label:\s*'([^']*)',\s*categories:\s*\[([^\]]*)\],\s*keywords:\s*\[([^\]]*)\]\s*\}/g;
|
|
50
|
+
const block = content.slice(metadataStart);
|
|
51
|
+
|
|
52
|
+
for (const match of block.matchAll(entryRegex)) {
|
|
53
|
+
const name = match[1]!;
|
|
54
|
+
const label = match[2]!;
|
|
55
|
+
const categories = match[3]!
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim().replace(/^'|'$/g, ''))
|
|
58
|
+
.filter((s) => s.length > 0);
|
|
59
|
+
const keywords = match[4]!
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((s) => s.trim().replace(/^'|'$/g, ''))
|
|
62
|
+
.filter((s) => s.length > 0);
|
|
63
|
+
|
|
64
|
+
const componentName =
|
|
65
|
+
componentNames.get(name) ?? `${name.charAt(0).toUpperCase() + name.slice(1)}Icon`;
|
|
66
|
+
|
|
67
|
+
entries.push({
|
|
68
|
+
name,
|
|
69
|
+
componentName,
|
|
70
|
+
label,
|
|
71
|
+
categories,
|
|
72
|
+
keywords
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadIcons(): Promise<IconEntry[]> {
|
|
80
|
+
if (cachedIcons) return cachedIcons;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const content = await readFile(getIconRegistryPath(), 'utf-8');
|
|
84
|
+
cachedIcons = parseIconMetadata(content);
|
|
85
|
+
} catch {
|
|
86
|
+
cachedIcons = [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return cachedIcons;
|
|
90
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { getRecipeDir } from '../utils/paths.js';
|
|
4
|
+
import { getRecipeById, loadRecipes } from './recipe-loader.js';
|
|
5
|
+
|
|
6
|
+
const recipesAvailable = existsSync(getRecipeDir());
|
|
7
|
+
|
|
8
|
+
describe.skipIf(!recipesAvailable)('recipe-loader pattern annotations', () => {
|
|
9
|
+
it('parses the Layer-4 pattern reference from meta.ts', async () => {
|
|
10
|
+
const dashboard = await getRecipeById('dashboard');
|
|
11
|
+
expect(dashboard?.pattern).toBe('dashboard');
|
|
12
|
+
|
|
13
|
+
const login = await getRecipeById('login');
|
|
14
|
+
expect(login?.pattern).toBe('form-page');
|
|
15
|
+
|
|
16
|
+
const onboarding = await getRecipeById('onboarding-flow');
|
|
17
|
+
expect(onboarding?.pattern).toBe('onboarding-guide');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('leaves pattern undefined for recipes without one', async () => {
|
|
21
|
+
const profile = await getRecipeById('profile-card');
|
|
22
|
+
// profile-card is a component-level snippet, not a page archetype → no pattern
|
|
23
|
+
expect(profile?.pattern).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('loads the meal-planner recipe with its planning-board pattern + Planner code', async () => {
|
|
27
|
+
const mealPlanner = await getRecipeById('meal-planner');
|
|
28
|
+
expect(mealPlanner?.title).toBe('Meal Planner');
|
|
29
|
+
expect(mealPlanner?.pattern).toBe('planning-board');
|
|
30
|
+
expect(mealPlanner?.components).toContain('Planner');
|
|
31
|
+
expect(mealPlanner?.code).toContain('<Planner');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('every annotated pattern names an existing pattern file', async () => {
|
|
35
|
+
const recipes = await loadRecipes();
|
|
36
|
+
const known = new Set([
|
|
37
|
+
'dashboard',
|
|
38
|
+
'form-page',
|
|
39
|
+
'settings-page',
|
|
40
|
+
'tab-navigation',
|
|
41
|
+
'onboarding-guide',
|
|
42
|
+
'planning-board'
|
|
43
|
+
]);
|
|
44
|
+
for (const r of recipes) {
|
|
45
|
+
if (r.pattern)
|
|
46
|
+
expect(known, `recipe "${r.id}" → unknown pattern "${r.pattern}"`).toContain(r.pattern);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { getRecipeDir } from '../utils/paths.js';
|
|
4
|
+
import type { RecipeEntry } from './catalog-loader.js';
|
|
5
|
+
|
|
6
|
+
let cachedRecipes: RecipeEntry[] | null = null;
|
|
7
|
+
|
|
8
|
+
function extractRecipeCode(content: string): string {
|
|
9
|
+
const startMatch = content.match(/const\s+recipeCode\s*=\s*\n?\s*/);
|
|
10
|
+
if (!startMatch) return '';
|
|
11
|
+
|
|
12
|
+
const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
|
|
13
|
+
const rest = content.slice(startIdx);
|
|
14
|
+
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let endIdx = -1;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < rest.length; i++) {
|
|
19
|
+
if (rest[i] === '`') {
|
|
20
|
+
depth = depth === 0 ? 1 : 0;
|
|
21
|
+
}
|
|
22
|
+
if (depth === 0 && rest[i] === ';') {
|
|
23
|
+
endIdx = i;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (endIdx === -1) return '';
|
|
29
|
+
|
|
30
|
+
const raw = rest.slice(0, endIdx);
|
|
31
|
+
const parts = raw.split(/`\s*\+\s*\n?\s*`/);
|
|
32
|
+
return parts.map((p) => p.replace(/^\s*`|`\s*$/g, '')).join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadRecipes(): Promise<RecipeEntry[]> {
|
|
36
|
+
if (cachedRecipes) return cachedRecipes;
|
|
37
|
+
|
|
38
|
+
const recipeDir = getRecipeDir();
|
|
39
|
+
const entries: RecipeEntry[] = [];
|
|
40
|
+
|
|
41
|
+
let dirs: string[];
|
|
42
|
+
try {
|
|
43
|
+
dirs = await readdir(recipeDir);
|
|
44
|
+
} catch {
|
|
45
|
+
cachedRecipes = [];
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const dirName of dirs) {
|
|
50
|
+
const dirPath = resolve(recipeDir, dirName);
|
|
51
|
+
const dirStat = await stat(dirPath).catch(() => null);
|
|
52
|
+
if (!dirStat?.isDirectory()) continue;
|
|
53
|
+
|
|
54
|
+
// Read structured metadata from meta.ts
|
|
55
|
+
const metaPath = resolve(dirPath, 'meta.ts');
|
|
56
|
+
let meta: {
|
|
57
|
+
title: string;
|
|
58
|
+
description: string;
|
|
59
|
+
components: string[];
|
|
60
|
+
features: string[];
|
|
61
|
+
pattern: string;
|
|
62
|
+
};
|
|
63
|
+
try {
|
|
64
|
+
const metaContent = await readFile(metaPath, 'utf-8');
|
|
65
|
+
meta = parseRecipeMeta(metaContent);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Read recipeCode from +page.svelte (still embedded there for the live preview)
|
|
71
|
+
const pagePath = resolve(dirPath, '+page.svelte');
|
|
72
|
+
let code = '';
|
|
73
|
+
try {
|
|
74
|
+
const pageContent = await readFile(pagePath, 'utf-8');
|
|
75
|
+
code = extractRecipeCode(pageContent);
|
|
76
|
+
} catch {
|
|
77
|
+
// No page = no code, but metadata is still valid
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
entries.push({
|
|
81
|
+
id: dirName,
|
|
82
|
+
title: meta.title,
|
|
83
|
+
description: meta.description,
|
|
84
|
+
components: meta.components,
|
|
85
|
+
code,
|
|
86
|
+
features: meta.features,
|
|
87
|
+
pattern: meta.pattern || undefined
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
cachedRecipes = entries;
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getRecipeById(id: string): Promise<RecipeEntry | null> {
|
|
96
|
+
const recipes = await loadRecipes();
|
|
97
|
+
return recipes.find((r) => r.id === id) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRecipeMeta(content: string): {
|
|
101
|
+
title: string;
|
|
102
|
+
description: string;
|
|
103
|
+
components: string[];
|
|
104
|
+
features: string[];
|
|
105
|
+
pattern: string;
|
|
106
|
+
} {
|
|
107
|
+
const extractString = (key: string): string => {
|
|
108
|
+
const match = content.match(new RegExp(`${key}:\\s*['"]([^'"]*?)['"]`));
|
|
109
|
+
if (match?.[1]) return match[1];
|
|
110
|
+
// Multi-line string with concatenation
|
|
111
|
+
const multiMatch = content.match(new RegExp(`${key}:\\s*\\n?\\s*['"]([\\s\\S]*?)['"]`, 'm'));
|
|
112
|
+
return multiMatch?.[1]?.replace(/'\s*\+\s*\n?\s*'/g, '') ?? '';
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const extractArray = (key: string): string[] => {
|
|
116
|
+
const match = content.match(new RegExp(`${key}:\\s*\\[([^\\]]+)\\]`, 's'));
|
|
117
|
+
if (!match?.[1]) return [];
|
|
118
|
+
return match[1]
|
|
119
|
+
.split(',')
|
|
120
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
121
|
+
.filter((s) => s.length > 0);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
title: extractString('title'),
|
|
126
|
+
description: extractString('description'),
|
|
127
|
+
components: extractArray('components'),
|
|
128
|
+
features: extractArray('features'),
|
|
129
|
+
pattern: extractString('pattern')
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { getTemplatePath } from '../utils/paths.js';
|
|
3
|
+
|
|
4
|
+
export interface TemplateSections {
|
|
5
|
+
'api-grammar': string;
|
|
6
|
+
'component-families': string;
|
|
7
|
+
tokens: string;
|
|
8
|
+
'design-quality': string;
|
|
9
|
+
customization: string;
|
|
10
|
+
'style-patterns': string;
|
|
11
|
+
'auth-setup': string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let cachedSections: TemplateSections | null = null;
|
|
15
|
+
|
|
16
|
+
function extractSection(lines: string[], startHeading: string, endMarker: string): string {
|
|
17
|
+
const startIdx = lines.findIndex((l) => l.trim().startsWith(startHeading));
|
|
18
|
+
if (startIdx === -1) return '';
|
|
19
|
+
|
|
20
|
+
let endIdx = lines.length;
|
|
21
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
22
|
+
if (lines[i]?.trim() === endMarker) {
|
|
23
|
+
endIdx = i;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return lines.slice(startIdx, endIdx).join('\n').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadTemplateSections(): Promise<TemplateSections> {
|
|
32
|
+
if (cachedSections) return cachedSections;
|
|
33
|
+
|
|
34
|
+
const templatePath = getTemplatePath();
|
|
35
|
+
const raw = await readFile(templatePath, 'utf-8');
|
|
36
|
+
const lines = raw.split('\n');
|
|
37
|
+
|
|
38
|
+
cachedSections = {
|
|
39
|
+
'api-grammar': extractSection(lines, '## Shared API Grammar', '---'),
|
|
40
|
+
'component-families': extractSection(lines, '## Component Families', '---'),
|
|
41
|
+
tokens: extractSection(lines, '## Design Token System', '---'),
|
|
42
|
+
'design-quality': extractSection(lines, '## Design Quality', '---'),
|
|
43
|
+
customization: extractSection(lines, '## Customization', '---'),
|
|
44
|
+
'style-patterns': extractSection(lines, '## Style Patterns', '---'),
|
|
45
|
+
// Auth Setup is the last section and contains no `---` rules of its own, so
|
|
46
|
+
// it extracts cleanly to EOF. Its `###` stage sub-headings carry the staging.
|
|
47
|
+
'auth-setup': extractSection(lines, '## Auth Setup', '---')
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return cachedSections;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getCachedSections(): TemplateSections | null {
|
|
54
|
+
return cachedSections;
|
|
55
|
+
}
|