@lumeo-ui/mcp-server 3.18.0 → 4.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/README.md CHANGED
@@ -11,18 +11,29 @@ actually compiles.
11
11
 
12
12
  ## What it exposes
13
13
 
14
- The server covers **all 125 components** from Lumeo's generated
15
- `registry.json`. The top ~35 most-used components ship with rich, hand-curated
16
- schemas (parameters, slots, ready-to-use Razor examples, CSS variables); the
17
- remaining ~90 are still discoverable and returned with category / description /
18
- files / dependencies / CSS variables plus a link back to the docs site for full
19
- reference. As more components get curated, the rich count grows automatically.
14
+ The server covers **the full Lumeo component catalog** from the generated
15
+ `registry.json` + `components-api.json`. Every component is returned with its
16
+ parameters (including which are `[EditorRequired]`), enums, sub-components, CSS
17
+ variables, examples, its **test-coverage tier**, and its **accessibility contract**
18
+ (roles, keyboard keys, focus) the schema is source-derived (Roslyn) so it has full
19
+ coverage with no thin fallbacks, and stays in lockstep with each release.
20
20
 
21
21
  ### Tools
22
22
 
23
- - `lumeo_list_components({ category?, query? })` — list all 125 components
24
- - `lumeo_get_component({ name })` — rich schema if curated; thin + docs link otherwise
25
- - `lumeo_search({ query })` — fuzzy search across all 125
23
+ Components:
24
+ - `lumeo_list_components({ category?, query? })` — list all components (name, category, description)
25
+ - `lumeo_get_component({ name })` — full schema: every `[Parameter]` (with `required` for `[EditorRequired]`), enums, records, events, sub-components, CSS vars, source files, a curated example, and the component's **test-coverage tier**
26
+ - `lumeo_get_a11y({ name })` — accessibility contract: ARIA roles + `aria-*` attributes rendered, keyboard keys handled, focus management, and whether that behaviour is test-covered
27
+ - `lumeo_get_install({ name })` — install + setup: NuGet package, `using`s, DI, host includes, required parameters, portal/overlay requirements
28
+ - `lumeo_get_example({ name })` — a hand-curated Razor usage example
29
+ - `lumeo_search({ query })` — fuzzy search across components and services
30
+ - `lumeo_validate_markup({ markup })` — static-check Razor: unknown params, **type-bound enum-value validation** (e.g. `Size="Large"` is rejected), and missing-parent errors for sub-components that read a cascading context
31
+
32
+ Services, patterns, theme:
33
+ - `lumeo_list_services()` / `lumeo_get_service({ name })` — service-layer API (OverlayService, ThemeService, global enums, …)
34
+ - `lumeo_list_patterns()` / `lumeo_get_pattern({ name })` — higher-level composition patterns
35
+ - `lumeo_get_theme_tokens()` — design tokens ↔ CSS variables
36
+ - `lumeo_changelog()` — recent library changes
26
37
 
27
38
  ### Resources
28
39
 
@@ -52,7 +63,7 @@ Edit `claude_desktop_config.json` (Settings → Developer → Edit Config):
52
63
  "mcpServers": {
53
64
  "lumeo": {
54
65
  "command": "node",
55
- "args": ["C:/Users/bemi/RiderProjects/Lumeo/tools/lumeo-mcp/dist/index.js"]
66
+ "args": ["/absolute/path/to/Lumeo/tools/lumeo-mcp/dist/index.js"]
56
67
  }
57
68
  }
58
69
  }
@@ -113,16 +124,17 @@ npm run dev # tsc --watch
113
124
  npm start # run the built server
114
125
  ```
115
126
 
116
- The component catalog is built at startup by merging two sources:
117
-
118
- - `src/components.ts` hand-curated rich entries (top ~35) with full
119
- `params`, `slots`, and `example` fields
120
- - `src/registry.json` the full 125-component registry, copied from
121
- `src/Lumeo/registry/registry.json` at `prebuild` time by
122
- `scripts/sync-registry.mjs`
123
-
124
- To enrich a thin entry, add a full entry for it in `src/components.ts` the
125
- merge layer will automatically upgrade it to the rich schema.
127
+ The component schema is generated at build time, not hand-maintained:
128
+ `tools/Lumeo.RegistryGen` reads the actual Razor source via Roslyn and emits full
129
+ params / enums / events / sub-component metadata for every component into
130
+ `src/Lumeo/registry/`. `scripts/sync-registry.mjs` copies the generated
131
+ `registry.json` (164 components) and `components-api.json` here at `prebuild`
132
+ time, so the catalog never drifts from the source.
133
+
134
+ `src/components.ts` only layers a few extra hand-curated example snippets on top —
135
+ there is no thin/rich split and no manual catalog drift. To add a richer example
136
+ for a component, add an entry for it in `src/components.ts`; the merge layer
137
+ overlays it onto the generated schema.
126
138
 
127
139
  ## License
128
140
 
@@ -317,14 +317,16 @@ export const components = [
317
317
  { name: "ChildContent", description: "DialogHeader / DialogContent / DialogFooter." },
318
318
  ],
319
319
  example: `<Dialog @bind-Open="_dialogOpen">
