@nysds/mcp-server 1.19.4 → 1.20.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.
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Component Relationships & Integration
3
+ *
4
+ * Derives how components connect to each other, to their npm packages, and to
5
+ * the guides that cover them — all from the Custom Elements Manifest, so it
6
+ * stays in sync as components are added or renamed.
7
+ *
8
+ * Two derivation passes:
9
+ *
10
+ * 1. **Family** — components declared under the same `packages/<name>/` source
11
+ * directory ship in the same npm package. Containment *within* a family is
12
+ * read from the docs rather than the directory name, which is unreliable
13
+ * (`packages/nys-checkbox` contains the container `nys-checkboxgroup`):
14
+ *
15
+ * a. A container's slot descriptions name its children — "Default slot for
16
+ * `nys-checkbox` elements", "Accepts `<nys-tab>` and `<nys-tabpanel>`".
17
+ * b. A child's description names its container — "for use within
18
+ * `nys-select`", "a single step within `nys-stepper`".
19
+ * c. A component marked "**Slotted component.**" or "**Internal
20
+ * component.**" falls back to the only unmarked member of its family.
21
+ *
22
+ * This is how `nys-option` is known to belong inside `nys-select`, and
23
+ * `nys-checkbox` inside `nys-checkboxgroup` rather than the reverse.
24
+ *
25
+ * 2. **Mentions** — component JSDoc names sibling and alternative components in
26
+ * prose (`For 7+ options, use \`nys-select\``). Scanning descriptions, slots,
27
+ * and attribute docs recovers those edges in both directions.
28
+ *
29
+ * Mentions are filtered against the set of registered tag names, which drops
30
+ * event names (`nys-change`, `nys-input`), CSS variables, and stale references
31
+ * to components that no longer exist. A tag that is *also* an event name
32
+ * (`nys-select` is both a component and an event emitted by `nys-textarea`) is
33
+ * additionally checked against its surrounding phrasing so an "emits
34
+ * `nys-select`" sentence does not fabricate a component relationship.
35
+ */
36
+ import { getAllComponents, getComponent, } from "./cem-parser.js";
37
+ import { getGuidesMentioning } from "./guides.js";
38
+ let cachedFamilies = null;
39
+ let cachedModulePaths = null;
40
+ let cachedTagSet = null;
41
+ let cachedEventNames = null;
42
+ let cachedMentions = null;
43
+ /** Map every component tag to the module path it was declared in. */
44
+ function getModulePaths() {
45
+ if (cachedModulePaths)
46
+ return cachedModulePaths;
47
+ const paths = new Map();
48
+ for (const component of getAllComponents()) {
49
+ if (component.modulePath && component.tagName) {
50
+ paths.set(component.tagName.toLowerCase(), component.modulePath);
51
+ }
52
+ }
53
+ cachedModulePaths = paths;
54
+ return paths;
55
+ }
56
+ function packageDirFor(tagName) {
57
+ const path = getModulePaths().get(tagName.toLowerCase());
58
+ if (!path)
59
+ return undefined;
60
+ const [dir] = path.split("/src/");
61
+ return dir && dir !== path ? dir : undefined;
62
+ }
63
+ function getFamilies() {
64
+ if (cachedFamilies)
65
+ return cachedFamilies;
66
+ // Group tags by their source package directory
67
+ const byDir = new Map();
68
+ for (const component of getAllComponents()) {
69
+ const tag = component.tagName?.toLowerCase();
70
+ if (!tag)
71
+ continue;
72
+ const dir = packageDirFor(tag);
73
+ if (!dir)
74
+ continue;
75
+ const members = byDir.get(dir);
76
+ if (members) {
77
+ members.push(tag);
78
+ }
79
+ else {
80
+ byDir.set(dir, [tag]);
81
+ }
82
+ }
83
+ const families = new Map();
84
+ for (const [dir, memberTags] of byDir) {
85
+ const dirName = dir.split("/").pop() ?? "";
86
+ const parentOf = deriveContainment(memberTags);
87
+ // The container is the member nothing else contains. If the docs leave that
88
+ // ambiguous, fall back to the component named after its directory.
89
+ const roots = memberTags.filter((t) => !parentOf.has(t));
90
+ const primaryTag = roots.length === 1
91
+ ? roots[0]
92
+ : (roots.find((t) => t === dirName) ??
93
+ memberTags.find((t) => t === dirName) ??
94
+ memberTags[0]);
95
+ const info = {
96
+ packageDir: dir,
97
+ packageName: dirName.startsWith("nys-")
98
+ ? `@nysds/${dirName}`
99
+ : "@nysds/components",
100
+ primaryTag,
101
+ memberTags: memberTags.sort(),
102
+ parentOf,
103
+ };
104
+ for (const tag of memberTags) {
105
+ families.set(tag, info);
106
+ }
107
+ }
108
+ cachedFamilies = families;
109
+ return families;
110
+ }
111
+ /** Phrasing a child uses to name the container it belongs inside. */
112
+ const CONTAINMENT_PHRASE = /\b(?:within|inside|in an?|part of|child of|children of|slotted (?:in|into|inside))\s+(?:an?\s+)?[`'"<]*\s*(nys-[a-z][a-z0-9-]*)/gi;
113
+ /** Markers that identify a component as non-standalone. */
114
+ const SUBCOMPONENT_MARKER = /\*\*(?:slotted|internal|sub)\s+component\.?\*\*/i;
115
+ /**
116
+ * Work out which family members nest inside which, from the docs alone.
117
+ */
118
+ function deriveContainment(memberTags) {
119
+ const parentOf = new Map();
120
+ if (memberTags.length < 2)
121
+ return parentOf;
122
+ const members = new Set(memberTags);
123
+ const declarations = new Map(memberTags.map((t) => [t, getComponent(t)]));
124
+ // (a) A container's slot descriptions name the children it accepts.
125
+ for (const [tag, component] of declarations) {
126
+ if (!component)
127
+ continue;
128
+ for (const slot of component.slots ?? []) {
129
+ for (const match of (slot.description ?? "").matchAll(TAG_PATTERN)) {
130
+ const child = match[0].toLowerCase();
131
+ if (child !== tag && members.has(child) && !parentOf.has(child)) {
132
+ parentOf.set(child, tag);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // (b) A child's own description names the container it belongs inside.
138
+ for (const [tag, component] of declarations) {
139
+ if (!component || parentOf.has(tag))
140
+ continue;
141
+ const text = `${component.summary ?? ""}\n${component.description ?? ""}`;
142
+ for (const match of text.matchAll(CONTAINMENT_PHRASE)) {
143
+ const parent = match[1].toLowerCase();
144
+ if (parent !== tag && members.has(parent)) {
145
+ parentOf.set(tag, parent);
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ // (c) A component explicitly marked as slotted/internal that still has no
151
+ // container falls back to the family's only unmarked member.
152
+ const marked = new Set(memberTags.filter((t) => {
153
+ const c = declarations.get(t);
154
+ return SUBCOMPONENT_MARKER.test(`${c?.summary ?? ""}\n${c?.description ?? ""}`);
155
+ }));
156
+ const unmarked = memberTags.filter((t) => !marked.has(t));
157
+ if (unmarked.length === 1) {
158
+ for (const tag of marked) {
159
+ if (!parentOf.has(tag))
160
+ parentOf.set(tag, unmarked[0]);
161
+ }
162
+ }
163
+ // Guard against a doc cycle producing an unrooted family.
164
+ for (const [child, parent] of parentOf) {
165
+ if (parentOf.get(parent) === child)
166
+ parentOf.delete(child);
167
+ }
168
+ return parentOf;
169
+ }
170
+ /**
171
+ * Get the npm package a component ships in.
172
+ * Subcomponents resolve to their parent's package.
173
+ */
174
+ export function getPackageName(tagName) {
175
+ return (getFamilies().get(tagName.toLowerCase())?.packageName ?? "@nysds/components");
176
+ }
177
+ // ============================================================================
178
+ // Mention derivation
179
+ // ============================================================================
180
+ const TAG_PATTERN = /nys-[a-z][a-z0-9-]*/g;
181
+ /** Phrasing that marks a `nys-*` string as an event name, not a component. */
182
+ const EVENT_CONTEXT_BEFORE = /\b(emit|emits|emitted|emitting|dispatch|dispatches|dispatched|fire|fires|fired|listen|listens|listening to|handler for|event)\b[^.]{0,60}$/i;
183
+ const EVENT_CONTEXT_AFTER = /^[`'")\]]*\s*(event|events)\b/i;
184
+ function getTagSet() {
185
+ if (cachedTagSet)
186
+ return cachedTagSet;
187
+ cachedTagSet = new Set(getAllComponents()
188
+ .map((c) => c.tagName?.toLowerCase())
189
+ .filter((t) => Boolean(t)));
190
+ return cachedTagSet;
191
+ }
192
+ /**
193
+ * Every event name emitted anywhere in the design system.
194
+ *
195
+ * Most (`nys-change`, `nys-input`) are already excluded by the tag-set filter.
196
+ * This set exists for the overlap cases — names that are simultaneously a
197
+ * registered component tag and an event name.
198
+ */
199
+ function getEventNames() {
200
+ if (cachedEventNames)
201
+ return cachedEventNames;
202
+ const names = new Set();
203
+ for (const component of getAllComponents()) {
204
+ for (const event of component.events ?? []) {
205
+ if (event.name)
206
+ names.add(event.name.toLowerCase());
207
+ }
208
+ }
209
+ cachedEventNames = names;
210
+ return names;
211
+ }
212
+ /** All prose a component's docs expose, concatenated for scanning. */
213
+ function documentedText(component) {
214
+ return [
215
+ component.summary,
216
+ component.description,
217
+ ...(component.slots ?? []).map((s) => s.description),
218
+ ...(component.attributes ?? []).map((a) => a.description),
219
+ ]
220
+ .filter(Boolean)
221
+ .join("\n");
222
+ }
223
+ function extractMentions(component) {
224
+ const selfTag = component.tagName?.toLowerCase() ?? "";
225
+ const text = documentedText(component);
226
+ const tags = getTagSet();
227
+ const eventNames = getEventNames();
228
+ const mentions = new Set();
229
+ for (const match of text.matchAll(TAG_PATTERN)) {
230
+ const tag = match[0].toLowerCase();
231
+ if (tag === selfTag)
232
+ continue;
233
+ // Drops event names, CSS variables, utility classes, and references to
234
+ // components that no longer exist.
235
+ if (!tags.has(tag))
236
+ continue;
237
+ // Ambiguous: this string is both a component tag and an event name.
238
+ // Fall back to the surrounding phrasing.
239
+ if (eventNames.has(tag)) {
240
+ const index = match.index ?? 0;
241
+ const before = text.slice(Math.max(0, index - 80), index);
242
+ const after = text.slice(index + tag.length, index + tag.length + 20);
243
+ if (EVENT_CONTEXT_BEFORE.test(before) ||
244
+ EVENT_CONTEXT_AFTER.test(after)) {
245
+ continue;
246
+ }
247
+ }
248
+ mentions.add(tag);
249
+ }
250
+ return mentions;
251
+ }
252
+ function getMentionGraph() {
253
+ if (cachedMentions)
254
+ return cachedMentions;
255
+ const graph = new Map();
256
+ for (const component of getAllComponents()) {
257
+ const tag = component.tagName?.toLowerCase();
258
+ if (!tag)
259
+ continue;
260
+ graph.set(tag, extractMentions(component));
261
+ }
262
+ cachedMentions = graph;
263
+ return graph;
264
+ }
265
+ // ============================================================================
266
+ // Relationship assembly
267
+ // ============================================================================
268
+ /** Whether the docs say a subcomponent is also usable outside its container. */
269
+ function allowsStandalone(tagName) {
270
+ const component = getComponent(tagName);
271
+ return /\bstandalone\b/i.test(`${component?.summary ?? ""}\n${component?.description ?? ""}`);
272
+ }
273
+ function summaryOf(tagName) {
274
+ const component = getComponent(tagName);
275
+ return (component?.summary ||
276
+ component?.description?.split("\n")[0] ||
277
+ "No description");
278
+ }
279
+ export function getRelationships(tagName) {
280
+ const tag = tagName.toLowerCase();
281
+ const family = getFamilies().get(tag);
282
+ const mentions = getMentionGraph();
283
+ const packageName = getPackageName(tag);
284
+ const children = [];
285
+ const siblings = [];
286
+ let parent;
287
+ if (family) {
288
+ const parentTag = family.parentOf.get(tag);
289
+ if (parentTag) {
290
+ parent = {
291
+ tagName: parentTag,
292
+ summary: summaryOf(parentTag),
293
+ relation: "parent",
294
+ note: allowsStandalone(tag)
295
+ ? `Container for <${tag}>. <${tag}> also works standalone, but the container handles grouping, validation, and form submission.`
296
+ : `<${tag}> must be nested inside <${parentTag}>.`,
297
+ samePackage: true,
298
+ };
299
+ }
300
+ for (const member of family.memberTags) {
301
+ if (member === tag)
302
+ continue;
303
+ const memberParent = family.parentOf.get(member);
304
+ if (memberParent === tag) {
305
+ children.push({
306
+ tagName: member,
307
+ summary: summaryOf(member),
308
+ relation: "child",
309
+ note: `Nested inside <${tag}>. Ships in ${packageName} — no extra install or import.`,
310
+ samePackage: true,
311
+ });
312
+ }
313
+ else if (parentTag && memberParent === parentTag) {
314
+ siblings.push({
315
+ tagName: member,
316
+ summary: summaryOf(member),
317
+ relation: "sibling",
318
+ note: `Used alongside <${tag}> inside <${parentTag}>. Ships in ${packageName}.`,
319
+ samePackage: true,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ // Cross-package edges from JSDoc prose, in both directions
325
+ const familyTags = new Set(family?.memberTags ?? [tag]);
326
+ const outgoing = mentions.get(tag) ?? new Set();
327
+ const incoming = new Set();
328
+ for (const [source, targets] of mentions) {
329
+ if (targets.has(tag))
330
+ incoming.add(source);
331
+ }
332
+ const relatedTags = new Set([...outgoing, ...incoming]);
333
+ const related = [];
334
+ for (const other of [...relatedTags].sort()) {
335
+ if (familyTags.has(other))
336
+ continue;
337
+ const isOutgoing = outgoing.has(other);
338
+ const isIncoming = incoming.has(other);
339
+ const relation = isOutgoing && isIncoming
340
+ ? "mutual"
341
+ : isOutgoing
342
+ ? "mentions"
343
+ : "mentioned-by";
344
+ const note = relation === "mutual"
345
+ ? `<${tag}> and <${other}> reference each other in their docs — usually alternatives for the same job.`
346
+ : relation === "mentions"
347
+ ? `The <${tag}> docs point to <${other}>.`
348
+ : `The <${other}> docs point back to <${tag}>.`;
349
+ related.push({
350
+ tagName: other,
351
+ summary: summaryOf(other),
352
+ relation,
353
+ note,
354
+ samePackage: getPackageName(other) === packageName,
355
+ });
356
+ }
357
+ return { package: packageName, parent, children, siblings, related };
358
+ }
359
+ // ============================================================================
360
+ // Integration payload
361
+ // ============================================================================
362
+ const STYLES_NOTE = "NYSDS components render unstyled without the CSS variables from @nysds/styles. " +
363
+ "Use '@nysds/styles/full' on a new project (reset + typography + utility classes) " +
364
+ "or '@nysds/styles' on an existing one (variables only).";
365
+ /**
366
+ * Build the full integration payload for a component: what to install, what to
367
+ * import, what has to be nested inside it, and which guides cover it.
368
+ */
369
+ export function getIntegration(component) {
370
+ const tag = component.tagName?.toLowerCase() ?? "";
371
+ const relationships = getRelationships(tag);
372
+ const packageName = relationships.package;
373
+ const className = component.name || "NysComponent";
374
+ // A subcomponent is required for correct usage of its parent, and vice versa.
375
+ const requires = [
376
+ ...(relationships.parent ? [relationships.parent] : []),
377
+ ...relationships.children,
378
+ ];
379
+ const attributeNames = new Set((component.attributes ?? []).map((a) => a.name));
380
+ const isFormControl = attributeNames.has("showError") || attributeNames.has("errorMessage");
381
+ const guides = [...getGuidesMentioning(tag)];
382
+ if (isFormControl && !guides.some((g) => g.topic === "form-validation")) {
383
+ guides.push({
384
+ guide: "form-validation",
385
+ topic: "form-validation",
386
+ call: `get_guide({ topic: "form-validation" })`,
387
+ });
388
+ }
389
+ const nextSteps = [];
390
+ for (const required of requires) {
391
+ nextSteps.push(`get_component({ tagName: "${required.tagName}" }) — ${required.relation === "parent"
392
+ ? `the container <${tag}> goes inside`
393
+ : `nested inside <${tag}>`}`);
394
+ }
395
+ nextSteps.push(`validate_component_api({ tagName: "${tag}", attributes: { ... } }) — check attributes before shipping`);
396
+ if (guides.length > 0) {
397
+ nextSteps.push(`${guides[0].call} — guide covering <${tag}>`);
398
+ }
399
+ nextSteps.push(`get_utility_classes({ category: "spacing" }) — spacing around <${tag}> without wrapper elements`);
400
+ return {
401
+ package: packageName,
402
+ install: `npm install ${packageName} @nysds/styles`,
403
+ imports: {
404
+ component: `import '${packageName}';`,
405
+ allComponents: `import '@nysds/components';`,
406
+ react: `import { ${className} } from '@nysds/components/react';`,
407
+ styles: `@import '@nysds/styles';`,
408
+ },
409
+ stylesNote: STYLES_NOTE,
410
+ requires,
411
+ usedWith: relationships.siblings,
412
+ alternatives: relationships.related,
413
+ guides,
414
+ styling: {
415
+ cssCustomProperties: (component.cssProperties ?? []).map((p) => ({
416
+ name: p.name,
417
+ description: p.description,
418
+ })),
419
+ spacing: `Apply spacing with utility classes on the element itself, e.g. <${tag} class="nys-margin-b-300">. Call get_utility_classes({ category: "spacing" }).`,
420
+ tokens: `Style overrides should reference design tokens, not raw values. Call find_tokens({ query: "..." }) or get_token({ token: "--nys-..." }).`,
421
+ },
422
+ nextSteps,
423
+ };
424
+ }
425
+ /**
426
+ * Clear cached derivations (useful for testing).
427
+ */
428
+ export function clearRelationshipCache() {
429
+ cachedFamilies = null;
430
+ cachedModulePaths = null;
431
+ cachedTagSet = null;
432
+ cachedEventNames = null;
433
+ cachedMentions = null;
434
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nysds/mcp-server",
3
- "version": "1.19.4",
3
+ "version": "1.20.0",
4
4
  "description": "MCP server for the New York State Design System. Exposes components, tokens, and documentation to AI assistants.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,8 +30,8 @@
30
30
  "zod": "^3.25"
31
31
  },
32
32
  "devDependencies": {
33
- "@nysds/styles": "^1.19.4",
34
- "@nysds/tokens": "^1.19.4",
33
+ "@nysds/styles": "^1.20.0",
34
+ "@nysds/tokens": "^1.20.0",
35
35
  "@types/node": "^22.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },