@webspire/mcp 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/data/registry.json +24522 -0
- package/dist/handlers.d.ts +4 -0
- package/dist/handlers.js +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +20 -0
- package/dist/registration.d.ts +6 -0
- package/dist/registration.js +471 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +60 -0
- package/dist/search.d.ts +12 -0
- package/dist/search.js +65 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +151 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.js +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { type RegistryOptions } from './registry.js';
|
|
3
|
+
export declare function registerTools(server: McpServer, registryOptions?: RegistryOptions): void;
|
|
4
|
+
export declare function registerResources(server: McpServer, registryOptions?: RegistryOptions): void;
|
package/dist/handlers.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerResourcesWithProvider, registerToolsWithProvider } from './registration.js';
|
|
2
|
+
import { loadRegistry } from './registry.js';
|
|
3
|
+
export function registerTools(server, registryOptions) {
|
|
4
|
+
registerToolsWithProvider(server, () => loadRegistry(registryOptions));
|
|
5
|
+
}
|
|
6
|
+
export function registerResources(server, registryOptions) {
|
|
7
|
+
registerResourcesWithProvider(server, () => loadRegistry(registryOptions));
|
|
8
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { registerResources, registerTools } from './handlers.js';
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const urlIdx = args.indexOf('--registry-url');
|
|
7
|
+
const fileIdx = args.indexOf('--registry-file');
|
|
8
|
+
const registryOptions = {
|
|
9
|
+
url: urlIdx !== -1 ? args[urlIdx + 1] : undefined,
|
|
10
|
+
filePath: fileIdx !== -1 ? args[fileIdx + 1] : undefined,
|
|
11
|
+
};
|
|
12
|
+
const server = new McpServer({
|
|
13
|
+
name: 'webspire',
|
|
14
|
+
version: '0.1.0',
|
|
15
|
+
});
|
|
16
|
+
registerTools(server, registryOptions);
|
|
17
|
+
registerResources(server, registryOptions);
|
|
18
|
+
const transport = new StdioServerTransport();
|
|
19
|
+
await server.connect(transport);
|
|
20
|
+
console.error('[webspire-mcp] Server running on stdio');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { Registry } from './types.js';
|
|
3
|
+
type RegistryProvider = () => Promise<Registry>;
|
|
4
|
+
export declare function registerToolsWithProvider(server: McpServer, getRegistry: RegistryProvider): void;
|
|
5
|
+
export declare function registerResourcesWithProvider(server: McpServer, getRegistry: RegistryProvider): void;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchSnippets } from './registry.js';
|
|
3
|
+
function formatSnippetBrief(s) {
|
|
4
|
+
return [
|
|
5
|
+
`**${s.title}** (${s.id})`,
|
|
6
|
+
` ${s.description}`,
|
|
7
|
+
` Category: ${s.category} | Tags: ${s.tags.join(', ')}`,
|
|
8
|
+
s.darkMode ? ' Dark mode: yes' : '',
|
|
9
|
+
s.responsive ? ' Responsive: yes' : '',
|
|
10
|
+
s.meta.useCases.length > 0 ? ` Use cases: ${s.meta.useCases.slice(0, 2).join('; ')}` : '',
|
|
11
|
+
]
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join('\n');
|
|
14
|
+
}
|
|
15
|
+
function formatAccessibility(s) {
|
|
16
|
+
const a11y = s.meta.accessibility;
|
|
17
|
+
const features = [];
|
|
18
|
+
if (a11y.prefersReducedMotion)
|
|
19
|
+
features.push('prefers-reduced-motion');
|
|
20
|
+
if (a11y.prefersColorScheme)
|
|
21
|
+
features.push('prefers-color-scheme');
|
|
22
|
+
if (a11y.supportsCheck)
|
|
23
|
+
features.push('@supports fallback');
|
|
24
|
+
if (a11y.ariaNotes?.length)
|
|
25
|
+
features.push(...a11y.ariaNotes);
|
|
26
|
+
return features.length > 0 ? `## Accessibility\nRespects: ${features.join(', ')}` : '';
|
|
27
|
+
}
|
|
28
|
+
function formatSnippetFull(s) {
|
|
29
|
+
const props = s.meta.customProperties
|
|
30
|
+
.map((p) => ` ${p}: ${s.meta.defaultValues?.[p] ?? 'unset'}`)
|
|
31
|
+
.join('\n');
|
|
32
|
+
return [
|
|
33
|
+
`# ${s.title}`,
|
|
34
|
+
'',
|
|
35
|
+
s.description,
|
|
36
|
+
'',
|
|
37
|
+
'## Usage',
|
|
38
|
+
'```html',
|
|
39
|
+
s.meta.usageExample,
|
|
40
|
+
'```',
|
|
41
|
+
'',
|
|
42
|
+
'## CSS',
|
|
43
|
+
'```css',
|
|
44
|
+
s.css,
|
|
45
|
+
'```',
|
|
46
|
+
'',
|
|
47
|
+
s.meta.customProperties.length > 0 ? `## Custom Properties\n${props}` : '',
|
|
48
|
+
s.meta.classes.length > 0 ? `## Classes\n${s.meta.classes.join(', ')}` : '',
|
|
49
|
+
formatAccessibility(s),
|
|
50
|
+
s.dependencies?.length ? `## Dependencies\n${s.dependencies.join(', ')}` : '',
|
|
51
|
+
s.variants?.length ? `## Variants\n${s.variants.join(', ')}` : '',
|
|
52
|
+
'',
|
|
53
|
+
`Browser support: ${s.meta.browser}`,
|
|
54
|
+
`Size: ${s.meta.lines} lines, ${s.meta.bytes} bytes`,
|
|
55
|
+
s.darkMode ? 'Dark mode: supported' : '',
|
|
56
|
+
s.responsive ? 'Responsive: yes' : '',
|
|
57
|
+
]
|
|
58
|
+
.filter((line) => line !== '')
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
function formatPatternBrief(p) {
|
|
62
|
+
return [
|
|
63
|
+
`**${p.title}** (${p.id})`,
|
|
64
|
+
` ${p.summary}`,
|
|
65
|
+
` Family: ${p.family} | Tier: ${p.tier} | Kind: ${p.kind}`,
|
|
66
|
+
p.tags.length > 0 ? ` Tags: ${p.tags.join(', ')}` : '',
|
|
67
|
+
p.extends ? ` Extends: ${p.extends}` : '',
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
function formatPatternFull(p) {
|
|
73
|
+
return [
|
|
74
|
+
`# ${p.title}`,
|
|
75
|
+
'',
|
|
76
|
+
p.description ?? p.summary,
|
|
77
|
+
'',
|
|
78
|
+
'## Identity',
|
|
79
|
+
`ID: ${p.id}`,
|
|
80
|
+
`Family: ${p.family}`,
|
|
81
|
+
`Tier: ${p.tier}`,
|
|
82
|
+
`Kind: ${p.kind}`,
|
|
83
|
+
p.extends ? `Extends: ${p.extends}` : '',
|
|
84
|
+
'',
|
|
85
|
+
p.tags.length > 0 ? `## Tags\n${p.tags.join(', ')}` : '',
|
|
86
|
+
p.slots.length > 0
|
|
87
|
+
? `## Slots\n${p.slots.map((s) => `- ${s.name}${s.required ? ' (required)' : ''}: ${s.description}`).join('\n')}`
|
|
88
|
+
: '',
|
|
89
|
+
p.props.length > 0
|
|
90
|
+
? `## Props\n${p.props.map((prop) => `- ${prop.name} (${prop.type}) default=${String(prop.default)}`).join('\n')}`
|
|
91
|
+
: '',
|
|
92
|
+
'## HTML',
|
|
93
|
+
'```html',
|
|
94
|
+
p.html,
|
|
95
|
+
'```',
|
|
96
|
+
p.css ? `## CSS\n\`\`\`css\n${p.css}\n\`\`\`` : '',
|
|
97
|
+
p.js ? `## JS\n\`\`\`js\n${p.js}\n\`\`\`` : '',
|
|
98
|
+
]
|
|
99
|
+
.filter((line) => line !== '')
|
|
100
|
+
.join('\n');
|
|
101
|
+
}
|
|
102
|
+
export function registerToolsWithProvider(server, getRegistry) {
|
|
103
|
+
server.tool('list_categories', 'List all available snippet categories with snippet counts', {}, async () => {
|
|
104
|
+
const registry = await getRegistry();
|
|
105
|
+
const counts = {};
|
|
106
|
+
for (const snippet of registry.snippets) {
|
|
107
|
+
counts[snippet.category] = (counts[snippet.category] ?? 0) + 1;
|
|
108
|
+
}
|
|
109
|
+
const result = Object.entries(counts)
|
|
110
|
+
.sort(([, a], [, b]) => b - a)
|
|
111
|
+
.map(([cat, count]) => `${cat}: ${count} snippet${count > 1 ? 's' : ''}`)
|
|
112
|
+
.join('\n');
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{
|
|
116
|
+
type: 'text',
|
|
117
|
+
text: `${registry.snippets.length} snippets across ${Object.keys(counts).length} categories:\n\n${result}`,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
server.tool('search_snippets', 'Search Webspire CSS snippets by keyword, problem description, or use case. Returns matching snippets with their descriptions and IDs.', {
|
|
123
|
+
query: z
|
|
124
|
+
.string()
|
|
125
|
+
.describe('Search query: problem description, use case, or CSS technique (e.g. "glass card", "fade animation", "blur entrance")'),
|
|
126
|
+
category: z
|
|
127
|
+
.string()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe('Filter by category: glass, animations, easing, scroll, decorative, interactions, text'),
|
|
130
|
+
tags: z.array(z.string()).optional().describe('Filter by tags'),
|
|
131
|
+
darkMode: z.boolean().optional().describe('Filter for dark mode support'),
|
|
132
|
+
responsive: z.boolean().optional().describe('Filter for responsive support'),
|
|
133
|
+
}, async ({ query, category, tags, darkMode, responsive }) => {
|
|
134
|
+
const registry = await getRegistry();
|
|
135
|
+
const results = searchSnippets(registry, {
|
|
136
|
+
query,
|
|
137
|
+
category,
|
|
138
|
+
tags,
|
|
139
|
+
darkMode,
|
|
140
|
+
responsive,
|
|
141
|
+
});
|
|
142
|
+
if (results.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
content: [
|
|
145
|
+
{
|
|
146
|
+
type: 'text',
|
|
147
|
+
text: 'No snippets found matching your query. Try broader keywords or check available categories with list_categories.',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const text = results.map(formatSnippetBrief).join('\n\n');
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'text',
|
|
157
|
+
text: `Found ${results.length} snippet${results.length > 1 ? 's' : ''}:\n\n${text}`,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
server.tool('get_snippet', 'Get full CSS source, metadata, usage example, and custom properties for a specific snippet. Use this after finding a snippet via search_snippets or recommend_snippet.', {
|
|
163
|
+
id: z
|
|
164
|
+
.string()
|
|
165
|
+
.describe('Snippet ID, e.g. "glass/frosted", "animations/fade-in", "easing/tokens"'),
|
|
166
|
+
}, async ({ id }) => {
|
|
167
|
+
const registry = await getRegistry();
|
|
168
|
+
const snippet = registry.snippets.find((s) => s.id === id);
|
|
169
|
+
if (!snippet) {
|
|
170
|
+
const available = registry.snippets.map((s) => s.id).join(', ');
|
|
171
|
+
return {
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: 'text',
|
|
175
|
+
text: `Snippet "${id}" not found. Available snippets: ${available}`,
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
content: [{ type: 'text', text: formatSnippetFull(snippet) }],
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
server.tool('recommend_snippet', 'Describe a UI problem or desired effect and get the best matching Webspire snippets with reasoning. Supports optional constraints like dark mode or reduced motion requirements.', {
|
|
185
|
+
use_case: z
|
|
186
|
+
.string()
|
|
187
|
+
.describe('Describe the UI problem or effect you need (e.g. "I need a blurred card overlay on my hero section", "my list items should animate in one by one")'),
|
|
188
|
+
category: z.string().optional().describe('Limit recommendations to a specific category'),
|
|
189
|
+
needs_dark_mode: z
|
|
190
|
+
.boolean()
|
|
191
|
+
.optional()
|
|
192
|
+
.describe('Only recommend snippets with dark mode support'),
|
|
193
|
+
needs_responsive: z
|
|
194
|
+
.boolean()
|
|
195
|
+
.optional()
|
|
196
|
+
.describe('Only recommend snippets with responsive support'),
|
|
197
|
+
needs_reduced_motion: z
|
|
198
|
+
.boolean()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe('Only recommend snippets that respect prefers-reduced-motion'),
|
|
201
|
+
}, async ({ use_case, category, needs_dark_mode, needs_responsive, needs_reduced_motion }) => {
|
|
202
|
+
const registry = await getRegistry();
|
|
203
|
+
let filteredRegistry = registry;
|
|
204
|
+
if (needs_reduced_motion) {
|
|
205
|
+
filteredRegistry = {
|
|
206
|
+
...registry,
|
|
207
|
+
snippets: registry.snippets.filter((s) => s.meta.accessibility.prefersReducedMotion),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const results = searchSnippets(filteredRegistry, {
|
|
211
|
+
query: use_case,
|
|
212
|
+
category,
|
|
213
|
+
darkMode: needs_dark_mode ? true : undefined,
|
|
214
|
+
responsive: needs_responsive ? true : undefined,
|
|
215
|
+
limit: 5,
|
|
216
|
+
});
|
|
217
|
+
if (results.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
content: [
|
|
220
|
+
{
|
|
221
|
+
type: 'text',
|
|
222
|
+
text: 'No matching snippet found for your description. Try rephrasing or relax the constraints.',
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const text = results
|
|
228
|
+
.map((s, i) => {
|
|
229
|
+
const reasons = [];
|
|
230
|
+
if (s.darkMode)
|
|
231
|
+
reasons.push('dark mode');
|
|
232
|
+
if (s.meta.accessibility.prefersReducedMotion)
|
|
233
|
+
reasons.push('reduced motion');
|
|
234
|
+
if (s.meta.accessibility.supportsCheck)
|
|
235
|
+
reasons.push('@supports fallback');
|
|
236
|
+
const reasonStr = reasons.length > 0 ? ` (${reasons.join(', ')})` : '';
|
|
237
|
+
return `${i + 1}. ${formatSnippetBrief(s)}${reasonStr}\n Usage: \`${s.meta.usageExample}\``;
|
|
238
|
+
})
|
|
239
|
+
.join('\n\n');
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: 'text',
|
|
244
|
+
text: `Top ${results.length} recommendation${results.length > 1 ? 's' : ''} for "${use_case}":\n\n${text}\n\nUse get_snippet(id) for full CSS and details.`,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
server.tool('list_pattern_families', 'List all available pattern families with counts', {}, async () => {
|
|
250
|
+
const registry = await getRegistry();
|
|
251
|
+
const patterns = registry.patterns ?? [];
|
|
252
|
+
const counts = {};
|
|
253
|
+
for (const pattern of patterns) {
|
|
254
|
+
counts[pattern.family] = (counts[pattern.family] ?? 0) + 1;
|
|
255
|
+
}
|
|
256
|
+
const result = Object.entries(counts)
|
|
257
|
+
.sort(([, a], [, b]) => b - a)
|
|
258
|
+
.map(([family, count]) => `${family}: ${count} pattern${count > 1 ? 's' : ''}`)
|
|
259
|
+
.join('\n');
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
{
|
|
263
|
+
type: 'text',
|
|
264
|
+
text: `${patterns.length} patterns across ${Object.keys(counts).length} families:\n\n${result}`,
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
server.tool('search_patterns', 'Search Webspire UI patterns by intent, family, and tier.', {
|
|
270
|
+
query: z.string().describe('Search query, e.g. "hero with image", "pricing section"'),
|
|
271
|
+
family: z
|
|
272
|
+
.string()
|
|
273
|
+
.optional()
|
|
274
|
+
.describe('Filter by pattern family, e.g. hero, pricing, navbar'),
|
|
275
|
+
tier: z.enum(['base', 'enhanced']).optional().describe('Filter by tier'),
|
|
276
|
+
}, async ({ query, family, tier }) => {
|
|
277
|
+
const registry = await getRegistry();
|
|
278
|
+
let patterns = registry.patterns ?? [];
|
|
279
|
+
if (family) {
|
|
280
|
+
patterns = patterns.filter((p) => p.family === family);
|
|
281
|
+
}
|
|
282
|
+
if (tier) {
|
|
283
|
+
patterns = patterns.filter((p) => p.tier === tier);
|
|
284
|
+
}
|
|
285
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
286
|
+
const results = patterns
|
|
287
|
+
.map((p) => {
|
|
288
|
+
const haystack = [
|
|
289
|
+
p.title,
|
|
290
|
+
p.summary,
|
|
291
|
+
p.description ?? '',
|
|
292
|
+
p.family,
|
|
293
|
+
p.kind,
|
|
294
|
+
...p.tags,
|
|
295
|
+
...p.search.intent,
|
|
296
|
+
...p.search.keywords,
|
|
297
|
+
...p.search.useCases,
|
|
298
|
+
]
|
|
299
|
+
.join(' ')
|
|
300
|
+
.toLowerCase();
|
|
301
|
+
const score = terms.reduce((acc, t) => acc + (haystack.includes(t) ? 1 : 0), 0);
|
|
302
|
+
return { pattern: p, score };
|
|
303
|
+
})
|
|
304
|
+
.filter((row) => row.score > 0)
|
|
305
|
+
.sort((a, b) => b.score - a.score)
|
|
306
|
+
.slice(0, 10)
|
|
307
|
+
.map((row) => row.pattern);
|
|
308
|
+
if (results.length === 0) {
|
|
309
|
+
return {
|
|
310
|
+
content: [
|
|
311
|
+
{
|
|
312
|
+
type: 'text',
|
|
313
|
+
text: 'No patterns found matching your query.',
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
content: [
|
|
320
|
+
{
|
|
321
|
+
type: 'text',
|
|
322
|
+
text: `Found ${results.length} pattern${results.length > 1 ? 's' : ''}:\n\n${results.map(formatPatternBrief).join('\n\n')}`,
|
|
323
|
+
},
|
|
324
|
+
],
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
server.tool('get_pattern', 'Get full pattern data including HTML (and optional CSS/JS) for a specific pattern.', {
|
|
328
|
+
id: z.string().describe('Pattern ID, e.g. "hero/base", "hero/with-image"'),
|
|
329
|
+
}, async ({ id }) => {
|
|
330
|
+
const registry = await getRegistry();
|
|
331
|
+
const pattern = (registry.patterns ?? []).find((p) => p.id === id);
|
|
332
|
+
if (!pattern) {
|
|
333
|
+
const available = (registry.patterns ?? []).map((p) => p.id).join(', ');
|
|
334
|
+
return {
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: 'text',
|
|
338
|
+
text: `Pattern "${id}" not found. Available patterns: ${available}`,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: 'text', text: formatPatternFull(pattern) }],
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
export function registerResourcesWithProvider(server, getRegistry) {
|
|
349
|
+
server.resource('categories', 'webspire://categories', {
|
|
350
|
+
description: 'List all snippet categories with counts',
|
|
351
|
+
mimeType: 'application/json',
|
|
352
|
+
}, async () => {
|
|
353
|
+
const registry = await getRegistry();
|
|
354
|
+
const counts = {};
|
|
355
|
+
for (const snippet of registry.snippets) {
|
|
356
|
+
counts[snippet.category] = (counts[snippet.category] ?? 0) + 1;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
contents: [
|
|
360
|
+
{
|
|
361
|
+
uri: 'webspire://categories',
|
|
362
|
+
mimeType: 'application/json',
|
|
363
|
+
text: JSON.stringify(counts, null, 2),
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
server.resource('category', 'webspire://category/{name}', {
|
|
369
|
+
description: 'List snippets in a specific category',
|
|
370
|
+
mimeType: 'application/json',
|
|
371
|
+
}, async (uri) => {
|
|
372
|
+
const name = uri.pathname.replace('//', '').split('/').pop();
|
|
373
|
+
const registry = await getRegistry();
|
|
374
|
+
const snippets = registry.snippets.filter((s) => s.category === name);
|
|
375
|
+
const brief = snippets.map((s) => ({
|
|
376
|
+
id: s.id,
|
|
377
|
+
title: s.title,
|
|
378
|
+
description: s.description,
|
|
379
|
+
tags: s.tags,
|
|
380
|
+
darkMode: s.darkMode,
|
|
381
|
+
responsive: s.responsive,
|
|
382
|
+
}));
|
|
383
|
+
return {
|
|
384
|
+
contents: [
|
|
385
|
+
{
|
|
386
|
+
uri: uri.href,
|
|
387
|
+
mimeType: 'application/json',
|
|
388
|
+
text: JSON.stringify(brief, null, 2),
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
server.resource('snippet', 'webspire://snippet/{id}', {
|
|
394
|
+
description: 'Get full snippet data including CSS source',
|
|
395
|
+
mimeType: 'application/json',
|
|
396
|
+
}, async (uri) => {
|
|
397
|
+
const parts = uri.pathname.replace('//', '').split('/');
|
|
398
|
+
const id = parts.slice(1).join('/');
|
|
399
|
+
const registry = await getRegistry();
|
|
400
|
+
const snippet = registry.snippets.find((s) => s.id === id);
|
|
401
|
+
if (!snippet) {
|
|
402
|
+
return {
|
|
403
|
+
contents: [
|
|
404
|
+
{
|
|
405
|
+
uri: uri.href,
|
|
406
|
+
mimeType: 'text/plain',
|
|
407
|
+
text: `Snippet "${id}" not found`,
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
contents: [
|
|
414
|
+
{
|
|
415
|
+
uri: uri.href,
|
|
416
|
+
mimeType: 'application/json',
|
|
417
|
+
text: JSON.stringify(snippet, null, 2),
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
server.resource('patterns', 'webspire://patterns', { description: 'List all patterns', mimeType: 'application/json' }, async () => {
|
|
423
|
+
const registry = await getRegistry();
|
|
424
|
+
const patterns = (registry.patterns ?? []).map((p) => ({
|
|
425
|
+
id: p.id,
|
|
426
|
+
title: p.title,
|
|
427
|
+
summary: p.summary,
|
|
428
|
+
family: p.family,
|
|
429
|
+
tier: p.tier,
|
|
430
|
+
kind: p.kind,
|
|
431
|
+
}));
|
|
432
|
+
return {
|
|
433
|
+
contents: [
|
|
434
|
+
{
|
|
435
|
+
uri: 'webspire://patterns',
|
|
436
|
+
mimeType: 'application/json',
|
|
437
|
+
text: JSON.stringify(patterns, null, 2),
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
});
|
|
442
|
+
server.resource('pattern', 'webspire://pattern/{id}', {
|
|
443
|
+
description: 'Get full pattern data including HTML source',
|
|
444
|
+
mimeType: 'application/json',
|
|
445
|
+
}, async (uri) => {
|
|
446
|
+
const parts = uri.pathname.replace('//', '').split('/');
|
|
447
|
+
const id = parts.slice(1).join('/');
|
|
448
|
+
const registry = await getRegistry();
|
|
449
|
+
const pattern = (registry.patterns ?? []).find((p) => p.id === id);
|
|
450
|
+
if (!pattern) {
|
|
451
|
+
return {
|
|
452
|
+
contents: [
|
|
453
|
+
{
|
|
454
|
+
uri: uri.href,
|
|
455
|
+
mimeType: 'text/plain',
|
|
456
|
+
text: `Pattern "${id}" not found`,
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
contents: [
|
|
463
|
+
{
|
|
464
|
+
uri: uri.href,
|
|
465
|
+
mimeType: 'application/json',
|
|
466
|
+
text: JSON.stringify(pattern, null, 2),
|
|
467
|
+
},
|
|
468
|
+
],
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SnippetSearchOptions } from './search.js';
|
|
2
|
+
import type { Registry, SnippetEntry } from './types.js';
|
|
3
|
+
export type { Registry, SnippetEntry };
|
|
4
|
+
export interface RegistryOptions {
|
|
5
|
+
url?: string;
|
|
6
|
+
filePath?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Load the registry with fallback chain:
|
|
10
|
+
* 1. Explicit --registry-file path
|
|
11
|
+
* 2. Bundled data/registry.json (shipped with npm package)
|
|
12
|
+
* 3. Remote fetch from --registry-url or default URL
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadRegistry(options?: RegistryOptions): Promise<Registry>;
|
|
15
|
+
export type SearchOptions = SnippetSearchOptions;
|
|
16
|
+
export declare function searchSnippets(registry: Registry, options: SearchOptions): SnippetEntry[];
|
|
17
|
+
export declare function suggestSnippets(registry: Registry, problem: string): SnippetEntry[];
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { searchSnippets as searchEntries } from './search.js';
|
|
6
|
+
const DEFAULT_REGISTRY_URL = 'https://webspire.de/registry.json';
|
|
7
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
let cache = null;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the path to the bundled registry.json shipped with this package.
|
|
11
|
+
* Works both from src/ (development) and dist/ (published).
|
|
12
|
+
*/
|
|
13
|
+
function getBundledRegistryPath() {
|
|
14
|
+
try {
|
|
15
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
// From dist/ → ../data/registry.json, from src/ → ../data/registry.json
|
|
17
|
+
const candidate = resolve(thisDir, '..', 'data', 'registry.json');
|
|
18
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Load the registry with fallback chain:
|
|
26
|
+
* 1. Explicit --registry-file path
|
|
27
|
+
* 2. Bundled data/registry.json (shipped with npm package)
|
|
28
|
+
* 3. Remote fetch from --registry-url or default URL
|
|
29
|
+
*/
|
|
30
|
+
export async function loadRegistry(options) {
|
|
31
|
+
// 1. Explicit file path
|
|
32
|
+
if (options?.filePath) {
|
|
33
|
+
const raw = await readFile(options.filePath, 'utf-8');
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
// 2. Bundled registry (no cache needed — file is local and fast)
|
|
37
|
+
const bundledPath = getBundledRegistryPath();
|
|
38
|
+
if (bundledPath && !options?.url) {
|
|
39
|
+
const raw = await readFile(bundledPath, 'utf-8');
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
// 3. Remote fetch with TTL cache
|
|
43
|
+
if (cache && Date.now() - cache.loadedAt < CACHE_TTL_MS) {
|
|
44
|
+
return cache.registry;
|
|
45
|
+
}
|
|
46
|
+
const url = options?.url ?? DEFAULT_REGISTRY_URL;
|
|
47
|
+
const response = await fetch(url);
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Failed to fetch registry from ${url}: ${response.statusText}`);
|
|
50
|
+
}
|
|
51
|
+
const registry = (await response.json());
|
|
52
|
+
cache = { registry, loadedAt: Date.now() };
|
|
53
|
+
return registry;
|
|
54
|
+
}
|
|
55
|
+
export function searchSnippets(registry, options) {
|
|
56
|
+
return searchEntries(registry.snippets, options);
|
|
57
|
+
}
|
|
58
|
+
export function suggestSnippets(registry, problem) {
|
|
59
|
+
return searchSnippets(registry, { query: problem, limit: 5 });
|
|
60
|
+
}
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SnippetEntry } from './types.js';
|
|
2
|
+
export interface SnippetSearchOptions {
|
|
3
|
+
query: string;
|
|
4
|
+
category?: string;
|
|
5
|
+
tags?: string[];
|
|
6
|
+
darkMode?: boolean;
|
|
7
|
+
responsive?: boolean;
|
|
8
|
+
limit?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function stem(word: string): string;
|
|
11
|
+
export declare function scoreSnippet(snippet: SnippetEntry, stems: string[]): number;
|
|
12
|
+
export declare function searchSnippets(snippets: SnippetEntry[], options: SnippetSearchOptions): SnippetEntry[];
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const STEM_SUFFIXES = [
|
|
2
|
+
'ions',
|
|
3
|
+
'ing',
|
|
4
|
+
'tion',
|
|
5
|
+
'ness',
|
|
6
|
+
'ment',
|
|
7
|
+
'able',
|
|
8
|
+
'ible',
|
|
9
|
+
'ous',
|
|
10
|
+
'ive',
|
|
11
|
+
'ed',
|
|
12
|
+
'es',
|
|
13
|
+
's',
|
|
14
|
+
];
|
|
15
|
+
export function stem(word) {
|
|
16
|
+
for (const suffix of STEM_SUFFIXES) {
|
|
17
|
+
if (word.length > suffix.length + 2 && word.endsWith(suffix)) {
|
|
18
|
+
return word.slice(0, -suffix.length);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return word;
|
|
22
|
+
}
|
|
23
|
+
export function scoreSnippet(snippet, stems) {
|
|
24
|
+
let score = 0;
|
|
25
|
+
for (const solve of snippet.meta.solves) {
|
|
26
|
+
const lower = solve.toLowerCase();
|
|
27
|
+
score += stems.filter((w) => lower.includes(w)).length * 3;
|
|
28
|
+
}
|
|
29
|
+
for (const useCase of snippet.meta.useCases) {
|
|
30
|
+
const lower = useCase.toLowerCase();
|
|
31
|
+
score += stems.filter((w) => lower.includes(w)).length * 2;
|
|
32
|
+
}
|
|
33
|
+
const desc = snippet.description.toLowerCase();
|
|
34
|
+
score += stems.filter((w) => desc.includes(w)).length;
|
|
35
|
+
const title = snippet.title.toLowerCase();
|
|
36
|
+
score += stems.filter((w) => title.includes(w)).length * 2;
|
|
37
|
+
score += stems.filter((w) => snippet.tags.some((t) => t.includes(w))).length;
|
|
38
|
+
const classes = snippet.meta.classes.join(' ').toLowerCase();
|
|
39
|
+
score += stems.filter((w) => classes.includes(w)).length;
|
|
40
|
+
return score;
|
|
41
|
+
}
|
|
42
|
+
export function searchSnippets(snippets, options) {
|
|
43
|
+
const { query, category, tags, darkMode, responsive, limit = 10 } = options;
|
|
44
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
45
|
+
const stems = words.map(stem);
|
|
46
|
+
if (stems.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const scored = snippets
|
|
50
|
+
.filter((snippet) => {
|
|
51
|
+
if (category && snippet.category !== category)
|
|
52
|
+
return false;
|
|
53
|
+
if (tags?.length && !tags.some((t) => snippet.tags.includes(t)))
|
|
54
|
+
return false;
|
|
55
|
+
if (darkMode !== undefined && snippet.darkMode !== darkMode)
|
|
56
|
+
return false;
|
|
57
|
+
if (responsive !== undefined && snippet.responsive !== responsive)
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
})
|
|
61
|
+
.map((snippet) => ({ snippet, score: scoreSnippet(snippet, stems) }))
|
|
62
|
+
.filter(({ score }) => score > 0)
|
|
63
|
+
.sort((a, b) => b.score - a.score);
|
|
64
|
+
return scored.slice(0, limit).map(({ snippet }) => snippet);
|
|
65
|
+
}
|
package/dist/tools.d.ts
ADDED