320
- <DialogHeader>
321
- <DialogTitle>Are you sure?</DialogTitle>
322
- <DialogDescription>This cannot be undone.</DialogDescription>
323
- </DialogHeader>
324
- <DialogFooter>
325
- <Button Variant="Button.ButtonVariant.Outline" OnClick='() => _dialogOpen = false'>Cancel</Button>
326
- <Button Variant="Button.ButtonVariant.Destructive" OnClick="Confirm">Delete</Button>
327
- </DialogFooter>
320
+ <DialogContent>
321
+ <DialogHeader>
322
+ <DialogTitle>Are you sure?</DialogTitle>
323
+ <DialogDescription>This cannot be undone.</DialogDescription>
324
+ </DialogHeader>
325
+ <DialogFooter>
326
+ <Button Variant="Button.ButtonVariant.Outline" OnClick='() => _dialogOpen = false'>Cancel</Button>
327
+ <Button Variant="Button.ButtonVariant.Destructive" OnClick="Confirm">Delete</Button>
328
+ </DialogFooter>
329
+ </DialogContent>
328
330
  </Dialog>`,
329
331
  cssVars: ["--color-background", "--color-foreground", "--color-border"],
330
332
  },
@@ -676,7 +678,7 @@ function slugify(name) {
676
678
  export const registry = loadRegistry();
677
679
  /**
678
680
  * Build the unified catalog:
679
- * - All 125 components from the registry
681
+ * - Every component from the registry
680
682
  * - Hand-curated rich entries override the thin registry entries for their name
681
683
  */
682
684
  function buildCatalog() {
@@ -3,7 +3,7 @@
3
3
  * `tools/Lumeo.RegistryGen` (Roslyn-based scan of every `[Parameter]` /
4
4
  * `[CascadingParameter]` property across every Razor component in the repo).
5
5
  *
6
- * This is the source-of-truth schema for ALL 131 Lumeo components. The
6
+ * This is the source-of-truth schema for ALL Lumeo components. The
7
7
  * legacy hand-curated `components.ts` is kept as an OPTIONAL example overlay:
8
8
  * when it has an entry for a component we surface its `example` Razor snippet
9
9
  * verbatim alongside the auto-generated parameter list.
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Lumeo MCP Server v2.0.1
3
+ * Lumeo MCP Server
4
+ *
5
+ * (Runtime version is read from package.json at startup — see SERVER_VERSION below —
6
+ * so it never drifts from the published package.)
4
7
  *
5
8
  * Source-of-truth schema for ALL Lumeo components, generated at build time
6
9
  * by `tools/Lumeo.RegistryGen` from the actual Razor source via Roslyn. Every
@@ -8,7 +11,7 @@
8
11
  * no thin/rich split, no manual catalog drift.
9
12
  *
10
13
  * Tools:
11
- * - lumeo_list_components — list/filter all 131 components (name+category+description)
14
+ * - lumeo_list_components — list/filter all components (name+category+description)
12
15
  * - lumeo_get_component — full schema (params, enums, events, sub-components, files, examples)
13
16
  * - lumeo_search — fuzzy search across name/category/description
14
17
  * - lumeo_get_example — working Razor snippet(s) for a component
@@ -21,6 +24,7 @@
21
24
  * Resources (URI template):
22
25
  * - lumeo://component/{name} — markdown reference per component
23
26
  * - lumeo://category/{name} — overview of all components in a category
27
+ * - lumeo://service/{name} — markdown reference per injectable service
24
28
  *
25
29
  * Transport: stdio (the standard for spawned MCP servers).
26
30
  */
@@ -29,8 +33,24 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
29
33
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
30
34
  import { loadComponentsApi, } from "./componentsApi.js";
31
35
  import { components as curatedExamples } from "./components.js";
36
+ import { loadRegistry } from "./registry.js";
32
37
  import { setupFor, PORTAL_COMPONENTS, NEEDS_OVERLAY_PROVIDER } from "./installInfo.js";
38
+ import { createValidator } from "./validate.js";
39
+ import { readFileSync } from "node:fs";
33
40
  const DOCS_BASE = "https://lumeo.nativ.sh";
41
+ // Server version, read from package.json so it can never drift from the published
42
+ // package — the publish job bumps package.json via `npm version`, and this file no
43
+ // longer carries a hand-edited literal that goes stale (it sat at 2.2.3 while the
44
+ // library was 3.19.0).
45
+ const SERVER_VERSION = (() => {
46
+ try {
47
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
48
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
49
+ }
50
+ catch {
51
+ return "0.0.0";
52
+ }
53
+ })();
34
54
  // ───────────────── Load source-of-truth schema ─────────────────
35
55
  const api = loadComponentsApi() ?? {
36
56
  version: "0.0.0",
@@ -68,6 +88,24 @@ const patternByKey = new Map(patterns.map((p) => [p.title.toLowerCase().replace(
68
88
  // parameters/enums/events; the curated `example` Razor snippet is preserved
69
89
  // as a documentation aid because LLMs benefit from seeing real usage.
70
90
  const curatedExampleByName = new Map(curatedExamples.map((c) => [c.name.toLowerCase(), c.example]));
91
+ // Per-component test-coverage (tier + which test dimensions exist), from registry.json.
92
+ // Surfaced in get_component so an agent can see, at a glance, what's battle-tested.
93
+ const coverageByName = new Map((loadRegistry()?.components ?? [])
94
+ .filter((c) => c.testCoverage)
95
+ .map((c) => [c.name.toLowerCase(), c.testCoverage]));
96
+ /** One-line human summary of a component's test coverage, e.g.
97
+ * "tier 3/4 · 32 tests · render, behavior, a11y, keyboard". */
98
+ function coverageSummary(tc) {
99
+ const dims = [
100
+ tc.render && "render",
101
+ tc.behavior && "behavior",
102
+ tc.a11y && "a11y",
103
+ tc.keyboard && "keyboard",
104
+ tc.scale && "scale",
105
+ tc.e2e && "e2e",
106
+ ].filter(Boolean);
107
+ return `tier ${tc.tier}/4 · ${tc.tests} test${tc.tests === 1 ? "" : "s"}${dims.length ? ` · ${dims.join(", ")}` : ""}`;
108
+ }
71
109
  // ───────────────── Helpers ─────────────────
72
110
  function findComponent(name) {
73
111
  // Top-level match first; fall back to the parent of a matching sub-component
@@ -125,6 +163,8 @@ function paramRow(p) {
125
163
  const def = p.default ?? "—";
126
164
  const desc = p.description ?? "";
127
165
  const flags = [];
166
+ if (p.isEditorRequired)
167
+ flags.push("required");
128
168
  if (p.isCascading)
129
169
  flags.push("cascading");
130
170
  if (p.captureUnmatched)
@@ -147,12 +187,14 @@ function toComponentMarkdown(c) {
147
187
  ? c.files.map((f) => `- \`${f}\``).join("\n")
