@ng-solid/mcp 22.0.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/GUIDE.md +292 -0
- package/README.md +159 -0
- package/data/design-system.json +24204 -0
- package/data/icons.json +1 -0
- package/data/skills/ns-solid-components/SKILL.md +140 -0
- package/data/skills/ns-solid-forms/SKILL.md +183 -0
- package/data/skills/ns-solid-icons/SKILL.md +95 -0
- package/data/skills/ns-solid-install/SKILL.md +120 -0
- package/data/skills/ns-solid-style-contract/SKILL.md +84 -0
- package/data/skills/ns-solid-theming/SKILL.md +123 -0
- package/index.d.ts +3 -0
- package/index.js +38 -0
- package/lib/data.d.ts +26 -0
- package/lib/data.js +51 -0
- package/lib/install-skills.d.ts +1 -0
- package/lib/install-skills.js +159 -0
- package/lib/preset-validator.d.ts +44 -0
- package/lib/preset-validator.js +172 -0
- package/lib/prompts.d.ts +2 -0
- package/lib/prompts.js +173 -0
- package/lib/resources.d.ts +2 -0
- package/lib/resources.js +328 -0
- package/lib/server.d.ts +3 -0
- package/lib/server.js +31 -0
- package/lib/style-contract.d.ts +37 -0
- package/lib/style-contract.js +167 -0
- package/lib/template-parser.d.ts +29 -0
- package/lib/template-parser.js +230 -0
- package/lib/tools.components.d.ts +44 -0
- package/lib/tools.components.js +836 -0
- package/lib/tools.d.ts +9 -0
- package/lib/tools.guides.d.ts +15 -0
- package/lib/tools.guides.js +103 -0
- package/lib/tools.icons.d.ts +7 -0
- package/lib/tools.icons.js +106 -0
- package/lib/tools.js +18 -0
- package/lib/tools.tokens.d.ts +7 -0
- package/lib/tools.tokens.js +278 -0
- package/lib/types.d.ts +349 -0
- package/lib/types.js +11 -0
- package/package.json +52 -0
- package/skills/ns-solid-components/SKILL.md +77 -0
- package/skills/ns-solid-forms/SKILL.md +166 -0
- package/skills/ns-solid-icons/SKILL.md +95 -0
- package/skills/ns-solid-install/SKILL.md +119 -0
- package/skills/ns-solid-style-contract/SKILL.md +84 -0
- package/skills/ns-solid-theming/SKILL.md +123 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery, API, form-control, service/provider and style-validation tools -- the bulk of
|
|
3
|
+
* the ng-solid MCP surface. Split out of `tools.ts` to keep that file a thin aggregator.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { jsonResult, loadSnapshot, textResult } from './data.js';
|
|
7
|
+
import { parseTemplateCandidates } from './template-parser.js';
|
|
8
|
+
import { validateStyleContract } from './style-contract.js';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Shared helpers -- exported for reuse by tools.tokens.ts, tools.icons.ts,
|
|
11
|
+
// tools.guides.ts, resources.ts and prompts.ts.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
export function jsonError(payload) {
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{ type: 'text', text: JSON.stringify(payload, null, 2) }
|
|
17
|
+
],
|
|
18
|
+
isError: true
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function importTrio(component) {
|
|
22
|
+
return {
|
|
23
|
+
selector: component.selector,
|
|
24
|
+
importStatement: `import { ${component.className} } from '${component.importFrom}';`,
|
|
25
|
+
imports: [component.className]
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Plain Levenshtein edit distance, used to rank "did you mean" suggestions. */
|
|
29
|
+
export function levenshtein(a, b) {
|
|
30
|
+
const rows = a.length + 1;
|
|
31
|
+
const cols = b.length + 1;
|
|
32
|
+
const distances = Array.from({ length: rows }, (_row, i) => {
|
|
33
|
+
const line = new Array(cols).fill(0);
|
|
34
|
+
line[0] = i;
|
|
35
|
+
return line;
|
|
36
|
+
});
|
|
37
|
+
for (let j = 0; j < cols; j++)
|
|
38
|
+
distances[0][j] = j;
|
|
39
|
+
for (let i = 1; i < rows; i++) {
|
|
40
|
+
for (let j = 1; j < cols; j++) {
|
|
41
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
42
|
+
distances[i][j] = Math.min(distances[i - 1][j] + 1, distances[i][j - 1] + 1, distances[i - 1][j - 1] + cost);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return distances[rows - 1][cols - 1];
|
|
46
|
+
}
|
|
47
|
+
export function suggestComponents(snapshot, query, limit = 5) {
|
|
48
|
+
const q = query
|
|
49
|
+
.trim()
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/^ns-/, '')
|
|
52
|
+
.replace(/^\[|\]$/g, '');
|
|
53
|
+
const scored = snapshot.components.map((c) => {
|
|
54
|
+
const bare = c.selector.replace(/^ns-/, '').replace(/^\[|\]$/g, '');
|
|
55
|
+
const distance = Math.min(levenshtein(q, bare), levenshtein(q, c.slug.toLowerCase()), levenshtein(q, c.className.toLowerCase()));
|
|
56
|
+
const substringBonus = bare.includes(q) || q.includes(bare) ? -2 : 0;
|
|
57
|
+
return { selector: c.selector, score: distance + substringBonus };
|
|
58
|
+
});
|
|
59
|
+
scored.sort((x, y) => x.score - y.score);
|
|
60
|
+
return scored.slice(0, limit).map((s) => s.selector);
|
|
61
|
+
}
|
|
62
|
+
function classNameToSelectorGuess(input) {
|
|
63
|
+
const match = /^Ns([A-Z][A-Za-z0-9]*)Component$/.exec(input.trim());
|
|
64
|
+
if (!match)
|
|
65
|
+
return null;
|
|
66
|
+
const kebab = match[1].replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
67
|
+
return `ns-${kebab}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolves a user-supplied name to a `ComponentDoc`. Accepts a selector (`ns-button`), a class
|
|
71
|
+
* name (`NsButtonComponent`), a slug (`button`) or an attribute-directive selector
|
|
72
|
+
* (`[nsTooltip]`), case-insensitively. Never returns a bare "not found" -- always attaches
|
|
73
|
+
* distance-ranked suggestions.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveComponent(snapshot, input) {
|
|
76
|
+
const trimmed = input.trim();
|
|
77
|
+
const lower = trimmed.toLowerCase();
|
|
78
|
+
const byExactSelector = snapshot.components.find((c) => c.selector === trimmed || c.selector === lower);
|
|
79
|
+
if (byExactSelector)
|
|
80
|
+
return { component: byExactSelector };
|
|
81
|
+
const byClassName = snapshot.components.find((c) => c.className.toLowerCase() === lower);
|
|
82
|
+
if (byClassName)
|
|
83
|
+
return { component: byClassName };
|
|
84
|
+
const bySlug = snapshot.components.find((c) => c.slug.toLowerCase() === lower);
|
|
85
|
+
if (bySlug)
|
|
86
|
+
return { component: bySlug };
|
|
87
|
+
const classNameGuess = classNameToSelectorGuess(trimmed);
|
|
88
|
+
if (classNameGuess) {
|
|
89
|
+
const found = snapshot.components.find((c) => c.selector === classNameGuess);
|
|
90
|
+
if (found)
|
|
91
|
+
return { component: found };
|
|
92
|
+
}
|
|
93
|
+
const bareGuess = lower.startsWith('ns-') || lower.startsWith('[') ? lower : `ns-${lower}`;
|
|
94
|
+
const byBareGuess = snapshot.components.find((c) => c.selector === bareGuess);
|
|
95
|
+
if (byBareGuess)
|
|
96
|
+
return { component: byBareGuess };
|
|
97
|
+
return {
|
|
98
|
+
component: null,
|
|
99
|
+
error: `No ng-solid component, directive or slug matches "${input}".`,
|
|
100
|
+
suggestions: suggestComponents(snapshot, trimmed)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** For an internal (non-exported) subcomponent, finds the public component that renders it. */
|
|
104
|
+
export function findPublicSiblingFor(snapshot, doc) {
|
|
105
|
+
const siblings = snapshot.components.filter((c) => c.visibility === 'public' && c.family === doc.family);
|
|
106
|
+
if (siblings.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
const exact = siblings.find((c) => c.selector === `ns-${doc.family}`);
|
|
109
|
+
if (exact)
|
|
110
|
+
return exact;
|
|
111
|
+
return [...siblings].sort((a, b) => a.selector.length - b.selector.length)[0];
|
|
112
|
+
}
|
|
113
|
+
function internalNotice(snapshot, doc) {
|
|
114
|
+
const publicSibling = findPublicSiblingFor(snapshot, doc);
|
|
115
|
+
return {
|
|
116
|
+
notice: `"${doc.selector}" is an internal subcomponent -- it is NOT exported from ` +
|
|
117
|
+
`@ng-solid/design-system's public barrel and cannot be used directly in a template. ` +
|
|
118
|
+
(publicSibling
|
|
119
|
+
? `It is rendered internally by "${publicSibling.selector}"; use that component instead.`
|
|
120
|
+
: `It belongs to the "${doc.family}" feature; look for its public entry component.`),
|
|
121
|
+
publicAlternative: publicSibling ? importTrio(publicSibling) : null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function searchText(c) {
|
|
125
|
+
return [
|
|
126
|
+
c.selector,
|
|
127
|
+
c.slug,
|
|
128
|
+
c.className,
|
|
129
|
+
c.category,
|
|
130
|
+
c.summary,
|
|
131
|
+
c.family,
|
|
132
|
+
...(c.variants ?? []),
|
|
133
|
+
...(c.pitfalls ?? [])
|
|
134
|
+
]
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.join(' ')
|
|
137
|
+
.toLowerCase();
|
|
138
|
+
}
|
|
139
|
+
/** Generic fuzzy-by-name resolver for services/providers (both keyed by a PascalCase `name`). */
|
|
140
|
+
function resolveByName(items, input, nameOf) {
|
|
141
|
+
const lower = input.trim().toLowerCase();
|
|
142
|
+
const normalize = (name) => name
|
|
143
|
+
.toLowerCase()
|
|
144
|
+
.replace(/^ns/, '')
|
|
145
|
+
.replace(/(service|component)$/, '');
|
|
146
|
+
const exact = items.find((item) => nameOf(item).toLowerCase() === lower);
|
|
147
|
+
if (exact)
|
|
148
|
+
return { item: exact };
|
|
149
|
+
const normalized = normalize(lower.startsWith('ns') ? lower : `ns${lower}`);
|
|
150
|
+
const byNormalized = items.find((item) => normalize(nameOf(item)) === normalized ||
|
|
151
|
+
normalize(nameOf(item)) === normalize(lower));
|
|
152
|
+
if (byNormalized)
|
|
153
|
+
return { item: byNormalized };
|
|
154
|
+
const scored = items
|
|
155
|
+
.map((item) => ({
|
|
156
|
+
item,
|
|
157
|
+
score: levenshtein(lower, nameOf(item).toLowerCase())
|
|
158
|
+
}))
|
|
159
|
+
.sort((a, b) => a.score - b.score)
|
|
160
|
+
.slice(0, 5)
|
|
161
|
+
.map(({ item }) => nameOf(item));
|
|
162
|
+
return { item: null, error: `No match for "${input}".`, suggestions: scored };
|
|
163
|
+
}
|
|
164
|
+
function placeholderValueFor(member) {
|
|
165
|
+
if (member.values && member.values.length > 0)
|
|
166
|
+
return member.values[0];
|
|
167
|
+
if (member.type.includes('number'))
|
|
168
|
+
return '0';
|
|
169
|
+
if (member.type === 'boolean')
|
|
170
|
+
return 'true';
|
|
171
|
+
return member.name;
|
|
172
|
+
}
|
|
173
|
+
/** Renders a single binding for a member, in the form an agent should actually write. */
|
|
174
|
+
function bindingFor(member) {
|
|
175
|
+
if (member.kind === 'model')
|
|
176
|
+
return `[(${member.bindingName})]="${member.name}"`;
|
|
177
|
+
if (member.acceptsAttribute && member.type === 'boolean')
|
|
178
|
+
return member.bindingName;
|
|
179
|
+
if (member.values && member.values.length > 0)
|
|
180
|
+
return `${member.bindingName}="${member.values[0]}"`;
|
|
181
|
+
if (member.type === 'string')
|
|
182
|
+
return `${member.bindingName}="${placeholderValueFor(member)}"`;
|
|
183
|
+
return `[${member.bindingName}]="${member.name}"`;
|
|
184
|
+
}
|
|
185
|
+
/** Builds a minimal-but-valid markup snippet for a component, honoring required inputs/models. */
|
|
186
|
+
export function buildTemplateSnippet(doc, options = {}) {
|
|
187
|
+
if (doc.snippet)
|
|
188
|
+
return doc.snippet;
|
|
189
|
+
const required = doc.inputs.filter((i) => i.required);
|
|
190
|
+
const optional = options.includeOptional
|
|
191
|
+
? doc.inputs.filter((i) => !i.required).slice(0, 3)
|
|
192
|
+
: [];
|
|
193
|
+
const bindings = [...required, ...optional, ...doc.models].map(bindingFor);
|
|
194
|
+
const attrs = bindings.length > 0 ? ` ${bindings.join(' ')}` : '';
|
|
195
|
+
const hasDefaultSlot = doc.slots.some((slot) => slot.name === 'default');
|
|
196
|
+
return hasDefaultSlot
|
|
197
|
+
? `<${doc.selector}${attrs}>Content</${doc.selector}>`
|
|
198
|
+
: `<${doc.selector}${attrs}></${doc.selector}>`;
|
|
199
|
+
}
|
|
200
|
+
function memberTable(members) {
|
|
201
|
+
if (members.length === 0)
|
|
202
|
+
return '_none_';
|
|
203
|
+
const rows = members.map((m) => {
|
|
204
|
+
const values = m.values ? m.values.map((v) => `\`${v}\``).join(', ') : '';
|
|
205
|
+
const inherited = m.inheritedFrom ? ` (from \`${m.inheritedFrom}\`)` : '';
|
|
206
|
+
return `| \`${m.bindingName}\` | \`${m.type}\` | ${m.required ? 'yes' : 'no'} | ${m.default ?? '-'} | ${values}${inherited} |`;
|
|
207
|
+
});
|
|
208
|
+
return [
|
|
209
|
+
'| Binding | Type | Required | Default | Values |',
|
|
210
|
+
'| --- | --- | --- | --- | --- |',
|
|
211
|
+
...rows
|
|
212
|
+
].join('\n');
|
|
213
|
+
}
|
|
214
|
+
/** Renders a full Markdown doc for a component -- shared by `export_component_docs` and the `ns://component/{selector}/markdown` resource. */
|
|
215
|
+
export function renderComponentMarkdown(snapshot, doc) {
|
|
216
|
+
const trio = importTrio(doc);
|
|
217
|
+
const lines = [];
|
|
218
|
+
lines.push(`# \`${doc.selector}\` (${doc.className})`);
|
|
219
|
+
if (doc.visibility === 'internal') {
|
|
220
|
+
const notice = internalNotice(snapshot, doc);
|
|
221
|
+
lines.push('', `> **Internal.** ${notice.notice}`);
|
|
222
|
+
}
|
|
223
|
+
if (doc.summary)
|
|
224
|
+
lines.push('', doc.summary);
|
|
225
|
+
lines.push('', '## Import', '', '```ts', trio.importStatement, `imports: [${trio.imports.join(', ')}]`, '```');
|
|
226
|
+
lines.push('', '## Usage', '', '```html', buildTemplateSnippet(doc), '```');
|
|
227
|
+
lines.push('', '## Inputs', '', memberTable(doc.inputs));
|
|
228
|
+
lines.push('', '## Outputs', '', memberTable(doc.outputs));
|
|
229
|
+
lines.push('', '## Models', '', memberTable(doc.models));
|
|
230
|
+
if (doc.slots.length > 0) {
|
|
231
|
+
lines.push('', '## Slots', '', ...doc.slots.map((s) => `- \`${s.name}\`${s.select ? ` (\`select: ${s.select}\`)` : ''}`));
|
|
232
|
+
}
|
|
233
|
+
if (doc.a11y.ariaAttributes.length > 0 || doc.a11y.roles.length > 0) {
|
|
234
|
+
lines.push('', '## Accessibility');
|
|
235
|
+
if (doc.a11y.roles.length > 0)
|
|
236
|
+
lines.push(`- roles: ${doc.a11y.roles.map((r) => `\`${r}\``).join(', ')}`);
|
|
237
|
+
if (doc.a11y.ariaAttributes.length > 0)
|
|
238
|
+
lines.push(`- aria attributes: ${doc.a11y.ariaAttributes.map((a) => `\`${a}\``).join(', ')}`);
|
|
239
|
+
}
|
|
240
|
+
if (doc.tokens.length > 0) {
|
|
241
|
+
lines.push('', `## Style tokens (${doc.tokens.length})`, '', doc.tokens.map((t) => `- \`${t}\``).join('\n'));
|
|
242
|
+
}
|
|
243
|
+
if (doc.pitfalls && doc.pitfalls.length > 0) {
|
|
244
|
+
lines.push('', '## Pitfalls', '', ...doc.pitfalls.map((p) => `- ${p}`));
|
|
245
|
+
}
|
|
246
|
+
if (doc.relatedComponents && doc.relatedComponents.length > 0) {
|
|
247
|
+
lines.push('', '## Related', '', doc.relatedComponents.map((r) => `- \`${r}\``).join('\n'));
|
|
248
|
+
}
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
}
|
|
251
|
+
const API_SECTIONS = [
|
|
252
|
+
'inputs',
|
|
253
|
+
'outputs',
|
|
254
|
+
'models',
|
|
255
|
+
'slots',
|
|
256
|
+
'classes',
|
|
257
|
+
'a11y'
|
|
258
|
+
];
|
|
259
|
+
function apiSections(doc) {
|
|
260
|
+
return {
|
|
261
|
+
inputs: doc.inputs,
|
|
262
|
+
outputs: doc.outputs,
|
|
263
|
+
models: doc.models,
|
|
264
|
+
slots: doc.slots,
|
|
265
|
+
classes: doc.bem,
|
|
266
|
+
a11y: doc.a11y
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Registration
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
export function registerComponentTools(server) {
|
|
273
|
+
// --- Discovery -----------------------------------------------------------
|
|
274
|
+
server.registerTool('list_components', {
|
|
275
|
+
title: 'List ng-solid components',
|
|
276
|
+
description: 'Lists ng-solid components/directives, optionally filtered by category, feature family or visibility. Defaults to public (exported) components only -- pass visibility: "all" to also see internal subcomponents.',
|
|
277
|
+
inputSchema: {
|
|
278
|
+
category: z
|
|
279
|
+
.string()
|
|
280
|
+
.optional()
|
|
281
|
+
.describe('Exact category name, e.g. "Formularios" (see get_categories).'),
|
|
282
|
+
family: z
|
|
283
|
+
.string()
|
|
284
|
+
.optional()
|
|
285
|
+
.describe('Feature directory, e.g. "input" or "modal" (see get_categories/list_components output).'),
|
|
286
|
+
visibility: z
|
|
287
|
+
.enum(['public', 'internal', 'all'])
|
|
288
|
+
.optional()
|
|
289
|
+
.describe('Defaults to "public".')
|
|
290
|
+
}
|
|
291
|
+
}, async ({ category, family, visibility }) => {
|
|
292
|
+
const snapshot = loadSnapshot();
|
|
293
|
+
const wanted = visibility ?? 'public';
|
|
294
|
+
const items = snapshot.components
|
|
295
|
+
.filter((c) => wanted === 'all' || c.visibility === wanted)
|
|
296
|
+
.filter((c) => !category || c.category === category)
|
|
297
|
+
.filter((c) => !family || c.family === family)
|
|
298
|
+
.map((c) => ({
|
|
299
|
+
selector: c.selector,
|
|
300
|
+
className: c.className,
|
|
301
|
+
slug: c.slug,
|
|
302
|
+
family: c.family,
|
|
303
|
+
group: c.group,
|
|
304
|
+
category: c.category ?? null,
|
|
305
|
+
summary: c.summary ?? null,
|
|
306
|
+
visibility: c.visibility
|
|
307
|
+
}));
|
|
308
|
+
return jsonResult({ count: items.length, components: items });
|
|
309
|
+
});
|
|
310
|
+
server.registerTool('get_component', {
|
|
311
|
+
title: 'Get a component doc',
|
|
312
|
+
description: 'Looks up one ng-solid component or directive by selector ("ns-button"), class name ("NsButtonComponent") or registry slug ("button") and returns its full documented API. The response always embeds the selector + importStatement + imports trio, since `<ns-button>` does not render without importing NsButtonComponent into a standalone component\'s `imports: [...]`. If the name resolves to an internal (non-exported) subcomponent, this returns an explanatory notice and the public component to use instead -- never a bare "not found". If nothing matches, returns ranked suggestions.',
|
|
313
|
+
inputSchema: {
|
|
314
|
+
component: z
|
|
315
|
+
.string()
|
|
316
|
+
.describe('Selector, class name or slug to resolve.')
|
|
317
|
+
}
|
|
318
|
+
}, async ({ component }) => {
|
|
319
|
+
const snapshot = loadSnapshot();
|
|
320
|
+
const resolution = resolveComponent(snapshot, component);
|
|
321
|
+
if (!resolution.component) {
|
|
322
|
+
return jsonError({
|
|
323
|
+
error: resolution.error,
|
|
324
|
+
suggestions: resolution.suggestions
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const doc = resolution.component;
|
|
328
|
+
if (doc.visibility === 'internal') {
|
|
329
|
+
return jsonResult({ ...internalNotice(snapshot, doc), component: doc });
|
|
330
|
+
}
|
|
331
|
+
return jsonResult({ ...importTrio(doc), component: doc });
|
|
332
|
+
});
|
|
333
|
+
server.registerTool('search_components', {
|
|
334
|
+
title: 'Search public components',
|
|
335
|
+
description: 'Full-text search over public component selectors, class names, categories, summaries, variants and known pitfalls.',
|
|
336
|
+
inputSchema: {
|
|
337
|
+
query: z.string().describe('Search term, e.g. "date" or "confirm".'),
|
|
338
|
+
limit: z.number().int().positive().max(50).optional()
|
|
339
|
+
}
|
|
340
|
+
}, async ({ query, limit }) => {
|
|
341
|
+
const snapshot = loadSnapshot();
|
|
342
|
+
const q = query.toLowerCase();
|
|
343
|
+
const matches = snapshot.components
|
|
344
|
+
.filter((c) => c.visibility === 'public')
|
|
345
|
+
.filter((c) => searchText(c).includes(q))
|
|
346
|
+
.slice(0, limit ?? 10)
|
|
347
|
+
.map((c) => ({
|
|
348
|
+
selector: c.selector,
|
|
349
|
+
className: c.className,
|
|
350
|
+
category: c.category ?? null,
|
|
351
|
+
summary: c.summary ?? null
|
|
352
|
+
}));
|
|
353
|
+
return jsonResult({ query, count: matches.length, components: matches });
|
|
354
|
+
});
|
|
355
|
+
server.registerTool('search_all', {
|
|
356
|
+
title: 'Search everything',
|
|
357
|
+
description: 'Broad search across public components, services, providers, guides and the icon catalog in one call.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
query: z.string(),
|
|
360
|
+
limit: z.number().int().positive().max(50).optional()
|
|
361
|
+
}
|
|
362
|
+
}, async ({ query, limit }) => {
|
|
363
|
+
const snapshot = loadSnapshot();
|
|
364
|
+
const n = limit ?? 5;
|
|
365
|
+
const q = query.toLowerCase();
|
|
366
|
+
const components = snapshot.components
|
|
367
|
+
.filter((c) => c.visibility === 'public' && searchText(c).includes(q))
|
|
368
|
+
.slice(0, n)
|
|
369
|
+
.map((c) => ({ selector: c.selector, summary: c.summary ?? null }));
|
|
370
|
+
const services = snapshot.services
|
|
371
|
+
.filter((s) => `${s.name} ${s.docs ?? ''}`.toLowerCase().includes(q))
|
|
372
|
+
.slice(0, n)
|
|
373
|
+
.map((s) => ({ name: s.name }));
|
|
374
|
+
const providers = snapshot.providers
|
|
375
|
+
.filter((p) => `${p.name} ${p.docs ?? ''}`.toLowerCase().includes(q))
|
|
376
|
+
.slice(0, n)
|
|
377
|
+
.map((p) => ({ name: p.name }));
|
|
378
|
+
const guides = snapshot.guides
|
|
379
|
+
.filter((g) => `${g.title} ${g.description ?? ''}`.toLowerCase().includes(q))
|
|
380
|
+
.slice(0, n)
|
|
381
|
+
.map((g) => ({ slug: g.slug }));
|
|
382
|
+
return jsonResult({ query, components, services, providers, guides });
|
|
383
|
+
});
|
|
384
|
+
server.registerTool('suggest_component', {
|
|
385
|
+
title: 'Suggest a component for an intent',
|
|
386
|
+
description: 'Given a natural-language description of a UI need ("confirm a destructive action", "pick a date range"), ranks public components by keyword overlap with their summary/category/variants. Returns the import trio for each suggestion.',
|
|
387
|
+
inputSchema: {
|
|
388
|
+
intent: z
|
|
389
|
+
.string()
|
|
390
|
+
.describe('What you are trying to build, in plain language.'),
|
|
391
|
+
limit: z.number().int().positive().max(10).optional()
|
|
392
|
+
}
|
|
393
|
+
}, async ({ intent, limit }) => {
|
|
394
|
+
const snapshot = loadSnapshot();
|
|
395
|
+
const words = intent
|
|
396
|
+
.toLowerCase()
|
|
397
|
+
.split(/\s+/)
|
|
398
|
+
.filter((w) => w.length > 2);
|
|
399
|
+
const scored = snapshot.components
|
|
400
|
+
.filter((c) => c.visibility === 'public')
|
|
401
|
+
.map((c) => {
|
|
402
|
+
const text = searchText(c);
|
|
403
|
+
const score = words.reduce((acc, w) => acc + (text.includes(w) ? 1 : 0), 0);
|
|
404
|
+
return { c, score };
|
|
405
|
+
})
|
|
406
|
+
.filter((x) => x.score > 0)
|
|
407
|
+
.sort((a, b) => b.score - a.score)
|
|
408
|
+
.slice(0, limit ?? 5);
|
|
409
|
+
if (scored.length === 0) {
|
|
410
|
+
return jsonResult({
|
|
411
|
+
intent,
|
|
412
|
+
suggestions: [],
|
|
413
|
+
note: 'No component keyword-matched this intent. Try search_components with a narrower term, or list_categories to browse.'
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return jsonResult({
|
|
417
|
+
intent,
|
|
418
|
+
suggestions: scored.map(({ c, score }) => ({
|
|
419
|
+
...importTrio(c),
|
|
420
|
+
summary: c.summary ?? null,
|
|
421
|
+
category: c.category ?? null,
|
|
422
|
+
score
|
|
423
|
+
}))
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
server.registerTool('get_related_components', {
|
|
427
|
+
title: 'Get related components',
|
|
428
|
+
description: 'Returns components related to the given one via parent/child composition, curated cross-references, and shared feature family.',
|
|
429
|
+
inputSchema: { component: z.string() }
|
|
430
|
+
}, async ({ component }) => {
|
|
431
|
+
const snapshot = loadSnapshot();
|
|
432
|
+
const resolution = resolveComponent(snapshot, component);
|
|
433
|
+
if (!resolution.component)
|
|
434
|
+
return jsonError({
|
|
435
|
+
error: resolution.error,
|
|
436
|
+
suggestions: resolution.suggestions
|
|
437
|
+
});
|
|
438
|
+
const doc = resolution.component;
|
|
439
|
+
const related = new Set();
|
|
440
|
+
if (doc.composition.parent)
|
|
441
|
+
related.add(doc.composition.parent);
|
|
442
|
+
for (const child of doc.composition.children)
|
|
443
|
+
related.add(child);
|
|
444
|
+
for (const rel of doc.relatedComponents ?? [])
|
|
445
|
+
related.add(rel);
|
|
446
|
+
for (const sibling of snapshot.components) {
|
|
447
|
+
if (sibling.selector !== doc.selector && sibling.family === doc.family)
|
|
448
|
+
related.add(sibling.selector);
|
|
449
|
+
}
|
|
450
|
+
related.delete(doc.selector);
|
|
451
|
+
const resolved = [...related]
|
|
452
|
+
.map((selector) => snapshot.components.find((c) => c.selector === selector))
|
|
453
|
+
.filter((c) => Boolean(c));
|
|
454
|
+
return jsonResult({
|
|
455
|
+
component: doc.selector,
|
|
456
|
+
related: resolved.map((c) => ({
|
|
457
|
+
selector: c.selector,
|
|
458
|
+
className: c.className,
|
|
459
|
+
visibility: c.visibility,
|
|
460
|
+
summary: c.summary ?? null
|
|
461
|
+
}))
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
server.registerTool('get_categories', {
|
|
465
|
+
title: 'List categories',
|
|
466
|
+
description: 'Lists the docs categories (Spanish, as used by the demo app) with a public-component count for each.',
|
|
467
|
+
inputSchema: {}
|
|
468
|
+
}, async () => {
|
|
469
|
+
const snapshot = loadSnapshot();
|
|
470
|
+
const counts = new Map();
|
|
471
|
+
for (const c of snapshot.components) {
|
|
472
|
+
if (c.visibility !== 'public' || !c.category)
|
|
473
|
+
continue;
|
|
474
|
+
counts.set(c.category, (counts.get(c.category) ?? 0) + 1);
|
|
475
|
+
}
|
|
476
|
+
return jsonResult(snapshot.categories.map((category) => ({
|
|
477
|
+
category,
|
|
478
|
+
componentCount: counts.get(category) ?? 0
|
|
479
|
+
})));
|
|
480
|
+
});
|
|
481
|
+
// --- API -------------------------------------------------------------
|
|
482
|
+
server.registerTool('get_component_api', {
|
|
483
|
+
title: 'Get a component API slice',
|
|
484
|
+
description: "Returns a component's inputs/outputs/models/slots/BEM classes/accessibility metadata. Pass `section` to fetch only one slice (cheaper on context than get_component); omit it to get all six. Always includes the selector + importStatement + imports trio.",
|
|
485
|
+
inputSchema: {
|
|
486
|
+
component: z.string(),
|
|
487
|
+
section: z
|
|
488
|
+
.enum(API_SECTIONS)
|
|
489
|
+
.optional()
|
|
490
|
+
.describe('Restrict the response to one section; omit for all.')
|
|
491
|
+
}
|
|
492
|
+
}, async ({ component, section }) => {
|
|
493
|
+
const snapshot = loadSnapshot();
|
|
494
|
+
const resolution = resolveComponent(snapshot, component);
|
|
495
|
+
if (!resolution.component)
|
|
496
|
+
return jsonError({
|
|
497
|
+
error: resolution.error,
|
|
498
|
+
suggestions: resolution.suggestions
|
|
499
|
+
});
|
|
500
|
+
const doc = resolution.component;
|
|
501
|
+
const sections = apiSections(doc);
|
|
502
|
+
const payload = section ? { [section]: sections[section] } : sections;
|
|
503
|
+
const notice = doc.visibility === 'internal' ? internalNotice(snapshot, doc) : {};
|
|
504
|
+
return jsonResult({ ...importTrio(doc), ...notice, ...payload });
|
|
505
|
+
});
|
|
506
|
+
server.registerTool('get_component_import', {
|
|
507
|
+
title: 'Get the import trio for a component',
|
|
508
|
+
description: 'Returns exactly the selector + importStatement + imports array a standalone Angular component needs to render this ng-solid component.',
|
|
509
|
+
inputSchema: { component: z.string() }
|
|
510
|
+
}, async ({ component }) => {
|
|
511
|
+
const snapshot = loadSnapshot();
|
|
512
|
+
const resolution = resolveComponent(snapshot, component);
|
|
513
|
+
if (!resolution.component)
|
|
514
|
+
return jsonError({
|
|
515
|
+
error: resolution.error,
|
|
516
|
+
suggestions: resolution.suggestions
|
|
517
|
+
});
|
|
518
|
+
const doc = resolution.component;
|
|
519
|
+
return jsonResult({
|
|
520
|
+
...importTrio(doc),
|
|
521
|
+
className: doc.className,
|
|
522
|
+
exportedAs: doc.exportedAs,
|
|
523
|
+
importFrom: doc.importFrom
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
server.registerTool('generate_component_template', {
|
|
527
|
+
title: 'Generate a markup template',
|
|
528
|
+
description: 'Generates a minimal-but-valid markup snippet for a component: every required input and model is bound with a placeholder of the correct shape (enum values use a real allowed value, never an invented one). Always includes the import trio.',
|
|
529
|
+
inputSchema: {
|
|
530
|
+
component: z.string(),
|
|
531
|
+
includeOptional: z
|
|
532
|
+
.boolean()
|
|
533
|
+
.optional()
|
|
534
|
+
.describe('Also bind up to 3 commonly-used optional inputs, not just required ones.')
|
|
535
|
+
}
|
|
536
|
+
}, async ({ component, includeOptional }) => {
|
|
537
|
+
const snapshot = loadSnapshot();
|
|
538
|
+
const resolution = resolveComponent(snapshot, component);
|
|
539
|
+
if (!resolution.component)
|
|
540
|
+
return jsonError({
|
|
541
|
+
error: resolution.error,
|
|
542
|
+
suggestions: resolution.suggestions
|
|
543
|
+
});
|
|
544
|
+
const doc = resolution.component;
|
|
545
|
+
const template = buildTemplateSnippet(doc, { includeOptional });
|
|
546
|
+
return jsonResult({
|
|
547
|
+
...importTrio(doc),
|
|
548
|
+
template,
|
|
549
|
+
requiredInputs: doc.inputs
|
|
550
|
+
.filter((i) => i.required)
|
|
551
|
+
.map((i) => i.bindingName),
|
|
552
|
+
requiredModels: doc.models.map((m) => m.bindingName)
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
server.registerTool('get_usage_example', {
|
|
556
|
+
title: 'Get a usage example',
|
|
557
|
+
description: 'Returns the canonical docs-registry snippet for a component when one exists, falling back to a generated minimal template. Always includes the import trio.',
|
|
558
|
+
inputSchema: { component: z.string() }
|
|
559
|
+
}, async ({ component }) => {
|
|
560
|
+
const snapshot = loadSnapshot();
|
|
561
|
+
const resolution = resolveComponent(snapshot, component);
|
|
562
|
+
if (!resolution.component)
|
|
563
|
+
return jsonError({
|
|
564
|
+
error: resolution.error,
|
|
565
|
+
suggestions: resolution.suggestions
|
|
566
|
+
});
|
|
567
|
+
const doc = resolution.component;
|
|
568
|
+
return jsonResult({
|
|
569
|
+
...importTrio(doc),
|
|
570
|
+
example: buildTemplateSnippet(doc),
|
|
571
|
+
fromRegistry: Boolean(doc.snippet)
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
server.registerTool('export_component_docs', {
|
|
575
|
+
title: 'Export full component documentation',
|
|
576
|
+
description: 'Exports the complete documentation for a component as Markdown (default, human/agent-readable) or as the raw JSON ComponentDoc.',
|
|
577
|
+
inputSchema: {
|
|
578
|
+
component: z.string(),
|
|
579
|
+
format: z.enum(['markdown', 'json']).optional()
|
|
580
|
+
}
|
|
581
|
+
}, async ({ component, format }) => {
|
|
582
|
+
const snapshot = loadSnapshot();
|
|
583
|
+
const resolution = resolveComponent(snapshot, component);
|
|
584
|
+
if (!resolution.component)
|
|
585
|
+
return jsonError({
|
|
586
|
+
error: resolution.error,
|
|
587
|
+
suggestions: resolution.suggestions
|
|
588
|
+
});
|
|
589
|
+
const doc = resolution.component;
|
|
590
|
+
if ((format ?? 'markdown') === 'json')
|
|
591
|
+
return jsonResult(doc);
|
|
592
|
+
return textResult(renderComponentMarkdown(snapshot, doc));
|
|
593
|
+
});
|
|
594
|
+
// --- Forms -------------------------------------------------------------
|
|
595
|
+
server.registerTool('get_form_control_contract', {
|
|
596
|
+
title: 'Get a form control contract',
|
|
597
|
+
description: 'For a component that implements ControlValueAccessor (works with formControlName / [(ngModel)]), returns its CVA contract: which inputs/outputs/models it inherits from the shared base class vs. declares itself.',
|
|
598
|
+
inputSchema: { component: z.string() }
|
|
599
|
+
}, async ({ component }) => {
|
|
600
|
+
const snapshot = loadSnapshot();
|
|
601
|
+
const resolution = resolveComponent(snapshot, component);
|
|
602
|
+
if (!resolution.component)
|
|
603
|
+
return jsonError({
|
|
604
|
+
error: resolution.error,
|
|
605
|
+
suggestions: resolution.suggestions
|
|
606
|
+
});
|
|
607
|
+
const doc = resolution.component;
|
|
608
|
+
if (!doc.formControl?.isValueAccessor) {
|
|
609
|
+
return jsonError({
|
|
610
|
+
error: `"${doc.selector}" is not a ControlValueAccessor (no formControl contract). Use get_component_api for its plain input/output API instead.`
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const baseClass = doc.formControl.baseClass;
|
|
614
|
+
const isInherited = (m) => Boolean(baseClass) && m.inheritedFrom === baseClass;
|
|
615
|
+
return jsonResult({
|
|
616
|
+
...importTrio(doc),
|
|
617
|
+
formControl: doc.formControl,
|
|
618
|
+
inherited: {
|
|
619
|
+
inputs: doc.inputs.filter(isInherited),
|
|
620
|
+
outputs: doc.outputs.filter(isInherited),
|
|
621
|
+
models: doc.models.filter(isInherited)
|
|
622
|
+
},
|
|
623
|
+
own: {
|
|
624
|
+
inputs: doc.inputs.filter((m) => !isInherited(m)),
|
|
625
|
+
outputs: doc.outputs.filter((m) => !isInherited(m)),
|
|
626
|
+
models: doc.models.filter((m) => !isInherited(m))
|
|
627
|
+
},
|
|
628
|
+
usage: {
|
|
629
|
+
reactiveForms: `<${doc.selector} formControlName="fieldName"></${doc.selector}>`,
|
|
630
|
+
modelBinding: `<${doc.selector} [(value)]="value"></${doc.selector}>`
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
server.registerTool('list_form_controls', {
|
|
635
|
+
title: 'List form controls',
|
|
636
|
+
description: 'Lists every public component that implements ControlValueAccessor (usable with formControlName / [(ngModel)]).',
|
|
637
|
+
inputSchema: {}
|
|
638
|
+
}, async () => {
|
|
639
|
+
const snapshot = loadSnapshot();
|
|
640
|
+
const controls = snapshot.components.filter((c) => c.visibility === 'public' && c.formControl?.isValueAccessor);
|
|
641
|
+
return jsonResult({
|
|
642
|
+
count: controls.length,
|
|
643
|
+
controls: controls.map((c) => ({
|
|
644
|
+
selector: c.selector,
|
|
645
|
+
className: c.className,
|
|
646
|
+
valueType: c.formControl?.valueType ?? null,
|
|
647
|
+
baseClass: c.formControl?.baseClass ?? null
|
|
648
|
+
}))
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
// --- Services & providers -------------------------------------------------
|
|
652
|
+
server.registerTool('list_services', {
|
|
653
|
+
title: 'List services',
|
|
654
|
+
description: 'Lists injectable ng-solid services (e.g. NsToastService, NsModalService) and the components each one manages.',
|
|
655
|
+
inputSchema: {}
|
|
656
|
+
}, async () => {
|
|
657
|
+
const snapshot = loadSnapshot();
|
|
658
|
+
return jsonResult(snapshot.services.map((s) => ({
|
|
659
|
+
name: s.name,
|
|
660
|
+
importFrom: s.importFrom,
|
|
661
|
+
providedIn: s.providedIn,
|
|
662
|
+
methodCount: s.methods.length,
|
|
663
|
+
relatedComponents: s.relatedComponents
|
|
664
|
+
})));
|
|
665
|
+
});
|
|
666
|
+
server.registerTool('get_service', {
|
|
667
|
+
title: 'Get a service',
|
|
668
|
+
description: 'Returns a service\'s full method signatures and an import statement, resolved fuzzily by name ("toast", "NsToastService", "ToastService" all work).',
|
|
669
|
+
inputSchema: { service: z.string() }
|
|
670
|
+
}, async ({ service }) => {
|
|
671
|
+
const snapshot = loadSnapshot();
|
|
672
|
+
const resolution = resolveByName(snapshot.services, service, (s) => s.name);
|
|
673
|
+
if (!resolution.item)
|
|
674
|
+
return jsonError({
|
|
675
|
+
error: resolution.error,
|
|
676
|
+
suggestions: resolution.suggestions
|
|
677
|
+
});
|
|
678
|
+
return jsonResult({
|
|
679
|
+
...resolution.item,
|
|
680
|
+
importStatement: `import { ${resolution.item.name} } from '${resolution.item.importFrom}';`
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
server.registerTool('get_provider', {
|
|
684
|
+
title: 'Get a function provider',
|
|
685
|
+
description: 'Returns a function provider (provideNsTheme, provideIcons, provideIconsPatch) signature, docs and an example call for `appConfig.providers`.',
|
|
686
|
+
inputSchema: { provider: z.string() }
|
|
687
|
+
}, async ({ provider }) => {
|
|
688
|
+
const snapshot = loadSnapshot();
|
|
689
|
+
const resolution = resolveByName(snapshot.providers, provider, (p) => p.name);
|
|
690
|
+
if (!resolution.item)
|
|
691
|
+
return jsonError({
|
|
692
|
+
error: resolution.error,
|
|
693
|
+
suggestions: resolution.suggestions
|
|
694
|
+
});
|
|
695
|
+
return jsonResult({
|
|
696
|
+
...resolution.item,
|
|
697
|
+
importStatement: `import { ${resolution.item.name} } from '${resolution.item.importFrom}';`
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
// --- Style contract & usage validation ------------------------------------
|
|
701
|
+
server.registerTool('get_style_contract', {
|
|
702
|
+
title: 'Get the style contract',
|
|
703
|
+
description: 'Returns the ng-solid style-architecture rules enforced by `pnpm quality:styles`: BEM naming, banned Tailwind utilities in markup, banned raw CSS values, required `@reference`, banned `@use`/`@import`.',
|
|
704
|
+
inputSchema: {}
|
|
705
|
+
}, async () => {
|
|
706
|
+
const snapshot = loadSnapshot();
|
|
707
|
+
if (!snapshot.styleContract)
|
|
708
|
+
return jsonError({
|
|
709
|
+
error: 'styleContract is missing from the snapshot.'
|
|
710
|
+
});
|
|
711
|
+
return jsonResult(snapshot.styleContract);
|
|
712
|
+
});
|
|
713
|
+
server.registerTool('validate_style_contract', {
|
|
714
|
+
title: 'Validate a code snippet against the style contract',
|
|
715
|
+
description: 'Runs the ng-solid style-architecture rules against a pasted snippet. `kind: "template"` checks markup for non-BEM classes and banned Tailwind utilities; `"scss"` checks a component stylesheet for missing `@reference`, banned `@use`/`@import`, and raw CSS values instead of `--ns-component-*` tokens; `"component-ts"` checks a component source for inline `styles:` (plus its inline template, if any).',
|
|
716
|
+
inputSchema: {
|
|
717
|
+
code: z.string(),
|
|
718
|
+
kind: z.enum([
|
|
719
|
+
'template',
|
|
720
|
+
'scss',
|
|
721
|
+
'component-ts'
|
|
722
|
+
])
|
|
723
|
+
}
|
|
724
|
+
}, async ({ code, kind }) => {
|
|
725
|
+
const snapshot = loadSnapshot();
|
|
726
|
+
if (!snapshot.styleContract)
|
|
727
|
+
return jsonError({
|
|
728
|
+
error: 'styleContract is missing from the snapshot.'
|
|
729
|
+
});
|
|
730
|
+
return jsonResult(validateStyleContract(code, kind, snapshot.styleContract));
|
|
731
|
+
});
|
|
732
|
+
server.registerTool('validate_component_usage', {
|
|
733
|
+
title: 'Validate component usage in a template',
|
|
734
|
+
description: 'The most valuable check in this server: validates a markup snippet against a component\'s REAL API from the AST-derived snapshot. Catches unknown inputs/outputs/models (respecting aliases like nsTooltip/maxlength/nsField), invalid enum values (e.g. `variant="warning"` on ns-button, which only accepts 5 values), invented BEM modifier classes, and a missing `imports: [...]` declaration. Every failure includes the correct alternative, never just a bare failure.',
|
|
735
|
+
inputSchema: {
|
|
736
|
+
component: z.string(),
|
|
737
|
+
template: z.string().describe('The markup to validate.'),
|
|
738
|
+
imports: z
|
|
739
|
+
.array(z.string())
|
|
740
|
+
.optional()
|
|
741
|
+
.describe("Class names already present in this file's `imports: [...]` array, to verify the component is actually importable. Omit to skip this check.")
|
|
742
|
+
}
|
|
743
|
+
}, async ({ component, template, imports }) => {
|
|
744
|
+
const snapshot = loadSnapshot();
|
|
745
|
+
const resolution = resolveComponent(snapshot, component);
|
|
746
|
+
if (!resolution.component)
|
|
747
|
+
return jsonError({
|
|
748
|
+
error: resolution.error,
|
|
749
|
+
suggestions: resolution.suggestions
|
|
750
|
+
});
|
|
751
|
+
const doc = resolution.component;
|
|
752
|
+
const inputByBinding = new Map(doc.inputs.map((i) => [i.bindingName, i]));
|
|
753
|
+
const outputByBinding = new Map(doc.outputs.map((o) => [o.bindingName, o]));
|
|
754
|
+
const modelByBinding = new Map(doc.models.map((m) => [m.bindingName, m]));
|
|
755
|
+
const { attributes, events, models } = parseTemplateCandidates(template);
|
|
756
|
+
const eventNames = new Set(events);
|
|
757
|
+
// ns-prefixed candidates are excluded from the invalid-props pool: they are almost always
|
|
758
|
+
// scanner noise (a selector fragment or an aliased-input binding, e.g. nsTooltip/nsField,
|
|
759
|
+
// which -- if genuinely used -- already resolves via inputByBinding). Ported from BHD's
|
|
760
|
+
// equivalent `bhd`-prefix filter.
|
|
761
|
+
const attributeCandidates = [...new Set(attributes)].filter((name) => !eventNames.has(name) && !name.startsWith('ns'));
|
|
762
|
+
const unknownInputs = attributeCandidates.filter((name) => !inputByBinding.has(name));
|
|
763
|
+
const unknownOutputs = [...new Set(events)].filter((name) => !outputByBinding.has(name));
|
|
764
|
+
const unknownModels = [...new Set(models)].filter((name) => !modelByBinding.has(name));
|
|
765
|
+
const invalidValues = [];
|
|
766
|
+
for (const match of template.matchAll(/([a-zA-Z][\w-]*)\s*=\s*"([^"]*)"/g)) {
|
|
767
|
+
const [, name, value] = match;
|
|
768
|
+
const input = inputByBinding.get(name);
|
|
769
|
+
if (input?.values &&
|
|
770
|
+
input.values.length > 0 &&
|
|
771
|
+
!input.values.includes(value)) {
|
|
772
|
+
invalidValues.push({
|
|
773
|
+
input: name,
|
|
774
|
+
value,
|
|
775
|
+
allowed: input.values,
|
|
776
|
+
label: `${name}=${value}`
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const knownClasses = new Set([
|
|
781
|
+
doc.bem.block,
|
|
782
|
+
...doc.bem.staticClasses,
|
|
783
|
+
...doc.bem.elements,
|
|
784
|
+
...doc.bem.modifiers.map((m) => m.className)
|
|
785
|
+
]);
|
|
786
|
+
const invalidClasses = [];
|
|
787
|
+
for (const match of template.matchAll(/(?:^|\s)class="([^"]+)"/g)) {
|
|
788
|
+
for (const cls of match[1].trim().split(/\s+/)) {
|
|
789
|
+
if (cls.startsWith(`${doc.bem.block}--`) && !knownClasses.has(cls))
|
|
790
|
+
invalidClasses.push(cls);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const styleResult = snapshot.styleContract
|
|
794
|
+
? validateStyleContract(template, 'template', snapshot.styleContract)
|
|
795
|
+
: null;
|
|
796
|
+
const styleErrors = (styleResult?.violations ?? []).filter((v) => v.severity === 'error');
|
|
797
|
+
const importDeclared = imports === undefined ? null : imports.includes(doc.className);
|
|
798
|
+
const suggestions = [];
|
|
799
|
+
for (const name of unknownInputs) {
|
|
800
|
+
const closest = [...inputByBinding.keys()].sort((a, b) => levenshtein(name, a) - levenshtein(name, b))[0];
|
|
801
|
+
if (closest)
|
|
802
|
+
suggestions.push(`Unknown input "${name}" -- did you mean "${closest}"?`);
|
|
803
|
+
}
|
|
804
|
+
for (const name of unknownOutputs) {
|
|
805
|
+
const closest = [...outputByBinding.keys()].sort((a, b) => levenshtein(name, a) - levenshtein(name, b))[0];
|
|
806
|
+
if (closest)
|
|
807
|
+
suggestions.push(`Unknown output "${name}" -- did you mean "(${closest})"?`);
|
|
808
|
+
}
|
|
809
|
+
for (const v of invalidValues) {
|
|
810
|
+
suggestions.push(`"${v.input}" only accepts ${v.allowed.map((a) => `"${a}"`).join(', ')} -- "${v.value}" is not valid.`);
|
|
811
|
+
}
|
|
812
|
+
if (importDeclared === false) {
|
|
813
|
+
suggestions.push(`"${doc.className}" is missing from imports: [...] -- add it or "${doc.selector}" will not render.`);
|
|
814
|
+
}
|
|
815
|
+
const valid = unknownInputs.length === 0 &&
|
|
816
|
+
unknownOutputs.length === 0 &&
|
|
817
|
+
unknownModels.length === 0 &&
|
|
818
|
+
invalidValues.length === 0 &&
|
|
819
|
+
invalidClasses.length === 0 &&
|
|
820
|
+
styleErrors.length === 0 &&
|
|
821
|
+
importDeclared !== false;
|
|
822
|
+
return jsonResult({
|
|
823
|
+
component: doc.selector,
|
|
824
|
+
valid,
|
|
825
|
+
unknownInputs,
|
|
826
|
+
unknownOutputs,
|
|
827
|
+
unknownModels,
|
|
828
|
+
invalidValues,
|
|
829
|
+
invalidClasses,
|
|
830
|
+
styleViolations: styleResult?.violations ?? [],
|
|
831
|
+
importDeclared,
|
|
832
|
+
suggestions
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
//# sourceMappingURL=tools.components.js.map
|