148
188
  : "";
149
189
  const example = curatedExampleByName.get(c.name.toLowerCase());
190
+ const coverage = coverageByName.get(c.name.toLowerCase());
150
191
  const sections = [
151
192
  `# ${c.name}`,
152
193
  "",
153
194
  `**Category:** ${c.category}${c.subcategory ? ` › ${c.subcategory}` : ""}`,
154
195
  `**NuGet:** \`${c.nugetPackage}\``,
155
196
  `**Namespace:** \`${c.namespace ?? "Lumeo"}\``,
197
+ ...(coverage ? [`**Test coverage:** ${coverageSummary(coverage)}`] : []),
156
198
  "",
157
199
  c.description,
158
200
  "",
@@ -326,6 +368,24 @@ function toGetPayload(c) {
326
368
  subComponents,
327
369
  examples: c.examples ?? [],
328
370
  curatedExample: curatedExampleByName.get(c.name.toLowerCase()) ?? null,
371
+ testCoverage: coverageByName.get(c.name.toLowerCase()) ?? null,
372
+ docs: docsUrl(c),
373
+ };
374
+ }
375
+ // ───────────────── lumeo_get_a11y ─────────────────
376
+ function toA11yPayload(c) {
377
+ const a11y = c.a11y ?? { roles: [], ariaAttributes: [], keys: [], keyboardInteractive: false, focusManaged: false };
378
+ const cov = coverageByName.get(c.name.toLowerCase());
379
+ return {
380
+ name: c.name,
381
+ roles: a11y.roles,
382
+ ariaAttributes: a11y.ariaAttributes,
383
+ keyboardKeys: a11y.keys,
384
+ keyboardInteractive: a11y.keyboardInteractive,
385
+ focusManaged: a11y.focusManaged,
386
+ // Whether the a11y / keyboard behaviour is actually covered by tests.
387
+ tested: cov ? { a11y: cov.a11y, keyboard: cov.keyboard } : null,
388
+ gotchas: c.gotchas ?? [],
329
389
  docs: docsUrl(c),
330
390
  };
331
391
  }
@@ -335,8 +395,9 @@ function buildInstallInfo(c) {
335
395
  const subNames = Object.values(c.subComponents).map((s) => s.componentName);
336
396
  const isPortal = PORTAL_COMPONENTS.has(c.name);
337
397
  const needsOverlayProvider = NEEDS_OVERLAY_PROVIDER.has(c.name);
398
+ // [EditorRequired] params, from the real scanner flag (was a fragile description-text grep).
338
399
  const requiredParams = c.parameters
339
- .filter((p) => /\bEditorRequired\b/i.test(p.description ?? "") || /required/i.test(p.description ?? ""))
400
+ .filter((p) => p.isEditorRequired)
340
401
  .map((p) => p.name);
341
402
  return {
342
403
  component: c.name,
@@ -368,101 +429,19 @@ function buildInstallInfo(c) {
368
429
  docs: docsUrl(c),
369
430
  };
370
431
  }
371
- // Build a lookup of every component + sub-component → its parameter set,
372
- // plus the parent each sub-component must live inside.
373
- const elementIndex = (() => {
374
- const m = new Map();
375
- const enumValueSet = (vals) => new Set(vals.map((v) => v.toLowerCase()));
376
- for (const c of components) {
377
- const enums = new Map();
378
- for (const e of c.enums)
379
- enums.set(e.name, enumValueSet(e.values));
380
- m.set(c.name.toLowerCase(), { params: new Set(c.parameters.map((p) => p.name)), enums });
381
- for (const s of Object.values(c.subComponents)) {
382
- const subEnums = new Map();
383
- for (const e of s.enums)
384
- subEnums.set(e.name, enumValueSet(e.values));
385
- m.set(s.componentName.toLowerCase(), { params: new Set(s.parameters.map((p) => p.name)), enums: subEnums, parent: c.name });
386
- }
387
- }
388
- return m;
389
- })();
390
- function validateMarkup(markup) {
391
- const issues = [];
392
- // Find component tags: <Foo ...> or <Foo .../> or </Foo>. Lumeo components are PascalCase.
393
- const tagRx = /<\/?([A-Z][A-Za-z0-9]*)((?:\s+[^<>]*?)?)\/?>/g;
394
- // Track which known Lumeo components appear, to validate parent-child.
395
- const present = new Set();
396
- let m;
397
- while ((m = tagRx.exec(markup)) !== null) {
398
- const tag = m[1];
399
- const isClose = m[0].startsWith("</");
400
- const known = elementIndex.get(tag.toLowerCase());
401
- if (!known)
402
- continue; // not a Lumeo element (could be a user component / HTML — ignore)
403
- present.add(tag);
404
- if (isClose)
405
- continue;
406
- // Parse attributes (best-effort): Name="..." | Name='...' | Name="@expr" | Name=@expr | bare-name
407
- const attrBlob = m[2] ?? "";
408
- const attrRx = /([@A-Za-z_][\w-]*)\s*=\s*("(?:[^"]*)"|'(?:[^']*)'|@?[^\s"'<>]+)|([@A-Za-z_][\w-]*)(?=\s|$)/g;
409
- let am;
410
- while ((am = attrRx.exec(attrBlob)) !== null) {
411
- let name = (am[1] ?? am[3] ?? "").trim();
412
- if (!name)
413
- continue;
414
- // Strip Blazor directive prefixes/suffixes: @bind-Foo, @bind-Foo:event, Foo:stopPropagation, @onclick, @attributes, @key, @ref, @bind
415
- if (name.startsWith("@bind-"))
416
- name = name.slice("@bind-".length).split(":")[0];
417
- else if (name.startsWith("@bind"))
418
- continue; // @bind / @bind:event on inputs — skip
419
- else if (name.startsWith("@on") || name === "@attributes" || name === "@key" || name === "@ref" || name === "@oninput" || name === "@onchange")
420
- continue;
421
- else if (name.startsWith("@"))
422
- continue; // other directives
423
- if (name.includes(":"))
424
- name = name.split(":")[0]; // EventName:stopPropagation, :preventDefault
425
- if (name === "class" || name === "style" || name === "id")
426
- continue; // pass-through HTML attrs (Lumeo captures unmatched)
427
- if (/^data-|^aria-/i.test(name))
428
- continue; // captured unmatched
429
- if (!known.params.has(name)) {
430
- // Could be a captured-unmatched HTML attr — only flag if it looks like a typo'd Lumeo param (PascalCase).
431
- if (/^[A-Z]/.test(name)) {
432
- issues.push({ severity: "warning", component: tag, message: `Unknown parameter \`${name}\` on <${tag}>. Did you mean one of: ${[...known.params].slice(0, 8).join(", ")}…? (Or it's a pass-through HTML attribute, which is allowed.)` });
433
- }
434
- continue;
435
- }
436
- // Enum value check: Foo="Bar.Baz.Qux" or Foo="Qux"
437
- const rawVal = (am[2] ?? "").replace(/^["']|["']$/g, "").trim();
438
- if (!rawVal || rawVal.startsWith("@"))
439
- continue; // dynamic expression — can't statically check
440
- // Which enum does this param use? Match by param name heuristically against enum names.
441
- for (const [enumName, vals] of known.enums) {
442
- // crude: the param likely uses this enum if rawVal looks like EnumName.Value or matches a value
443
- const lastSeg = rawVal.split(".").pop().toLowerCase();
444
- const looksLikeThisEnum = rawVal.toLowerCase().includes(enumName.toLowerCase()) || vals.has(lastSeg);
445
- if (looksLikeThisEnum && !vals.has(lastSeg)) {
446
- issues.push({ severity: "error", component: tag, message: `\`${name}="${rawVal}"\` — \`${lastSeg}\` is not a valid ${enumName} value. Allowed: ${[...vals].join(", ")}.` });
447
- }
448
- }
449
- }
450
- }
451
- // Parent-child: every sub-component present should have its required parent present somewhere.
452
- for (const tag of present) {
453
- const known = elementIndex.get(tag.toLowerCase());
454
- if (known.parent && !present.has(known.parent)) {
455
- issues.push({ severity: "error", component: tag, message: `<${tag}> must be used inside <${known.parent}> (it reads a CascadingValue from it). No <${known.parent}> found in this markup.` });
456
- }
457
- }
458
- // Also flag obviously-unknown PascalCase tags that look like attempted Lumeo components.
459
- // (Skip — too noisy; user components are also PascalCase. The known-element checks above are enough.)
460
- return { ok: !issues.some((i) => i.severity === "error"), issues };
461
- }
432
+ // ───────────────── lumeo_validate_markup ─────────────────
433
+ // The validator lives in ./validate.ts so it can be unit-tested in isolation;
434
+ // build it from the loaded catalog here. Shared/global enums (Lumeo.Size,
435
+ // Orientation, …) live in the service layer rather than on any one component, so
436
+ // feed them in separately for type-bound enum validation.
437
+ const sharedEnums = services
438
+ .filter((s) => s.kind === "enum")
439
+ .map((s) => ({ name: s.name, values: s.enumValues.map((v) => v.name) }));
440
+ const validateMarkup = createValidator(components, sharedEnums);
462
441
  // ───────────────── Server setup ─────────────────
463
442
  const server = new Server({
464
443
  name: "lumeo-mcp",
465
- version: "2.2.3",
444
+ version: SERVER_VERSION,
466
445
  }, {
467
446
  capabilities: {
468
447
  tools: {},
@@ -504,6 +483,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
504
483
  },
505
484
  },
506
485
  },
486
+ {
487
+ name: "lumeo_get_a11y",
488
+ description: "Get the ACCESSIBILITY contract for a Lumeo component: the ARIA roles and aria-* " +
489
+ "attributes it renders, the keyboard keys it handles, whether it manages focus, and " +
490
+ "whether that a11y/keyboard behaviour is covered by tests. Use it to verify a " +
491
+ "component's accessibility before relying on it, or to know which keys to exercise. " +
492
+ "Extracted from the actual Razor source by RegistryGen.",
493
+ inputSchema: {
494
+ type: "object",
495
+ required: ["name"],
496
+ properties: {
497
+ name: {
498
+ type: "string",
499
+ description: "Component name (e.g. \"Dialog\", \"Tabs\", \"Select\"). Case-insensitive.",
500
+ },
501
+ },
502
+ },
503
+ },
507
504
  {
508
505
  name: "lumeo_list_services",
509
506
  description: `List all ${services.length} Lumeo SERVICE-LAYER public API types — services (OverlayService, ` +
@@ -641,6 +638,20 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
641
638
  }
642
639
  return { content: [{ type: "text", text: JSON.stringify(toGetPayload(c), null, 2) }] };
643
640
  }
641
+ case "lumeo_get_a11y": {
642
+ const wanted = typeof a.name === "string" ? a.name : "";
643
+ const c = findComponent(wanted);
644
+ if (!c) {
645
+ return {
646
+ isError: true,
647
+ content: [{
648
+ type: "text",
649
+ text: `Component "${wanted}" not found. Use lumeo_list_components or lumeo_search to discover available components.`,
650
+ }],
651
+ };
652
+ }
653
+ return { content: [{ type: "text", text: JSON.stringify(toA11yPayload(c), null, 2) }] };
654
+ }
644
655
  case "lumeo_list_services": {
645
656
  const results = services.map(toServiceListPayload);
646
657
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
package/dist/registry.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Loads Lumeo's generated registry.json (synced into src/registry.json at
3
- * prebuild time — see `scripts/sync-registry.mjs`). All 125 components are
3
+ * prebuild time — see `scripts/sync-registry.mjs`). All components are
4
4
  * surfaced to the MCP server through this file so `lumeo_list_components`,
5
5
  * `lumeo_get_component`, and `lumeo_search` can cover the full catalog.
6
6
  *
@@ -71,6 +71,7 @@ export function loadRegistry() {
71
71
  dependencies: entry.dependencies ?? [],
72
72
  cssVars: entry.cssVars ?? [],
73
73
  registryUrl: entry.registryUrl,
74
+ testCoverage: entry.testCoverage,
74
75
  });
75
76
  }
76
77
  return {
@@ -0,0 +1,216 @@
1
+ // Markup validator for lumeo_validate_markup — extracted from index.ts so it can
2
+ // be unit-tested in isolation. Parameterized by the component catalog (rather than
3
+ // reaching into module-level state) so tests can drive it with a small synthetic
4
+ // catalog and the server can build it from the loaded components-api.
5
+ /** `@typeparam` names used by Lumeo's generic components, keyed by component name (lowercase)
6
+ * -> the exact type-param name(s) THAT component declares. These bind a generic component to
7
+ * a concrete type via a Razor attribute (e.g. `<DataGrid TItem="Employee">`), but they are NOT
8
+ * `[Parameter]` properties — the generated component catalog has no entry for them on any
9
+ * component. Scoped per-component (Codex P3) rather than a blanket name exemption: many Lumeo
10
+ * components capture unmatched attributes, so an unscoped exemption let genuine PascalCase
11
+ * typos like `<Button TItem="Order">` or `<Input T="Foo">` — exactly the class of mistake the
12
+ * validator exists to catch — through silently on ANY component, not just the generic ones
13
+ * that actually declare that type parameter. Derived from every `@typeparam` directive in the
14
+ * library (`grep -rn "@typeparam" src/Lumeo.* src/Lumeo`); not threaded through the registry schema for
15
+ * the same proportionality reason as the original (unscoped) fix — the set is small and stable. */
16
+ const GENERIC_TYPE_PARAMS_BY_COMPONENT = {
17
+ form: new Set(["TModel"]),
18
+ picklist: new Set(["TItem"]),
19
+ pivotgrid: new Set(["TItem"]),
20
+ sortablelist: new Set(["TItem"]),
21
+ taginput: new Set(["TItem"]),
22
+ treeview: new Set(["T"]),
23
+ treeviewnode: new Set(["T"]),
24
+ datagrid: new Set(["TItem"]),
25
+ datatable: new Set(["TItem"]),
26
+ datagridbody: new Set(["TItem"]),
27
+ datagridcell: new Set(["TItem"]),
28
+ datagridcolumndef: new Set(["TItem"]),
29
+ datagridcolumnfilter: new Set(["TItem"]),
30
+ datagridcolumngroup: new Set(["TItem"]),
31
+ datagridcolumnvisibility: new Set(["TItem"]),
32
+ datagriddetailrow: new Set(["TItem"]),
33
+ datagridfooter: new Set(["TItem"]),
34
+ datagridgrouprow: new Set(["TItem"]),
35
+ datagridheader: new Set(["TItem"]),
36
+ datagridheadercell: new Set(["TItem"]),
37
+ datagridpagination: new Set(["TItem"]),
38
+ datagridrow: new Set(["TItem"]),
39
+ datagridtoolbar: new Set(["TItem"]),
40
+ datagridtoolbarcolumns: new Set(["TItem"]),
41
+ datagridtoolbarcopyselected: new Set(["TItem"]),
42
+ datagridtoolbarexport: new Set(["TItem"]),
43
+ datagridtoolbarfullscreen: new Set(["TItem"]),
44
+ datagridtoolbarlayouts: new Set(["TItem"]),
45
+ toolbarcontent: new Set(["TItem"]),
46
+ };
47
+ /** Strips namespace / global:: / trailing nullable `?` to the bare type name:
48
+ * "Lumeo.Size?" → "Size", "global::Lumeo.Orientation" → "Orientation". */
49
+ function bareTypeName(type) {
50
+ if (!type)
51
+ return "";
52
+ return type.replace(/\?+$/, "").replace(/^global::/, "").split(".").pop() ?? "";
53
+ }
54
+ /** Global enum table: bare enum name (lowercased) → its display values. Built once from
55
+ * every component's `enums` plus the shared enums (Size, Orientation, …). The validator
56
+ * binds a param's declared `type` to one of these by name, so it checks a value against
57
+ * the param's ACTUAL enum rather than guessing from substring matches. */
58
+ function buildEnumTable(components, sharedEnums) {
59
+ const table = new Map();
60
+ const add = (name, values) => {
61
+ if (name && values.length && !table.has(name.toLowerCase()))
62
+ table.set(name.toLowerCase(), values);
63
+ };
64
+ for (const e of sharedEnums)
65
+ add(e.name, e.values);
66
+ for (const c of components) {
67
+ for (const e of c.enums)
68
+ add(e.name, e.values);
69
+ for (const s of Object.values(c.subComponents))
70
+ for (const e of s.enums)
71
+ add(e.name, e.values);
72
+ }
73
+ return table;
74
+ }
75
+ function buildElementIndex(components, enumTable) {
76
+ const m = new Map();
77
+ // For each param, bind it to its enum by type (only when that enum is in the table).
78
+ const bindEnums = (params) => {
79
+ const paramEnum = new Map();
80
+ for (const p of params) {
81
+ const bare = bareTypeName(p.type);
82
+ if (bare && enumTable.has(bare.toLowerCase()))
83
+ paramEnum.set(p.name, bare);
84
+ }
85
+ return paramEnum;
86
+ };
87
+ for (const c of components) {
88
+ m.set(c.name.toLowerCase(), {
89
+ params: new Set(c.parameters.map((p) => p.name)),
90
+ paramEnum: bindEnums(c.parameters),
91
+ });
92
+ for (const s of Object.values(c.subComponents)) {
93
+ m.set(s.componentName.toLowerCase(), {
94
+ params: new Set(s.parameters.map((p) => p.name)),
95
+ paramEnum: bindEnums(s.parameters),
96
+ parent: c.name,
97
+ parentCascades: s.parameters.some((p) => p.isCascading === true),
98
+ });
99
+ }
100
+ }
101
+ return m;
102
+ }
103
+ /** Builds a validateMarkup function bound to the given component catalog. `sharedEnums`
104
+ * supplies enums that live outside any component (e.g. Lumeo.Size) so type-bound enum
105
+ * validation works for shared types too. */
106
+ export function createValidator(components, sharedEnums = []) {
107
+ const enumTable = buildEnumTable(components, sharedEnums);
108
+ const elementIndex = buildElementIndex(components, enumTable);
109
+ return function validateMarkup(markup) {
110
+ const issues = [];
111
+ // Find component tags: <Foo ...> or <Foo .../> or </Foo>. Lumeo components are PascalCase.
112
+ // The attribute blob is quote-aware: a quoted value (Foo="...", Foo='...') can contain
113
+ // `<`/`>` and must not end the match early. Common valid Razor like
114
+ // Field="@(u => u.Name)" (a DataGrid column lambda) has a `>` inside the quoted
115
+ // attribute value; a naive `[^<>]*` stopped the match right there, truncating the tag
116
+ // and dropping every attribute after it from validation — and, worse, silently leaving
117
+ // a self-closing element on `openStack` (the truncated match never reaches its real
118
+ // `/>`), producing false nesting diagnostics for markup after it. Each alternative below
119
+ // consumes either a whole double-quoted or single-quoted run, or one plain non-quote/
120
+ // non-bracket character, so `<`/`>` inside a quoted value are absorbed instead of ending
121
+ // the tag.
122
+ const tagRx = /<\/?([A-Z][A-Za-z0-9]*)((?:\s+(?:"[^"]*"|'[^']*'|[^<>"'])*)?)\/?>/g;
123
+ // Stack of currently-open Lumeo elements for nesting-aware parent-child enforcement.
124
+ // Only known Lumeo components are tracked; non-Lumeo wrappers are invisible to this stack.
125
+ const openStack = [];
126
+ let m;
127
+ while ((m = tagRx.exec(markup)) !== null) {
128
+ const tag = m[1];
129
+ const isClose = m[0].startsWith("</");
130
+ // A tag ending in "/>" is self-closing: it opens and immediately closes, so it is
131
+ // never pushed onto openStack and is not a valid ancestor for anything.
132
+ const isSelfClosing = !isClose && m[0].endsWith("/>");
133
+ const known = elementIndex.get(tag.toLowerCase());
134
+ if (!known)
135
+ continue; // not a Lumeo element (could be a user component / HTML — ignore)
136
+ // Closing tag: pop the stack down to and including this element.
137
+ // Tolerate minor mis-nesting gracefully — stop at the first match found from the top;
138
+ // if not found (stray close tag), leave the stack unchanged rather than throwing.
139
+ if (isClose) {
140
+ const tagLower = tag.toLowerCase();
141
+ for (let i = openStack.length - 1; i >= 0; i--) {
142
+ const popped = openStack.pop();
143
+ if (popped.toLowerCase() === tagLower)
144
+ break;
145
+ }
146
+ continue;
147
+ }
148
+ // Parent-child (nesting-aware): a sub-component that reads a CascadingValue from its
149
+ // parent (parentCascades=true) must have that parent as an OPEN ANCESTOR — i.e. the
150
+ // parent must currently be on the open-element stack, not merely present somewhere in
151
+ // the markup. Self-closing tags are checked against the stack at the moment they appear
152
+ // (they are never on the stack themselves). Purely presentational sub-components
153
+ // (parentCascades=false / undefined) are still allowed standalone.
154
+ if (known.parent && known.parentCascades) {
155
+ const parentLower = known.parent.toLowerCase();
156
+ if (!openStack.some((t) => t.toLowerCase() === parentLower)) {
157
+ issues.push({ severity: "error", component: tag, message: `<${tag}> must be used inside <${known.parent}> (it reads a CascadingValue from it). No <${known.parent}> found as an open ancestor.` });
158
+ }
159
+ }
160
+ // Push opening (non-self-closing) tags onto the ancestor stack so they can act as
161
+ // valid parents for subsequent child elements.
162
+ if (!isSelfClosing)
163
+ openStack.push(tag);
164
+ // Parse attributes (best-effort): Name="..." | Name='...' | Name="@expr" | Name=@expr | bare-name
165
+ const attrBlob = m[2] ?? "";
166
+ const attrRx = /([@A-Za-z_][\w-]*)\s*=\s*("(?:[^"]*)"|'(?:[^']*)'|@?[^\s"'<>]+)|([@A-Za-z_][\w-]*)(?=\s|$)/g;
167
+ let am;
168
+ while ((am = attrRx.exec(attrBlob)) !== null) {
169
+ let name = (am[1] ?? am[3] ?? "").trim();
170
+ if (!name)
171
+ continue;
172
+ // Strip Blazor directive prefixes/suffixes: @bind-Foo, @bind-Foo:event, Foo:stopPropagation, @onclick, @attributes, @key, @ref, @bind
173
+ if (name.startsWith("@bind-"))
174
+ name = name.slice("@bind-".length).split(":")[0];
175
+ else if (name.startsWith("@bind"))
176
+ continue; // @bind / @bind:event on inputs — skip
177
+ else if (name.startsWith("@on") || name === "@attributes" || name === "@key" || name === "@ref" || name === "@oninput" || name === "@onchange")
178
+ continue;
179
+ else if (name.startsWith("@"))
180
+ continue; // other directives
181
+ if (name.includes(":"))
182
+ name = name.split(":")[0]; // EventName:stopPropagation, :preventDefault
183
+ if (name === "class" || name === "style" || name === "id")
184
+ continue; // pass-through HTML attrs (Lumeo captures unmatched)
185
+ if (/^data-|^aria-/i.test(name))
186
+ continue; // captured unmatched
187
+ if (GENERIC_TYPE_PARAMS_BY_COMPONENT[tag.toLowerCase()]?.has(name))
188
+ continue; // @typeparam binding (Foo TItem="Employee"), not a [Parameter]
189
+ if (!known.params.has(name)) {
190
+ // Could be a captured-unmatched HTML attr — only flag if it looks like a typo'd Lumeo param (PascalCase).
191
+ if (/^[A-Z]/.test(name)) {
192
+ issues.push({ severity: "warning", component: tag, message: `Unknown parameter \`${name}\` on <${tag}>. Did you mean one of: ${[...known.params].slice(0, 8).join(", ")}…? (Or it's a pass-through HTML attribute, which is allowed.)` });
193
+ }
194
+ continue;
195
+ }
196
+ // Enum value check: Foo="Bar.Baz.Qux" or Foo="Qux". Bind the param to its
197
+ // declared enum TYPE (not a substring guess), then check the value against that
198
+ // enum's allowed values. This catches Size="Large" (Large ∉ Size) and never
199
+ // mis-attributes a value to an unrelated enum that merely shares a substring.
200
+ const rawVal = (am[2] ?? "").replace(/^["']|["']$/g, "").trim();
201
+ if (!rawVal || rawVal.startsWith("@"))
202
+ continue; // dynamic expression — can't statically check
203
+ const boundEnum = known.paramEnum.get(name);
204
+ if (boundEnum) {
205
+ const display = enumTable.get(boundEnum.toLowerCase());
206
+ const lastSegRaw = rawVal.split(".").pop();
207
+ const lastSeg = lastSegRaw.toLowerCase();
208
+ if (!display.some((v) => v.toLowerCase() === lastSeg)) {
209
+ issues.push({ severity: "error", component: tag, message: `\`${name}="${rawVal}"\` — \`${lastSegRaw}\` is not a valid ${boundEnum} value. Allowed: ${display.join(", ")}.` });
210
+ }
211
+ }
212
+ }
213
+ }
214
+ return { ok: !issues.some((i) => i.severity === "error"), issues };
215
+ };
216
+ }