@golemui/gui-mcp 0.16.0 → 0.16.2

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.
@@ -1,371 +1,5 @@
1
- #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
1
  import Ajv2020 from "ajv/dist/2020.js";
9
2
  import addFormats from "ajv-formats";
10
- const SUPPORTED_STRING_FORMATS = /* @__PURE__ */ new Set([
11
- "email",
12
- "hostname",
13
- "ipv4",
14
- "ipv6",
15
- "url",
16
- "uuid",
17
- "date",
18
- "time",
19
- "date-time",
20
- "duration"
21
- ]);
22
- function buildStringValidator(s, required2) {
23
- const v = { type: "string" };
24
- let used = false;
25
- if (required2) {
26
- v.required = true;
27
- used = true;
28
- }
29
- if (typeof s.minLength === "number") {
30
- v.minLength = s.minLength;
31
- used = true;
32
- }
33
- if (typeof s.maxLength === "number") {
34
- v.maxLength = s.maxLength;
35
- used = true;
36
- }
37
- if (typeof s.pattern === "string") {
38
- v.pattern = s.pattern;
39
- used = true;
40
- }
41
- if (typeof s.format === "string" && SUPPORTED_STRING_FORMATS.has(s.format)) {
42
- if (s.format !== "date" && s.format !== "date-time") {
43
- v.format = s.format;
44
- used = true;
45
- }
46
- }
47
- if (s.const !== void 0) {
48
- v.const = s.const;
49
- used = true;
50
- }
51
- if (Array.isArray(s.enum)) {
52
- v.enum = s.enum;
53
- used = true;
54
- }
55
- return used ? v : void 0;
56
- }
57
- function buildNumberValidator(s, required2, t) {
58
- const v = { type: t };
59
- let used = false;
60
- if (required2) {
61
- v.required = true;
62
- used = true;
63
- }
64
- if (typeof s.minimum === "number") {
65
- v.minimum = s.minimum;
66
- used = true;
67
- }
68
- if (typeof s.maximum === "number") {
69
- v.maximum = s.maximum;
70
- used = true;
71
- }
72
- if (typeof s.exclusiveMinimum === "number") {
73
- v.exclusiveMinimum = s.exclusiveMinimum;
74
- used = true;
75
- }
76
- if (typeof s.exclusiveMaximum === "number") {
77
- v.exclusiveMaximum = s.exclusiveMaximum;
78
- used = true;
79
- }
80
- if (typeof s.multipleOf === "number") {
81
- v.multipleOf = s.multipleOf;
82
- used = true;
83
- }
84
- return used ? v : void 0;
85
- }
86
- function buildBooleanValidator(s, required2) {
87
- const v = { type: "boolean" };
88
- let used = false;
89
- if (required2) {
90
- v.required = true;
91
- used = true;
92
- }
93
- if (s.const !== void 0) {
94
- v.const = s.const;
95
- used = true;
96
- }
97
- return used ? v : void 0;
98
- }
99
- const SECRET_HINTS = /(password|secret|api[_-]?key|token)/i;
100
- const LONG_TEXT_MIN_LENGTH = 200;
101
- const SELECT_THRESHOLD = 6;
102
- function jsonSchemaToGui(schema, opts = {}) {
103
- const unmapped = [];
104
- const root = unwrap(schema);
105
- const fields = [];
106
- if (root.type === "object" || root.properties) {
107
- const required2 = new Set(root.required ?? []);
108
- for (const [name, propSchema] of Object.entries(root.properties ?? {})) {
109
- const widget = mapProperty(name, unwrap(propSchema), required2.has(name), "", unmapped);
110
- if (widget) fields.push(widget);
111
- }
112
- } else {
113
- unmapped.push({
114
- path: "",
115
- reason: "Top-level JSON Schema is not an object — only object schemas map to forms."
116
- });
117
- }
118
- if (opts.submitAction !== false) {
119
- fields.push({
120
- kind: "action",
121
- type: "button",
122
- actionType: "submit",
123
- label: opts.submitLabel ?? "Submit",
124
- props: { variant: "filled" }
125
- });
126
- }
127
- const wrapped = opts.layout && opts.layout !== "vertical" ? [
128
- {
129
- kind: "layout",
130
- type: opts.layout === "grid" ? "grid" : "flex",
131
- props: opts.layout === "grid" ? { columnGap: 12, rowGap: 12 } : { direction: "row", gap: 12 },
132
- children: fields
133
- }
134
- ] : fields;
135
- return {
136
- formDefinition: { $schema: "https://golemui.com/schemas/form.schema.json", form: wrapped },
137
- unmapped
138
- };
139
- }
140
- function unwrap(schema) {
141
- if (schema.oneOf?.length === 2) {
142
- const nonNull = schema.oneOf.find((s) => s.type !== "null");
143
- if (nonNull) return { ...nonNull, ...stripBranches(schema) };
144
- }
145
- if (schema.anyOf?.length === 2) {
146
- const nonNull = schema.anyOf.find((s) => s.type !== "null");
147
- if (nonNull) return { ...nonNull, ...stripBranches(schema) };
148
- }
149
- return schema;
150
- }
151
- function stripBranches(s) {
152
- const { oneOf: oneOf2, anyOf, ...rest } = s;
153
- return rest;
154
- }
155
- function mapProperty(name, schema, required2, parentPath, unmapped) {
156
- const path = parentPath ? `${parentPath}.${name}` : name;
157
- const label = humanLabel(schema.title ?? name);
158
- const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
159
- if (Array.isArray(schema.enum) && schema.enum.every((v) => typeof v === "string")) {
160
- return buildEnumField(path, name, label, schema, required2);
161
- }
162
- switch (t) {
163
- case "string":
164
- return buildStringField(path, name, label, schema, required2);
165
- case "number":
166
- case "integer":
167
- return buildNumberField(path, label, schema, required2, t);
168
- case "boolean":
169
- return buildBooleanField(path, label, schema, required2);
170
- case "object":
171
- return buildObjectGroup(path, name, label, schema, unmapped);
172
- case "array":
173
- return buildArrayField(path, name, label, schema, required2, unmapped);
174
- default:
175
- unmapped.push({
176
- path,
177
- reason: `Unsupported JSON Schema type \`${schema.type ?? "undefined"}\`.`
178
- });
179
- return null;
180
- }
181
- }
182
- function buildStringField(path, name, label, schema, required2) {
183
- const validator = buildStringValidator(schema, required2);
184
- const isSecret = SECRET_HINTS.test(name);
185
- const isLong = (schema.maxLength ?? 0) >= LONG_TEXT_MIN_LENGTH;
186
- if (schema.format === "date") {
187
- return cleanFields({ kind: "input", type: "dateInput", path, label, validator });
188
- }
189
- if (schema.format === "date-time") {
190
- return cleanFields({ kind: "input", type: "datePicker", path, label, validator });
191
- }
192
- if (isSecret) {
193
- return cleanFields({ kind: "input", type: "password", path, label, validator });
194
- }
195
- if (isLong) {
196
- return cleanFields({
197
- kind: "input",
198
- type: "textarea",
199
- path,
200
- label,
201
- validator,
202
- props: schema.description ? { hint: schema.description } : void 0
203
- });
204
- }
205
- return cleanFields({
206
- kind: "input",
207
- type: "textinput",
208
- path,
209
- label,
210
- validator,
211
- props: schema.description ? { hint: schema.description } : void 0
212
- });
213
- }
214
- function buildEnumField(path, name, label, schema, required2) {
215
- const values = schema.enum ?? [];
216
- const validator = required2 ? { type: "string", required: true, enum: values } : { type: "string", enum: values };
217
- if (values.length <= SELECT_THRESHOLD) {
218
- return cleanFields({
219
- kind: "input",
220
- type: "select",
221
- path,
222
- label,
223
- validator,
224
- props: {
225
- options: values.map((v) => ({ label: humanLabel(v), value: v }))
226
- }
227
- });
228
- }
229
- return cleanFields({
230
- kind: "input",
231
- type: "dropdown",
232
- path,
233
- label,
234
- validator,
235
- props: {
236
- labelField: "label",
237
- valueField: "value",
238
- items: values.map((v) => ({ label: humanLabel(v), value: v }))
239
- }
240
- });
241
- }
242
- function buildNumberField(path, label, schema, required2, t) {
243
- const validator = buildNumberValidator(schema, required2, t);
244
- return cleanFields({
245
- kind: "input",
246
- type: "number",
247
- path,
248
- label,
249
- validator,
250
- props: schema.description ? { hint: schema.description } : void 0
251
- });
252
- }
253
- function buildBooleanField(path, label, schema, required2) {
254
- const validator = buildBooleanValidator(schema, required2);
255
- return cleanFields({ kind: "input", type: "checkbox", path, label, validator });
256
- }
257
- function buildObjectGroup(path, _name, _label, schema, unmapped) {
258
- const required2 = new Set(schema.required ?? []);
259
- const children = [];
260
- for (const [childName, childSchema] of Object.entries(schema.properties ?? {})) {
261
- const w = mapProperty(childName, unwrap(childSchema), required2.has(childName), path, unmapped);
262
- if (w) children.push(w);
263
- }
264
- if (!children.length) {
265
- unmapped.push({ path, reason: "Object has no mappable properties." });
266
- return null;
267
- }
268
- return {
269
- kind: "layout",
270
- type: "flex",
271
- props: { direction: "column", gap: 8 },
272
- children
273
- };
274
- }
275
- function buildArrayField(path, _name, label, schema, required2, unmapped) {
276
- const items = schema.items ? unwrap(schema.items) : void 0;
277
- if (!items) {
278
- unmapped.push({ path, reason: "Array without `items` schema cannot be mapped." });
279
- return null;
280
- }
281
- if (items.type === "object") {
282
- const templateChildren = [];
283
- const required22 = new Set(items.required ?? []);
284
- const childParent = `${path}.items`;
285
- for (const [childName, childSchema] of Object.entries(items.properties ?? {})) {
286
- const w = mapProperty(
287
- childName,
288
- unwrap(childSchema),
289
- required22.has(childName),
290
- childParent,
291
- unmapped
292
- );
293
- if (w) templateChildren.push(w);
294
- }
295
- if (!templateChildren.length) {
296
- unmapped.push({ path, reason: "Array of objects has no mappable item properties." });
297
- return null;
298
- }
299
- return {
300
- kind: "input",
301
- type: "repeater",
302
- path,
303
- label,
304
- props: {
305
- addLabel: `Add ${singular(label).toLowerCase()}`,
306
- removeLabel: "Remove",
307
- template: {
308
- kind: "layout",
309
- type: "flex",
310
- props: { direction: "column", gap: 8 },
311
- children: templateChildren
312
- }
313
- }
314
- };
315
- }
316
- if (items.type === "string" || items.type === "number" || items.type === "integer") {
317
- const validator = buildArrayValidator(schema, required2);
318
- return cleanFields({
319
- kind: "input",
320
- type: "tags",
321
- path,
322
- label,
323
- validator,
324
- props: schema.description ? { placeholder: "Add and press Enter", hint: schema.description } : { placeholder: "Add and press Enter" }
325
- });
326
- }
327
- unmapped.push({
328
- path,
329
- reason: "Array shape is not mappable (only arrays of objects → repeater, or arrays of strings/numbers → tags)."
330
- });
331
- return null;
332
- }
333
- function buildArrayValidator(s, required2) {
334
- const v = { type: "array" };
335
- let used = false;
336
- if (required2) {
337
- v.required = true;
338
- used = true;
339
- }
340
- if (typeof s.minItems === "number") {
341
- v.minItems = s.minItems;
342
- used = true;
343
- }
344
- if (typeof s.maxItems === "number") {
345
- v.maxItems = s.maxItems;
346
- used = true;
347
- }
348
- if (s.uniqueItems === true) {
349
- v.uniqueItems = true;
350
- used = true;
351
- }
352
- return used ? v : void 0;
353
- }
354
- function humanLabel(name) {
355
- if (!name) return "";
356
- return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim().replace(/^./, (c) => c.toUpperCase());
357
- }
358
- function singular(label) {
359
- if (label.endsWith("ies") && label.length > 3) return label.slice(0, -3) + "y";
360
- if (label.endsWith("s") && !label.endsWith("ss")) return label.slice(0, -1);
361
- return label;
362
- }
363
- function cleanFields(o) {
364
- for (const k of Object.keys(o)) {
365
- if (o[k] === void 0) delete o[k];
366
- }
367
- return o;
368
- }
369
3
  const $schema$u = "https://json-schema.org/draft/2020-12/schema";
370
4
  const $id$u = "https://golemui.com/schemas/common.schema.json";
371
5
  const title$u = "Golem Common Definitions";
@@ -1050,690 +684,1049 @@ function buildAjv() {
1050
684
  ajv.addSchema(schema);
1051
685
  }
1052
686
  }
1053
- return ajv;
687
+ return ajv;
688
+ }
689
+ function getAjv() {
690
+ if (!cachedAjv) {
691
+ cachedAjv = buildAjv();
692
+ }
693
+ return cachedAjv;
694
+ }
695
+ function getFormValidator() {
696
+ if (!cachedFormValidator) {
697
+ cachedFormValidator = getAjv().compile(FORM_SCHEMA);
698
+ }
699
+ return cachedFormValidator;
700
+ }
701
+ const cache = /* @__PURE__ */ new Map();
702
+ const validatorBranchCache = /* @__PURE__ */ new Map();
703
+ const VALIDATOR_TYPE_TO_DEF = {
704
+ string: "stringValidator",
705
+ number: "numberValidator",
706
+ integer: "numberValidator",
707
+ boolean: "booleanValidator",
708
+ array: "arrayValidator",
709
+ custom: "customValidator"
710
+ };
711
+ function getValidatorBranches() {
712
+ return Object.keys(VALIDATOR_TYPE_TO_DEF);
713
+ }
714
+ function getValidatorBranchValidator(validatorType) {
715
+ const defKey = VALIDATOR_TYPE_TO_DEF[validatorType];
716
+ if (!defKey) return null;
717
+ if (validatorBranchCache.has(defKey)) return validatorBranchCache.get(defKey);
718
+ const defs = VALIDATORS_SCHEMA.$defs ?? {};
719
+ const branch = defs[defKey];
720
+ if (!branch) return null;
721
+ const cloned = {
722
+ ...JSON.parse(JSON.stringify(branch)),
723
+ $defs: JSON.parse(JSON.stringify(defs))
724
+ };
725
+ rewriteRefs(cloned, VALIDATORS_SCHEMA.$id);
726
+ const ajv = getAjv();
727
+ const v = ajv.compile(cloned);
728
+ validatorBranchCache.set(defKey, v);
729
+ return v;
730
+ }
731
+ function getShallowWidgetValidator(widgetType) {
732
+ if (cache.has(widgetType)) return cache.get(widgetType);
733
+ const schema = COMPONENT_SCHEMAS[widgetType];
734
+ if (!schema) return null;
735
+ const shallow = makeShallow(schema, schema.$id);
736
+ const ajv = getAjv();
737
+ const validator = ajv.compile(shallow);
738
+ cache.set(widgetType, validator);
739
+ return validator;
740
+ }
741
+ function makeShallow(schema, baseId) {
742
+ const cloned = JSON.parse(JSON.stringify(schema));
743
+ rewriteRefs(cloned, baseId);
744
+ delete cloned["$id"];
745
+ const props = cloned["properties"] ?? {};
746
+ if (props["children"]) {
747
+ props["children"] = { type: "array" };
748
+ }
749
+ if (props["validator"]) {
750
+ props["validator"] = { type: "object" };
751
+ }
752
+ const patternProps = cloned["patternProperties"];
753
+ if (patternProps) {
754
+ for (const key of Object.keys(patternProps)) {
755
+ if (key.startsWith("^validator\\.")) {
756
+ patternProps[key] = { type: "object" };
757
+ }
758
+ }
759
+ }
760
+ const propsField = props["props"];
761
+ if (propsField?.properties?.["template"]) {
762
+ propsField.properties["template"] = { type: "object" };
763
+ }
764
+ return cloned;
765
+ }
766
+ function rewriteRefs(node, baseId) {
767
+ if (node === null || typeof node !== "object") return;
768
+ if (Array.isArray(node)) {
769
+ for (const item of node) rewriteRefs(item, baseId);
770
+ return;
771
+ }
772
+ const obj = node;
773
+ if (typeof obj["$ref"] === "string") {
774
+ const ref = obj["$ref"];
775
+ if (!ref.startsWith("http") && !ref.startsWith("#")) {
776
+ try {
777
+ obj["$ref"] = new URL(ref, baseId).href;
778
+ } catch {
779
+ }
780
+ }
781
+ }
782
+ for (const v of Object.values(obj)) rewriteRefs(v, baseId);
783
+ }
784
+ const STRING_FORMATS = ["email", "hostname", "ipv4", "ipv6", "url", "uuid", "date", "time", "date-time", "duration"];
785
+ const WIDGET_TYPES = Object.keys(COMPONENT_SCHEMAS);
786
+ function levenshtein(a, b) {
787
+ if (a === b) return 0;
788
+ if (!a.length) return b.length;
789
+ if (!b.length) return a.length;
790
+ let prev = new Array(b.length + 1);
791
+ let curr = new Array(b.length + 1);
792
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
793
+ for (let i = 1; i <= a.length; i++) {
794
+ curr[0] = i;
795
+ for (let j = 1; j <= b.length; j++) {
796
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
797
+ const del = (prev[j] ?? 0) + 1;
798
+ const ins = (curr[j - 1] ?? 0) + 1;
799
+ const sub = (prev[j - 1] ?? 0) + cost;
800
+ curr[j] = Math.min(del, ins, sub);
801
+ }
802
+ [prev, curr] = [curr, prev];
803
+ }
804
+ return prev[b.length] ?? 0;
805
+ }
806
+ function nearest(target, candidates) {
807
+ let best;
808
+ let bestDist = Infinity;
809
+ for (const c of candidates) {
810
+ const d = levenshtein(target.toLowerCase(), c.toLowerCase());
811
+ if (d < bestDist) {
812
+ bestDist = d;
813
+ best = c;
814
+ }
815
+ }
816
+ return bestDist <= Math.max(2, Math.floor(target.length / 2)) ? best : void 0;
817
+ }
818
+ function suggestForAdditional(propertyName, instancePath) {
819
+ if (/\/validator(\/|$)/.test(instancePath)) {
820
+ const validatorKeys = ["type", "required", "minLength", "maxLength", "minimum", "maximum", "pattern", "format", "const", "enum", "messages", "minItems", "maxItems", "uniqueItems", "multipleOf", "exclusiveMinimum", "exclusiveMaximum"];
821
+ const guess = nearest(propertyName, validatorKeys);
822
+ if (guess && guess !== propertyName) return `Did you mean \`${guess}\`?`;
823
+ return void 0;
824
+ }
825
+ return void 0;
826
+ }
827
+ function suggestForEnum(value, allowed) {
828
+ if (typeof value !== "string") return void 0;
829
+ const stringAllowed = allowed.filter((v) => typeof v === "string");
830
+ if (!stringAllowed.length) return void 0;
831
+ const guess = nearest(value, stringAllowed);
832
+ if (guess) return `Did you mean \`${guess}\`? Allowed values: ${stringAllowed.map((v) => `\`${v}\``).join(", ")}.`;
833
+ return `Allowed values: ${stringAllowed.map((v) => `\`${v}\``).join(", ")}.`;
834
+ }
835
+ const VALIDATOR_TYPES = ["string", "number", "integer", "boolean", "array", "custom"];
836
+ function suggestForConst(value, allowed, instancePath) {
837
+ if (typeof value !== "string" || typeof allowed !== "string") return void 0;
838
+ if (/^\/form\/\d+(?:\/children\/\d+)*(?:\/props\/template)?\/type$/.test(instancePath)) {
839
+ const guess = nearest(value, WIDGET_TYPES);
840
+ return guess ? `Did you mean \`type: '${guess}'\`?` : `Valid widget types: ${WIDGET_TYPES.map((v) => `\`${v}\``).join(", ")}.`;
841
+ }
842
+ if (instancePath.endsWith("/validator/type")) {
843
+ const guess = nearest(value, VALIDATOR_TYPES);
844
+ return guess ? `Did you mean \`type: '${guess}'\`?` : `Allowed validator types: ${VALIDATOR_TYPES.map((v) => `\`${v}\``).join(", ")}.`;
845
+ }
846
+ return void 0;
847
+ }
848
+ function describe(value) {
849
+ if (value === null) return "null";
850
+ if (Array.isArray(value)) return "array";
851
+ if (typeof value === "object") return "object";
852
+ return JSON.stringify(value);
853
+ }
854
+ function formatAjvErrors(errors, dataRoot) {
855
+ if (!errors?.length) return { errors: [], warnings: [] };
856
+ const collapsed = dataRoot !== void 0 ? collapseOneOfErrors(errors, dataRoot) : { errors, warnings: [] };
857
+ const filtered = collapsed.errors;
858
+ const out = [];
859
+ for (const err of filtered) {
860
+ const path = err.instancePath || "/";
861
+ let message = err.message ?? "invalid value";
862
+ let suggestion;
863
+ switch (err.keyword) {
864
+ case "additionalProperties":
865
+ case "unevaluatedProperties": {
866
+ const prop = err.params.additionalProperty;
867
+ if (prop) {
868
+ message = `Unknown property \`${prop}\` at \`${path}\``;
869
+ suggestion = suggestForAdditional(prop, path);
870
+ }
871
+ break;
872
+ }
873
+ case "required": {
874
+ const prop = err.params.missingProperty;
875
+ if (prop) {
876
+ message = `Missing required property \`${prop}\` at \`${path}\``;
877
+ }
878
+ break;
879
+ }
880
+ case "enum": {
881
+ const allowed = err.params.allowedValues ?? [];
882
+ const v = describe(err.data);
883
+ message = `Value ${v} at \`${path}\` is not one of the allowed values`;
884
+ suggestion = suggestForEnum(err.data, allowed);
885
+ break;
886
+ }
887
+ case "const": {
888
+ const allowed = err.params.allowedValue;
889
+ message = `Expected \`${describe(allowed)}\` at \`${path}\`, got \`${describe(err.data)}\``;
890
+ suggestion = suggestForConst(err.data, allowed, path);
891
+ break;
892
+ }
893
+ case "type": {
894
+ const expected = err.params.type;
895
+ message = `Expected type ${Array.isArray(expected) ? expected.join("|") : expected} at \`${path}\`, got ${describe(err.data)}`;
896
+ break;
897
+ }
898
+ case "oneOf":
899
+ case "anyOf": {
900
+ message = `Value at \`${path}\` does not match any allowed variant`;
901
+ break;
902
+ }
903
+ default:
904
+ message = `${err.message ?? "invalid value"} at \`${path}\``;
905
+ }
906
+ if (path.endsWith("/validator/format") && err.keyword === "enum") {
907
+ suggestion = `Allowed string formats: ${STRING_FORMATS.map((f) => `\`${f}\``).join(", ")}.`;
908
+ }
909
+ out.push({
910
+ path,
911
+ message,
912
+ suggestion,
913
+ keyword: err.keyword,
914
+ params: err.params
915
+ });
916
+ }
917
+ return { errors: dedupe(out), warnings: collapsed.warnings };
918
+ }
919
+ function extractWidgetPath(instancePath) {
920
+ const m = instancePath.match(/^(\/form\/\d+(?:\/children\/\d+)*)/);
921
+ return m ? m[1] : null;
922
+ }
923
+ function pickIntendedBranch(widget) {
924
+ if (!widget || typeof widget !== "object") return null;
925
+ const w = widget;
926
+ const widgetType = typeof w.type === "string" ? w.type : void 0;
927
+ const widgetKind = typeof w.kind === "string" ? w.kind : void 0;
928
+ if (widgetType && COMPONENT_SCHEMAS[widgetType]) {
929
+ return { $id: COMPONENT_SCHEMAS[widgetType].$id, type: widgetType };
930
+ }
931
+ if (!widgetType) return null;
932
+ const kindMatched = Object.entries(COMPONENT_SCHEMAS).filter(([, schema]) => {
933
+ const k = schema["properties"]?.["kind"]?.const;
934
+ return k === widgetKind;
935
+ });
936
+ const pool = kindMatched.length ? kindMatched : Object.entries(COMPONENT_SCHEMAS);
937
+ const candidateTypes = pool.map(([t]) => t);
938
+ const match = nearest(widgetType, candidateTypes);
939
+ if (!match) return null;
940
+ return { $id: COMPONENT_SCHEMAS[match].$id, type: match };
941
+ }
942
+ function collapseOneOfErrors(errors, dataRoot) {
943
+ const topLevel = [];
944
+ for (const err of errors) {
945
+ if (extractWidgetPath(err.instancePath) === null) topLevel.push(err);
946
+ }
947
+ const widgetErrors = [];
948
+ const warnings = [];
949
+ const form = dataRoot?.form;
950
+ if (Array.isArray(form)) {
951
+ form.forEach((widget, i) => {
952
+ collectWidgetErrors(widget, `/form/${i}`, widgetErrors, warnings);
953
+ });
954
+ }
955
+ return { errors: [...topLevel, ...widgetErrors], warnings };
956
+ }
957
+ function collectWidgetErrors(widget, widgetPath, out, warnings) {
958
+ const intended = pickIntendedBranch(widget);
959
+ if (!intended) {
960
+ const widgetType = widget?.type;
961
+ if (typeof widgetType !== "string" || !widgetType) {
962
+ out.push(
963
+ {
964
+ keyword: "required",
965
+ instancePath: widgetPath,
966
+ schemaPath: "",
967
+ params: { missingProperty: "type" },
968
+ message: `Widget at ${widgetPath} is missing or has an invalid \`type\``
969
+ }
970
+ );
971
+ } else {
972
+ warnings.push({
973
+ path: `${widgetPath}/type`,
974
+ keyword: "customWidget",
975
+ message: `Widget type \`${widgetType}\` at \`${widgetPath}\` is not a built-in GolemUI widget — assumed custom. Its props were not validated.`,
976
+ suggestion: "Built-in widget types: " + Object.keys(COMPONENT_SCHEMAS).join(", ") + ". If this is intentional (a custom widget registered via the framework loader), you can ignore this warning.",
977
+ params: { type: widgetType }
978
+ });
979
+ }
980
+ recurseIntoChildren(widget, widgetPath, out, warnings);
981
+ return;
982
+ }
983
+ const validator = getShallowWidgetValidator(intended.type);
984
+ if (validator) {
985
+ validator(widget);
986
+ for (const e of validator.errors ?? []) {
987
+ out.push({
988
+ ...e,
989
+ instancePath: widgetPath + e.instancePath
990
+ });
991
+ }
992
+ }
993
+ const w = widget;
994
+ if (w?.validator && typeof w.validator === "object") {
995
+ collectValidatorErrors(w.validator, `${widgetPath}/validator`, out);
996
+ }
997
+ if (widget && typeof widget === "object" && !Array.isArray(widget)) {
998
+ for (const [key, value] of Object.entries(widget)) {
999
+ if (key.startsWith("validator.") && value && typeof value === "object") {
1000
+ collectValidatorErrors(value, `${widgetPath}/${key}`, out);
1001
+ }
1002
+ }
1003
+ }
1004
+ recurseIntoChildren(widget, widgetPath, out, warnings);
1054
1005
  }
1055
- function getAjv() {
1056
- if (!cachedAjv) {
1057
- cachedAjv = buildAjv();
1006
+ function recurseIntoChildren(widget, widgetPath, out, warnings) {
1007
+ const w = widget;
1008
+ if (Array.isArray(w?.children)) {
1009
+ w.children.forEach((child, i) => {
1010
+ collectWidgetErrors(child, `${widgetPath}/children/${i}`, out, warnings);
1011
+ });
1012
+ }
1013
+ if (w?.type === "repeater" && w.props?.template) {
1014
+ collectWidgetErrors(w.props.template, `${widgetPath}/props/template`, out, warnings);
1058
1015
  }
1059
- return cachedAjv;
1060
1016
  }
1061
- function getFormValidator() {
1062
- if (!cachedFormValidator) {
1063
- cachedFormValidator = getAjv().compile(FORM_SCHEMA);
1017
+ function collectValidatorErrors(validator, path, out) {
1018
+ const v = validator;
1019
+ const t = typeof v.type === "string" ? v.type : null;
1020
+ const branches = getValidatorBranches();
1021
+ const matchedType = t && branches.includes(t) ? t : t ? nearest(t, branches) : void 0;
1022
+ if (!matchedType) {
1023
+ out.push({
1024
+ keyword: "enum",
1025
+ instancePath: `${path}/type`,
1026
+ schemaPath: "",
1027
+ params: { allowedValues: branches },
1028
+ message: `Validator type is not one of ${branches.join(", ")}`,
1029
+ data: t
1030
+ });
1031
+ return;
1032
+ }
1033
+ const compiled = getValidatorBranchValidator(matchedType);
1034
+ if (!compiled) return;
1035
+ compiled(validator);
1036
+ for (const e of compiled.errors ?? []) {
1037
+ out.push({ ...e, instancePath: path + e.instancePath });
1064
1038
  }
1065
- return cachedFormValidator;
1066
1039
  }
1067
- const cache = /* @__PURE__ */ new Map();
1068
- const validatorBranchCache = /* @__PURE__ */ new Map();
1069
- const VALIDATOR_TYPE_TO_DEF = {
1070
- string: "stringValidator",
1071
- number: "numberValidator",
1072
- integer: "numberValidator",
1073
- boolean: "booleanValidator",
1074
- array: "arrayValidator",
1075
- custom: "customValidator"
1076
- };
1077
- function getValidatorBranches() {
1078
- return Object.keys(VALIDATOR_TYPE_TO_DEF);
1040
+ function dedupe(errors) {
1041
+ const seen = /* @__PURE__ */ new Set();
1042
+ const result = [];
1043
+ for (const e of errors) {
1044
+ const key = `${e.path}|${e.keyword}|${e.message}`;
1045
+ if (seen.has(key)) continue;
1046
+ seen.add(key);
1047
+ result.push(e);
1048
+ }
1049
+ return result;
1079
1050
  }
1080
- function getValidatorBranchValidator(validatorType) {
1081
- const defKey = VALIDATOR_TYPE_TO_DEF[validatorType];
1082
- if (!defKey) return null;
1083
- if (validatorBranchCache.has(defKey)) return validatorBranchCache.get(defKey);
1084
- const defs = VALIDATORS_SCHEMA.$defs ?? {};
1085
- const branch = defs[defKey];
1086
- if (!branch) return null;
1087
- const cloned = {
1088
- ...JSON.parse(JSON.stringify(branch)),
1089
- $defs: JSON.parse(JSON.stringify(defs))
1090
- };
1091
- rewriteRefs(cloned, VALIDATORS_SCHEMA.$id);
1092
- const ajv = getAjv();
1093
- const v = ajv.compile(cloned);
1094
- validatorBranchCache.set(defKey, v);
1095
- return v;
1051
+ function lintReactiveExpressions(formDefinition) {
1052
+ const findings = [];
1053
+ walk$1(formDefinition, "", findings);
1054
+ return findings;
1096
1055
  }
1097
- function getShallowWidgetValidator(widgetType) {
1098
- if (cache.has(widgetType)) return cache.get(widgetType);
1099
- const schema = COMPONENT_SCHEMAS[widgetType];
1100
- if (!schema) return null;
1101
- const shallow = makeShallow(schema, schema.$id);
1102
- const ajv = getAjv();
1103
- const validator = ajv.compile(shallow);
1104
- cache.set(widgetType, validator);
1105
- return validator;
1056
+ function walk$1(node, path, out) {
1057
+ if (node === null || typeof node !== "object") return;
1058
+ if (Array.isArray(node)) {
1059
+ node.forEach((item, i) => walk$1(item, `${path}/${i}`, out));
1060
+ return;
1061
+ }
1062
+ const obj = node;
1063
+ for (const key of ["include", "exclude"]) {
1064
+ const child = obj[key];
1065
+ if (child && typeof child === "object" && "when" in child) {
1066
+ const expr = child.when;
1067
+ if (typeof expr === "string") {
1068
+ checkExpression(expr, `${path}/${key}/when`, out);
1069
+ }
1070
+ }
1071
+ }
1072
+ if (path === "" && obj["states"] && typeof obj["states"] === "object") {
1073
+ for (const [name, expr] of Object.entries(obj["states"])) {
1074
+ if (typeof expr === "string") {
1075
+ checkExpression(expr, `/states/${name}`, out);
1076
+ }
1077
+ }
1078
+ }
1079
+ for (const [k, v] of Object.entries(obj)) {
1080
+ if (v && typeof v === "object" && !Array.isArray(v) && "when" in v) {
1081
+ const expr = v.when;
1082
+ if (typeof expr === "string" && k !== "include" && k !== "exclude") {
1083
+ checkExpression(expr, `${path}/${k}/when`, out);
1084
+ }
1085
+ }
1086
+ if (typeof v === "object" && v !== null) {
1087
+ walk$1(v, `${path}/${k}`, out);
1088
+ }
1089
+ }
1106
1090
  }
1107
- function makeShallow(schema, baseId) {
1108
- const cloned = JSON.parse(JSON.stringify(schema));
1109
- rewriteRefs(cloned, baseId);
1110
- delete cloned["$id"];
1111
- const props = cloned["properties"] ?? {};
1112
- if (props["children"]) {
1113
- props["children"] = { type: "array" };
1091
+ function checkExpression(expr, path, out) {
1092
+ const trimmed = expr.trim();
1093
+ if (!trimmed) {
1094
+ out.push({ path, expression: expr, message: "Expression is empty." });
1095
+ return;
1114
1096
  }
1115
- if (props["validator"]) {
1116
- props["validator"] = { type: "object" };
1097
+ const stack = [];
1098
+ const pairs = { ")": "(", "]": "[", "}": "{" };
1099
+ for (const ch of trimmed) {
1100
+ if ("([{".includes(ch)) stack.push(ch);
1101
+ else if (")]}".includes(ch)) {
1102
+ if (stack.pop() !== pairs[ch]) {
1103
+ out.push({
1104
+ path,
1105
+ expression: expr,
1106
+ message: `Unbalanced \`${ch}\` in reactive expression.`
1107
+ });
1108
+ return;
1109
+ }
1110
+ }
1117
1111
  }
1118
- const patternProps = cloned["patternProperties"];
1119
- if (patternProps) {
1120
- for (const key of Object.keys(patternProps)) {
1121
- if (key.startsWith("^validator\\.")) {
1122
- patternProps[key] = { type: "object" };
1112
+ if (stack.length) {
1113
+ out.push({
1114
+ path,
1115
+ expression: expr,
1116
+ message: `Unclosed \`${stack[stack.length - 1]}\` in reactive expression.`
1117
+ });
1118
+ return;
1119
+ }
1120
+ if (!/\$form\b|\$meta\b|\$formIsInvalid\b/.test(trimmed)) {
1121
+ out.push({
1122
+ path,
1123
+ expression: expr,
1124
+ message: "Expression does not reference `$form`, `$meta`, or `$formIsInvalid`.",
1125
+ suggestion: "GolemUI expressions read form data via `$form.fieldName`, form metadata via `$meta.key`, or the built-in `$formIsInvalid` boolean. Did you forget the prefix?"
1126
+ });
1127
+ }
1128
+ if (/(?<![=!<>])=(?![=>])/.test(trimmed)) {
1129
+ out.push({
1130
+ path,
1131
+ expression: expr,
1132
+ message: "Expression contains a single `=` (assignment). Reactive expressions are read-only.",
1133
+ suggestion: "Use `===` for equality comparison."
1134
+ });
1135
+ }
1136
+ if (/(?<![&])&(?![&])/.test(trimmed) || /(?<![|])\|(?![|])/.test(trimmed)) {
1137
+ out.push({
1138
+ path,
1139
+ expression: expr,
1140
+ message: "Expression contains a single `&` or `|` (bitwise).",
1141
+ suggestion: "Use `&&` for logical AND, `||` for logical OR."
1142
+ });
1143
+ }
1144
+ if (/(?<![=!])==(?!=)/.test(trimmed) || /(?<![!])!=(?!=)/.test(trimmed)) {
1145
+ out.push({
1146
+ path,
1147
+ expression: expr,
1148
+ message: "Expression uses loose equality (`==` or `!=`).",
1149
+ suggestion: "Use strict equality `===` / `!==` to avoid type coercion (e.g. `$form.x !== undefined` rather than `$form.x != null`)."
1150
+ });
1151
+ }
1152
+ if (/(?<![=!])!\s*\$(?:form|meta)\b/.test(trimmed)) {
1153
+ out.push({
1154
+ path,
1155
+ expression: expr,
1156
+ message: "Expression negates a `$form`/`$meta` reference (relies on truthy/falsy coercion).",
1157
+ suggestion: 'Form data values can be `undefined`. Pick the case you actually mean and write it explicitly — `$form.x === undefined`, `$form.x === null`, `$form.x === 0`, `$form.x === ""` — instead of `!$form.x`.'
1158
+ });
1159
+ }
1160
+ const refChainRe = /\$(?:form|meta)\b((?:\.[\w?]+)*)/g;
1161
+ let chainFlagged = false;
1162
+ let chainMatch;
1163
+ while ((chainMatch = refChainRe.exec(trimmed)) !== null) {
1164
+ const chain = chainMatch[1];
1165
+ if (!chain) continue;
1166
+ const segments = chain.split(".").filter(Boolean);
1167
+ let unsafe = false;
1168
+ for (let i = 0; i < segments.length - 1; i++) {
1169
+ const segment = segments[i];
1170
+ if (!segment || !segment.endsWith("?")) {
1171
+ unsafe = true;
1172
+ break;
1123
1173
  }
1124
1174
  }
1175
+ if (unsafe && !chainFlagged) {
1176
+ out.push({
1177
+ path,
1178
+ expression: expr,
1179
+ message: "Expression chains nested property access without optional chaining (e.g. `$form.user.name`).",
1180
+ suggestion: "Treat every nested property as possibly `undefined`. Use `?.` between segments: `$form.user?.name` instead of `$form.user.name`. The runtime throws if `$form.user` is undefined."
1181
+ });
1182
+ chainFlagged = true;
1183
+ }
1125
1184
  }
1126
- const propsField = props["props"];
1127
- if (propsField?.properties?.["template"]) {
1128
- propsField.properties["template"] = { type: "object" };
1185
+ const refOnly = /^\$(?:form|meta)(?:\.[\w?]+)*$/;
1186
+ const refBeforeBool = /\$(?:form|meta)(?:\.[\w?]+)*\s*(?:&&|\|\||\?(?![.?]))/;
1187
+ const refAfterBool = /(?:&&|\|\|)\s*\$(?:form|meta)(?:\.[\w?]+)*\s*$/;
1188
+ if (refOnly.test(trimmed) || refBeforeBool.test(trimmed) || refAfterBool.test(trimmed)) {
1189
+ out.push({
1190
+ path,
1191
+ expression: expr,
1192
+ message: "Expression uses `$form`/`$meta` directly as a boolean (relies on truthy/falsy coercion).",
1193
+ suggestion: 'Form data values can be `undefined`. Compare explicitly: `$form.x !== undefined`, `$form.x === "value"`, `$form.items?.length > 0`. For default values use nullish coalescing: `$form.x ?? defaultValue`.'
1194
+ });
1195
+ }
1196
+ const refForCmpRe = /\$(?:form|meta)(?:\.[\w?]+)+/g;
1197
+ let r5Flagged = false;
1198
+ let cmpMatch;
1199
+ while ((cmpMatch = refForCmpRe.exec(trimmed)) !== null) {
1200
+ const start = cmpMatch.index;
1201
+ const end = start + cmpMatch[0].length;
1202
+ const before = trimmed.slice(0, start).trimEnd();
1203
+ const after = trimmed.slice(end).trimStart();
1204
+ const opAfter = /^(?:<=?|>=?|[+\-*/%])(?!=)/.test(after);
1205
+ const opBefore = /(?<![=!])[<>+\-*/%]$/.test(before);
1206
+ if (!(opAfter || opBefore)) continue;
1207
+ if (/&&|\|\|/.test(before)) continue;
1208
+ if (!r5Flagged) {
1209
+ out.push({
1210
+ path,
1211
+ expression: expr,
1212
+ message: "Expression applies a comparison or arithmetic operator to a `$form`/`$meta` reference whose leaf may be `undefined`.",
1213
+ suggestion: "Guard the value first: `$form.x !== undefined && $form.x > 180`. Or default it: `($form.x ?? 0) > 180`. Strict equality (`$form.x === 180`) is also safe since it evaluates to `false` when undefined."
1214
+ });
1215
+ r5Flagged = true;
1216
+ }
1129
1217
  }
1130
- return cloned;
1131
1218
  }
1132
- function rewriteRefs(node, baseId) {
1133
- if (node === null || typeof node !== "object") return;
1219
+ function lintStringInterpolations(formDefinition) {
1220
+ const findings = [];
1221
+ walk(formDefinition, "", findings);
1222
+ return findings;
1223
+ }
1224
+ const SLOT_REGEX = /\{\{([^}]*(?:\}[^}]+)*)\}\}/g;
1225
+ function walk(node, path, out) {
1226
+ if (node === null || typeof node !== "object") {
1227
+ return;
1228
+ }
1134
1229
  if (Array.isArray(node)) {
1135
- for (const item of node) rewriteRefs(item, baseId);
1230
+ node.forEach((item, i) => walk(item, `${path}/${i}`, out));
1136
1231
  return;
1137
1232
  }
1138
1233
  const obj = node;
1139
- if (typeof obj["$ref"] === "string") {
1140
- const ref = obj["$ref"];
1141
- if (!ref.startsWith("http") && !ref.startsWith("#")) {
1142
- try {
1143
- obj["$ref"] = new URL(ref, baseId).href;
1144
- } catch {
1234
+ const isTranslationConfig = typeof obj["key"] === "string" && obj["params"] !== null && typeof obj["params"] === "object" && !Array.isArray(obj["params"]);
1235
+ if (isTranslationConfig) {
1236
+ const params = obj["params"];
1237
+ for (const [paramKey, paramValue] of Object.entries(params)) {
1238
+ if (typeof paramValue === "string") {
1239
+ checkParamExpression(paramValue, `${path}/params/${paramKey}`, out);
1145
1240
  }
1146
1241
  }
1147
1242
  }
1148
- for (const v of Object.values(obj)) rewriteRefs(v, baseId);
1149
- }
1150
- const STRING_FORMATS = ["email", "hostname", "ipv4", "ipv6", "url", "uuid", "date", "time", "date-time", "duration"];
1151
- const WIDGET_TYPES = Object.keys(COMPONENT_SCHEMAS);
1152
- function levenshtein(a, b) {
1153
- if (a === b) return 0;
1154
- if (!a.length) return b.length;
1155
- if (!b.length) return a.length;
1156
- let prev = new Array(b.length + 1);
1157
- let curr = new Array(b.length + 1);
1158
- for (let j = 0; j <= b.length; j++) prev[j] = j;
1159
- for (let i = 1; i <= a.length; i++) {
1160
- curr[0] = i;
1161
- for (let j = 1; j <= b.length; j++) {
1162
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1163
- const del = (prev[j] ?? 0) + 1;
1164
- const ins = (curr[j - 1] ?? 0) + 1;
1165
- const sub = (prev[j - 1] ?? 0) + cost;
1166
- curr[j] = Math.min(del, ins, sub);
1243
+ for (const [key, value] of Object.entries(obj)) {
1244
+ if (key === "params" && isTranslationConfig) {
1245
+ continue;
1167
1246
  }
1168
- [prev, curr] = [curr, prev];
1169
- }
1170
- return prev[b.length] ?? 0;
1171
- }
1172
- function nearest(target, candidates) {
1173
- let best;
1174
- let bestDist = Infinity;
1175
- for (const c of candidates) {
1176
- const d = levenshtein(target.toLowerCase(), c.toLowerCase());
1177
- if (d < bestDist) {
1178
- bestDist = d;
1179
- best = c;
1247
+ if (key === "defaultValue" || key.startsWith("defaultValue.")) {
1248
+ continue;
1249
+ }
1250
+ const childPath = `${path}/${key}`;
1251
+ if (typeof value === "string") {
1252
+ checkTemplate(value, childPath, out);
1253
+ } else {
1254
+ walk(value, childPath, out);
1180
1255
  }
1181
1256
  }
1182
- return bestDist <= Math.max(2, Math.floor(target.length / 2)) ? best : void 0;
1183
1257
  }
1184
- function suggestForAdditional(propertyName, instancePath) {
1185
- if (/\/validator(\/|$)/.test(instancePath)) {
1186
- const validatorKeys = ["type", "required", "minLength", "maxLength", "minimum", "maximum", "pattern", "format", "const", "enum", "messages", "minItems", "maxItems", "uniqueItems", "multipleOf", "exclusiveMinimum", "exclusiveMaximum"];
1187
- const guess = nearest(propertyName, validatorKeys);
1188
- if (guess && guess !== propertyName) return `Did you mean \`${guess}\`?`;
1189
- return void 0;
1258
+ function checkParamExpression(value, path, out) {
1259
+ if (value.includes("{{") || value.includes("}}")) {
1260
+ out.push({
1261
+ path,
1262
+ slot: value,
1263
+ message: "i18n param expression should not use `{{` / `}}` delimiters.",
1264
+ suggestion: 'Use a bare expression: `"$form.fieldName"` not `"{{$form.fieldName}}"`.'
1265
+ });
1266
+ return;
1267
+ }
1268
+ if (value.startsWith("$") && /(?<![=!<>])=(?![=>])/.test(value)) {
1269
+ out.push({
1270
+ path,
1271
+ slot: value,
1272
+ message: "i18n param expression contains a single `=` (assignment).",
1273
+ suggestion: "Param expressions are read-only. Did you mean `===` for equality?"
1274
+ });
1190
1275
  }
1191
- return void 0;
1192
- }
1193
- function suggestForEnum(value, allowed) {
1194
- if (typeof value !== "string") return void 0;
1195
- const stringAllowed = allowed.filter((v) => typeof v === "string");
1196
- if (!stringAllowed.length) return void 0;
1197
- const guess = nearest(value, stringAllowed);
1198
- if (guess) return `Did you mean \`${guess}\`? Allowed values: ${stringAllowed.map((v) => `\`${v}\``).join(", ")}.`;
1199
- return `Allowed values: ${stringAllowed.map((v) => `\`${v}\``).join(", ")}.`;
1200
1276
  }
1201
- const VALIDATOR_TYPES = ["string", "number", "integer", "boolean", "array", "custom"];
1202
- function suggestForConst(value, allowed, instancePath) {
1203
- if (typeof value !== "string" || typeof allowed !== "string") return void 0;
1204
- if (/^\/form\/\d+(?:\/children\/\d+)*(?:\/props\/template)?\/type$/.test(instancePath)) {
1205
- const guess = nearest(value, WIDGET_TYPES);
1206
- return guess ? `Did you mean \`type: '${guess}'\`?` : `Valid widget types: ${WIDGET_TYPES.map((v) => `\`${v}\``).join(", ")}.`;
1277
+ function checkTemplate(value, path, out) {
1278
+ const openCount = (value.match(/\{\{/g) ?? []).length;
1279
+ const closeCount = (value.match(/\}\}/g) ?? []).length;
1280
+ if (openCount !== closeCount) {
1281
+ out.push({
1282
+ path,
1283
+ slot: value,
1284
+ message: "String template has unbalanced `{{` / `}}` delimiters.",
1285
+ suggestion: "Every `{{` must have a matching `}}`."
1286
+ });
1287
+ return;
1207
1288
  }
1208
- if (instancePath.endsWith("/validator/type")) {
1209
- const guess = nearest(value, VALIDATOR_TYPES);
1210
- return guess ? `Did you mean \`type: '${guess}'\`?` : `Allowed validator types: ${VALIDATOR_TYPES.map((v) => `\`${v}\``).join(", ")}.`;
1289
+ if (openCount === 0) return;
1290
+ let match;
1291
+ SLOT_REGEX.lastIndex = 0;
1292
+ while ((match = SLOT_REGEX.exec(value)) !== null) {
1293
+ const slot = match[0];
1294
+ const expr = match[1];
1295
+ checkSlot(expr, slot, path, out);
1211
1296
  }
1212
- return void 0;
1213
- }
1214
- function describe(value) {
1215
- if (value === null) return "null";
1216
- if (Array.isArray(value)) return "array";
1217
- if (typeof value === "object") return "object";
1218
- return JSON.stringify(value);
1219
1297
  }
1220
- function formatAjvErrors(errors, dataRoot) {
1221
- if (!errors?.length) return { errors: [], warnings: [] };
1222
- const collapsed = dataRoot !== void 0 ? collapseOneOfErrors(errors, dataRoot) : { errors, warnings: [] };
1223
- const filtered = collapsed.errors;
1224
- const out = [];
1225
- for (const err2 of filtered) {
1226
- const path = err2.instancePath || "/";
1227
- let message = err2.message ?? "invalid value";
1228
- let suggestion;
1229
- switch (err2.keyword) {
1230
- case "additionalProperties":
1231
- case "unevaluatedProperties": {
1232
- const prop = err2.params.additionalProperty;
1233
- if (prop) {
1234
- message = `Unknown property \`${prop}\` at \`${path}\``;
1235
- suggestion = suggestForAdditional(prop, path);
1236
- }
1237
- break;
1238
- }
1239
- case "required": {
1240
- const prop = err2.params.missingProperty;
1241
- if (prop) {
1242
- message = `Missing required property \`${prop}\` at \`${path}\``;
1243
- }
1244
- break;
1245
- }
1246
- case "enum": {
1247
- const allowed = err2.params.allowedValues ?? [];
1248
- const v = describe(err2.data);
1249
- message = `Value ${v} at \`${path}\` is not one of the allowed values`;
1250
- suggestion = suggestForEnum(err2.data, allowed);
1251
- break;
1252
- }
1253
- case "const": {
1254
- const allowed = err2.params.allowedValue;
1255
- message = `Expected \`${describe(allowed)}\` at \`${path}\`, got \`${describe(err2.data)}\``;
1256
- suggestion = suggestForConst(err2.data, allowed, path);
1257
- break;
1258
- }
1259
- case "type": {
1260
- const expected = err2.params.type;
1261
- message = `Expected type ${Array.isArray(expected) ? expected.join("|") : expected} at \`${path}\`, got ${describe(err2.data)}`;
1262
- break;
1263
- }
1264
- case "oneOf":
1265
- case "anyOf": {
1266
- message = `Value at \`${path}\` does not match any allowed variant`;
1267
- break;
1268
- }
1269
- default:
1270
- message = `${err2.message ?? "invalid value"} at \`${path}\``;
1271
- }
1272
- if (path.endsWith("/validator/format") && err2.keyword === "enum") {
1273
- suggestion = `Allowed string formats: ${STRING_FORMATS.map((f) => `\`${f}\``).join(", ")}.`;
1274
- }
1298
+ function checkSlot(expr, slot, path, out) {
1299
+ const trimmed = expr.trim();
1300
+ if (!trimmed) {
1301
+ out.push({
1302
+ path,
1303
+ slot,
1304
+ message: "String interpolation slot is empty.",
1305
+ suggestion: "Add an expression, e.g. `{{$form.fieldName}}`."
1306
+ });
1307
+ return;
1308
+ }
1309
+ if (trimmed.includes("{{")) {
1310
+ out.push({
1311
+ path,
1312
+ slot,
1313
+ message: "Interpolation slot contains nested `{{`.",
1314
+ suggestion: "Slots cannot be nested. Check for a copy-paste error."
1315
+ });
1316
+ return;
1317
+ }
1318
+ if (!/\$form\b|\$meta\b|\$errors\b|\$formIsInvalid\b/.test(trimmed)) {
1319
+ out.push({
1320
+ path,
1321
+ slot,
1322
+ message: "Interpolation slot does not reference `$form`, `$meta`, `$errors`, or `$formIsInvalid`.",
1323
+ suggestion: "GolemUI template slots read data via `$form.fieldName`, metadata via `$meta.key`, validation errors via `$errors.fieldName`, or the built-in `$formIsInvalid` boolean."
1324
+ });
1325
+ }
1326
+ if (/(?<![=!<>])=(?![=>])/.test(trimmed)) {
1275
1327
  out.push({
1276
1328
  path,
1277
- message,
1278
- suggestion,
1279
- keyword: err2.keyword,
1280
- params: err2.params
1329
+ slot,
1330
+ message: "Interpolation slot contains a single `=` (assignment).",
1331
+ suggestion: "Template slots are read-only. Did you mean `===` for equality?"
1281
1332
  });
1282
1333
  }
1283
- return { errors: dedupe(out), warnings: collapsed.warnings };
1284
- }
1285
- function extractWidgetPath(instancePath) {
1286
- const m = instancePath.match(/^(\/form\/\d+(?:\/children\/\d+)*)/);
1287
- return m ? m[1] : null;
1288
1334
  }
1289
- function pickIntendedBranch(widget) {
1290
- if (!widget || typeof widget !== "object") return null;
1291
- const w = widget;
1292
- const widgetType = typeof w.type === "string" ? w.type : void 0;
1293
- const widgetKind = typeof w.kind === "string" ? w.kind : void 0;
1294
- if (widgetType && COMPONENT_SCHEMAS[widgetType]) {
1295
- return { $id: COMPONENT_SCHEMAS[widgetType].$id, type: widgetType };
1335
+ function validateFormDefinition(input) {
1336
+ const validate = getFormValidator();
1337
+ const ajvOk = validate(input.formDefinition);
1338
+ const { errors, warnings } = formatAjvErrors(validate.errors, input.formDefinition);
1339
+ const expressionWarnings = lintReactiveExpressions(input.formDefinition);
1340
+ const interpolationWarnings = lintStringInterpolations(input.formDefinition);
1341
+ if (!ajvOk && errors.length === 0 && warnings.length === 0) {
1342
+ errors.push({
1343
+ path: "/",
1344
+ keyword: "oneOf",
1345
+ message: "Form failed schema validation but no specific error could be localized. The form may contain a widget at a position the form schema does not allow, or a structural shape that our targeted validator missed. Verify each widget against its `get_widget_spec` entry."
1346
+ });
1296
1347
  }
1297
- if (!widgetType) return null;
1298
- const kindMatched = Object.entries(COMPONENT_SCHEMAS).filter(([, schema]) => {
1299
- const k = schema["properties"]?.["kind"]?.const;
1300
- return k === widgetKind;
1301
- });
1302
- const pool = kindMatched.length ? kindMatched : Object.entries(COMPONENT_SCHEMAS);
1303
- const candidateTypes = pool.map(([t]) => t);
1304
- const match = nearest(widgetType, candidateTypes);
1305
- if (!match) return null;
1306
- return { $id: COMPONENT_SCHEMAS[match].$id, type: match };
1348
+ return {
1349
+ valid: errors.length === 0,
1350
+ errors,
1351
+ warnings,
1352
+ expressionWarnings,
1353
+ interpolationWarnings
1354
+ };
1307
1355
  }
1308
- function collapseOneOfErrors(errors, dataRoot) {
1309
- const topLevel = [];
1310
- for (const err2 of errors) {
1311
- if (extractWidgetPath(err2.instancePath) === null) topLevel.push(err2);
1356
+ const VALIDATE_FORM_DEFINITION_TOOL = {
1357
+ name: "validate_form_definition",
1358
+ description: "Validate a GolemUI form definition against the bundled JSON Schemas. Use this AFTER generating or modifying a form definition to guarantee it is correct before the user pastes it into their codebase. Returns `{ valid, errors, warnings, expressionWarnings, interpolationWarnings }`. Hard mistakes (typos in widget `type`, missing required props, invalid validator shapes) show up in `errors` and flip `valid` to false. Likely-custom widgets (a `type` value that isn't a built-in and isn't close to one) show up in `warnings` instead — they don't affect `valid`. Reactive expressions (`include.when`, `disabled.when`, etc.) are linted separately into `expressionWarnings`. String interpolation templates (`{{$form.x}}`, `{{$meta.y}}`, expressions like `{{$form.count + 1}}`, etc.) in widget props, and bare expressions inside i18n `params` objects, are linted into `interpolationWarnings`.",
1359
+ inputSchema: {
1360
+ type: "object",
1361
+ properties: {
1362
+ formDefinition: {
1363
+ type: "object",
1364
+ additionalProperties: true,
1365
+ description: "The full GolemUI form definition object, shaped as `{ form: [...widgets], states?: {...} }`. Pass the JSON object, not a stringified version."
1366
+ }
1367
+ },
1368
+ required: ["formDefinition"]
1312
1369
  }
1313
- const widgetErrors = [];
1314
- const warnings = [];
1315
- const form = dataRoot?.form;
1316
- if (Array.isArray(form)) {
1317
- form.forEach((widget, i) => {
1318
- collectWidgetErrors(widget, `/form/${i}`, widgetErrors, warnings);
1319
- });
1370
+ };
1371
+ const SUPPORTED_STRING_FORMATS = /* @__PURE__ */ new Set([
1372
+ "email",
1373
+ "hostname",
1374
+ "ipv4",
1375
+ "ipv6",
1376
+ "url",
1377
+ "uuid",
1378
+ "date",
1379
+ "time",
1380
+ "date-time",
1381
+ "duration"
1382
+ ]);
1383
+ function buildStringValidator(s, required2) {
1384
+ const v = { type: "string" };
1385
+ let used = false;
1386
+ if (required2) {
1387
+ v.required = true;
1388
+ used = true;
1320
1389
  }
1321
- return { errors: [...topLevel, ...widgetErrors], warnings };
1322
- }
1323
- function collectWidgetErrors(widget, widgetPath, out, warnings) {
1324
- const intended = pickIntendedBranch(widget);
1325
- if (!intended) {
1326
- const widgetType = widget?.type;
1327
- if (typeof widgetType !== "string" || !widgetType) {
1328
- out.push(
1329
- {
1330
- keyword: "required",
1331
- instancePath: widgetPath,
1332
- schemaPath: "",
1333
- params: { missingProperty: "type" },
1334
- message: `Widget at ${widgetPath} is missing or has an invalid \`type\``
1335
- }
1336
- );
1337
- } else {
1338
- warnings.push({
1339
- path: `${widgetPath}/type`,
1340
- keyword: "customWidget",
1341
- message: `Widget type \`${widgetType}\` at \`${widgetPath}\` is not a built-in GolemUI widget — assumed custom. Its props were not validated.`,
1342
- suggestion: "Built-in widget types: " + Object.keys(COMPONENT_SCHEMAS).join(", ") + ". If this is intentional (a custom widget registered via the framework loader), you can ignore this warning.",
1343
- params: { type: widgetType }
1344
- });
1345
- }
1346
- recurseIntoChildren(widget, widgetPath, out, warnings);
1347
- return;
1390
+ if (typeof s.minLength === "number") {
1391
+ v.minLength = s.minLength;
1392
+ used = true;
1348
1393
  }
1349
- const validator = getShallowWidgetValidator(intended.type);
1350
- if (validator) {
1351
- validator(widget);
1352
- for (const e of validator.errors ?? []) {
1353
- out.push({
1354
- ...e,
1355
- instancePath: widgetPath + e.instancePath
1356
- });
1357
- }
1394
+ if (typeof s.maxLength === "number") {
1395
+ v.maxLength = s.maxLength;
1396
+ used = true;
1358
1397
  }
1359
- const w = widget;
1360
- if (w?.validator && typeof w.validator === "object") {
1361
- collectValidatorErrors(w.validator, `${widgetPath}/validator`, out);
1398
+ if (typeof s.pattern === "string") {
1399
+ v.pattern = s.pattern;
1400
+ used = true;
1362
1401
  }
1363
- if (widget && typeof widget === "object" && !Array.isArray(widget)) {
1364
- for (const [key, value] of Object.entries(widget)) {
1365
- if (key.startsWith("validator.") && value && typeof value === "object") {
1366
- collectValidatorErrors(value, `${widgetPath}/${key}`, out);
1367
- }
1402
+ if (typeof s.format === "string" && SUPPORTED_STRING_FORMATS.has(s.format)) {
1403
+ if (s.format !== "date" && s.format !== "date-time") {
1404
+ v.format = s.format;
1405
+ used = true;
1368
1406
  }
1369
1407
  }
1370
- recurseIntoChildren(widget, widgetPath, out, warnings);
1371
- }
1372
- function recurseIntoChildren(widget, widgetPath, out, warnings) {
1373
- const w = widget;
1374
- if (Array.isArray(w?.children)) {
1375
- w.children.forEach((child, i) => {
1376
- collectWidgetErrors(child, `${widgetPath}/children/${i}`, out, warnings);
1377
- });
1408
+ if (s.const !== void 0) {
1409
+ v.const = s.const;
1410
+ used = true;
1378
1411
  }
1379
- if (w?.type === "repeater" && w.props?.template) {
1380
- collectWidgetErrors(w.props.template, `${widgetPath}/props/template`, out, warnings);
1412
+ if (Array.isArray(s.enum)) {
1413
+ v.enum = s.enum;
1414
+ used = true;
1381
1415
  }
1416
+ return used ? v : void 0;
1382
1417
  }
1383
- function collectValidatorErrors(validator, path, out) {
1384
- const v = validator;
1385
- const t = typeof v.type === "string" ? v.type : null;
1386
- const branches = getValidatorBranches();
1387
- const matchedType = t && branches.includes(t) ? t : t ? nearest(t, branches) : void 0;
1388
- if (!matchedType) {
1389
- out.push({
1390
- keyword: "enum",
1391
- instancePath: `${path}/type`,
1392
- schemaPath: "",
1393
- params: { allowedValues: branches },
1394
- message: `Validator type is not one of ${branches.join(", ")}`,
1395
- data: t
1396
- });
1397
- return;
1418
+ function buildNumberValidator(s, required2, t) {
1419
+ const v = { type: t };
1420
+ let used = false;
1421
+ if (required2) {
1422
+ v.required = true;
1423
+ used = true;
1398
1424
  }
1399
- const compiled = getValidatorBranchValidator(matchedType);
1400
- if (!compiled) return;
1401
- compiled(validator);
1402
- for (const e of compiled.errors ?? []) {
1403
- out.push({ ...e, instancePath: path + e.instancePath });
1425
+ if (typeof s.minimum === "number") {
1426
+ v.minimum = s.minimum;
1427
+ used = true;
1404
1428
  }
1405
- }
1406
- function dedupe(errors) {
1407
- const seen = /* @__PURE__ */ new Set();
1408
- const result = [];
1409
- for (const e of errors) {
1410
- const key = `${e.path}|${e.keyword}|${e.message}`;
1411
- if (seen.has(key)) continue;
1412
- seen.add(key);
1413
- result.push(e);
1429
+ if (typeof s.maximum === "number") {
1430
+ v.maximum = s.maximum;
1431
+ used = true;
1414
1432
  }
1415
- return result;
1416
- }
1417
- function lintReactiveExpressions(formDefinition) {
1418
- const findings = [];
1419
- walk$1(formDefinition, "", findings);
1420
- return findings;
1433
+ if (typeof s.exclusiveMinimum === "number") {
1434
+ v.exclusiveMinimum = s.exclusiveMinimum;
1435
+ used = true;
1436
+ }
1437
+ if (typeof s.exclusiveMaximum === "number") {
1438
+ v.exclusiveMaximum = s.exclusiveMaximum;
1439
+ used = true;
1440
+ }
1441
+ if (typeof s.multipleOf === "number") {
1442
+ v.multipleOf = s.multipleOf;
1443
+ used = true;
1444
+ }
1445
+ return used ? v : void 0;
1421
1446
  }
1422
- function walk$1(node, path, out) {
1423
- if (node === null || typeof node !== "object") return;
1424
- if (Array.isArray(node)) {
1425
- node.forEach((item, i) => walk$1(item, `${path}/${i}`, out));
1426
- return;
1447
+ function buildBooleanValidator(s, required2) {
1448
+ const v = { type: "boolean" };
1449
+ let used = false;
1450
+ if (required2) {
1451
+ v.required = true;
1452
+ used = true;
1427
1453
  }
1428
- const obj = node;
1429
- for (const key of ["include", "exclude"]) {
1430
- const child = obj[key];
1431
- if (child && typeof child === "object" && "when" in child) {
1432
- const expr = child.when;
1433
- if (typeof expr === "string") {
1434
- checkExpression(expr, `${path}/${key}/when`, out);
1435
- }
1454
+ if (s.const !== void 0) {
1455
+ v.const = s.const;
1456
+ used = true;
1457
+ }
1458
+ return used ? v : void 0;
1459
+ }
1460
+ const SECRET_HINTS = /(password|secret|api[_-]?key|token)/i;
1461
+ const LONG_TEXT_MIN_LENGTH = 200;
1462
+ const SELECT_THRESHOLD = 6;
1463
+ function jsonSchemaToGui(schema, opts = {}) {
1464
+ const unmapped = [];
1465
+ const root = unwrap(schema);
1466
+ const fields = [];
1467
+ if (root.type === "object" || root.properties) {
1468
+ const required2 = new Set(root.required ?? []);
1469
+ for (const [name, propSchema] of Object.entries(root.properties ?? {})) {
1470
+ const widget = mapProperty(name, unwrap(propSchema), required2.has(name), "", unmapped);
1471
+ if (widget) fields.push(widget);
1436
1472
  }
1473
+ } else {
1474
+ unmapped.push({
1475
+ path: "",
1476
+ reason: "Top-level JSON Schema is not an object — only object schemas map to forms."
1477
+ });
1437
1478
  }
1438
- if (path === "" && obj["states"] && typeof obj["states"] === "object") {
1439
- for (const [name, expr] of Object.entries(obj["states"])) {
1440
- if (typeof expr === "string") {
1441
- checkExpression(expr, `/states/${name}`, out);
1442
- }
1443
- }
1479
+ if (opts.submitAction !== false) {
1480
+ fields.push({
1481
+ kind: "action",
1482
+ type: "button",
1483
+ actionType: "submit",
1484
+ label: opts.submitLabel ?? "Submit",
1485
+ props: { variant: "filled" }
1486
+ });
1444
1487
  }
1445
- for (const [k, v] of Object.entries(obj)) {
1446
- if (v && typeof v === "object" && !Array.isArray(v) && "when" in v) {
1447
- const expr = v.when;
1448
- if (typeof expr === "string" && k !== "include" && k !== "exclude") {
1449
- checkExpression(expr, `${path}/${k}/when`, out);
1450
- }
1451
- }
1452
- if (typeof v === "object" && v !== null) {
1453
- walk$1(v, `${path}/${k}`, out);
1488
+ const wrapped = opts.layout && opts.layout !== "vertical" ? [
1489
+ {
1490
+ kind: "layout",
1491
+ type: opts.layout === "grid" ? "grid" : "flex",
1492
+ props: opts.layout === "grid" ? { columnGap: 12, rowGap: 12 } : { direction: "row", gap: 12 },
1493
+ children: fields
1454
1494
  }
1455
- }
1495
+ ] : fields;
1496
+ return {
1497
+ formDefinition: { $schema: "https://golemui.com/schemas/form.schema.json", form: wrapped },
1498
+ unmapped
1499
+ };
1456
1500
  }
1457
- function checkExpression(expr, path, out) {
1458
- const trimmed = expr.trim();
1459
- if (!trimmed) {
1460
- out.push({ path, expression: expr, message: "Expression is empty." });
1461
- return;
1501
+ function unwrap(schema) {
1502
+ if (schema.oneOf?.length === 2) {
1503
+ const nonNull = schema.oneOf.find((s) => s.type !== "null");
1504
+ if (nonNull) return { ...nonNull, ...stripBranches(schema) };
1462
1505
  }
1463
- const stack = [];
1464
- const pairs = { ")": "(", "]": "[", "}": "{" };
1465
- for (const ch of trimmed) {
1466
- if ("([{".includes(ch)) stack.push(ch);
1467
- else if (")]}".includes(ch)) {
1468
- if (stack.pop() !== pairs[ch]) {
1469
- out.push({
1470
- path,
1471
- expression: expr,
1472
- message: `Unbalanced \`${ch}\` in reactive expression.`
1473
- });
1474
- return;
1475
- }
1476
- }
1506
+ if (schema.anyOf?.length === 2) {
1507
+ const nonNull = schema.anyOf.find((s) => s.type !== "null");
1508
+ if (nonNull) return { ...nonNull, ...stripBranches(schema) };
1477
1509
  }
1478
- if (stack.length) {
1479
- out.push({
1480
- path,
1481
- expression: expr,
1482
- message: `Unclosed \`${stack[stack.length - 1]}\` in reactive expression.`
1483
- });
1484
- return;
1510
+ return schema;
1511
+ }
1512
+ function stripBranches(s) {
1513
+ const { oneOf: oneOf2, anyOf, ...rest } = s;
1514
+ return rest;
1515
+ }
1516
+ function mapProperty(name, schema, required2, parentPath, unmapped) {
1517
+ const path = parentPath ? `${parentPath}.${name}` : name;
1518
+ const label = humanLabel(schema.title ?? name);
1519
+ const t = Array.isArray(schema.type) ? schema.type.find((x) => x !== "null") : schema.type;
1520
+ if (Array.isArray(schema.enum) && schema.enum.every((v) => typeof v === "string")) {
1521
+ return buildEnumField(path, name, label, schema, required2);
1485
1522
  }
1486
- if (!/\$form\b|\$meta\b|\$formIsInvalid\b/.test(trimmed)) {
1487
- out.push({
1488
- path,
1489
- expression: expr,
1490
- message: "Expression does not reference `$form`, `$meta`, or `$formIsInvalid`.",
1491
- suggestion: "GolemUI expressions read form data via `$form.fieldName`, form metadata via `$meta.key`, or the built-in `$formIsInvalid` boolean. Did you forget the prefix?"
1492
- });
1523
+ switch (t) {
1524
+ case "string":
1525
+ return buildStringField(path, name, label, schema, required2);
1526
+ case "number":
1527
+ case "integer":
1528
+ return buildNumberField(path, label, schema, required2, t);
1529
+ case "boolean":
1530
+ return buildBooleanField(path, label, schema, required2);
1531
+ case "object":
1532
+ return buildObjectGroup(path, name, label, schema, unmapped);
1533
+ case "array":
1534
+ return buildArrayField(path, name, label, schema, required2, unmapped);
1535
+ default:
1536
+ unmapped.push({
1537
+ path,
1538
+ reason: `Unsupported JSON Schema type \`${schema.type ?? "undefined"}\`.`
1539
+ });
1540
+ return null;
1493
1541
  }
1494
- if (/(?<![=!<>])=(?![=>])/.test(trimmed)) {
1495
- out.push({
1496
- path,
1497
- expression: expr,
1498
- message: "Expression contains a single `=` (assignment). Reactive expressions are read-only.",
1499
- suggestion: "Use `===` for equality comparison."
1500
- });
1542
+ }
1543
+ function buildStringField(path, name, label, schema, required2) {
1544
+ const validator = buildStringValidator(schema, required2);
1545
+ const isSecret = SECRET_HINTS.test(name);
1546
+ const isLong = (schema.maxLength ?? 0) >= LONG_TEXT_MIN_LENGTH;
1547
+ if (schema.format === "date") {
1548
+ return cleanFields({ kind: "input", type: "dateInput", path, label, validator });
1501
1549
  }
1502
- if (/(?<![&])&(?![&])/.test(trimmed) || /(?<![|])\|(?![|])/.test(trimmed)) {
1503
- out.push({
1504
- path,
1505
- expression: expr,
1506
- message: "Expression contains a single `&` or `|` (bitwise).",
1507
- suggestion: "Use `&&` for logical AND, `||` for logical OR."
1508
- });
1550
+ if (schema.format === "date-time") {
1551
+ return cleanFields({ kind: "input", type: "datePicker", path, label, validator });
1509
1552
  }
1510
- if (/(?<![=!])==(?!=)/.test(trimmed) || /(?<![!])!=(?!=)/.test(trimmed)) {
1511
- out.push({
1512
- path,
1513
- expression: expr,
1514
- message: "Expression uses loose equality (`==` or `!=`).",
1515
- suggestion: "Use strict equality `===` / `!==` to avoid type coercion (e.g. `$form.x !== undefined` rather than `$form.x != null`)."
1516
- });
1553
+ if (isSecret) {
1554
+ return cleanFields({ kind: "input", type: "password", path, label, validator });
1517
1555
  }
1518
- if (/(?<![=!])!\s*\$(?:form|meta)\b/.test(trimmed)) {
1519
- out.push({
1556
+ if (isLong) {
1557
+ return cleanFields({
1558
+ kind: "input",
1559
+ type: "textarea",
1520
1560
  path,
1521
- expression: expr,
1522
- message: "Expression negates a `$form`/`$meta` reference (relies on truthy/falsy coercion).",
1523
- suggestion: 'Form data values can be `undefined`. Pick the case you actually mean and write it explicitly — `$form.x === undefined`, `$form.x === null`, `$form.x === 0`, `$form.x === ""` — instead of `!$form.x`.'
1561
+ label,
1562
+ validator,
1563
+ props: schema.description ? { hint: schema.description } : void 0
1524
1564
  });
1525
1565
  }
1526
- const refChainRe = /\$(?:form|meta)\b((?:\.[\w?]+)*)/g;
1527
- let chainFlagged = false;
1528
- let chainMatch;
1529
- while ((chainMatch = refChainRe.exec(trimmed)) !== null) {
1530
- const chain = chainMatch[1];
1531
- if (!chain) continue;
1532
- const segments = chain.split(".").filter(Boolean);
1533
- let unsafe = false;
1534
- for (let i = 0; i < segments.length - 1; i++) {
1535
- const segment = segments[i];
1536
- if (!segment || !segment.endsWith("?")) {
1537
- unsafe = true;
1538
- break;
1539
- }
1540
- }
1541
- if (unsafe && !chainFlagged) {
1542
- out.push({
1543
- path,
1544
- expression: expr,
1545
- message: "Expression chains nested property access without optional chaining (e.g. `$form.user.name`).",
1546
- suggestion: "Treat every nested property as possibly `undefined`. Use `?.` between segments: `$form.user?.name` instead of `$form.user.name`. The runtime throws if `$form.user` is undefined."
1547
- });
1548
- chainFlagged = true;
1549
- }
1550
- }
1551
- const refOnly = /^\$(?:form|meta)(?:\.[\w?]+)*$/;
1552
- const refBeforeBool = /\$(?:form|meta)(?:\.[\w?]+)*\s*(?:&&|\|\||\?(?![.?]))/;
1553
- const refAfterBool = /(?:&&|\|\|)\s*\$(?:form|meta)(?:\.[\w?]+)*\s*$/;
1554
- if (refOnly.test(trimmed) || refBeforeBool.test(trimmed) || refAfterBool.test(trimmed)) {
1555
- out.push({
1566
+ return cleanFields({
1567
+ kind: "input",
1568
+ type: "textinput",
1569
+ path,
1570
+ label,
1571
+ validator,
1572
+ props: schema.description ? { hint: schema.description } : void 0
1573
+ });
1574
+ }
1575
+ function buildEnumField(path, name, label, schema, required2) {
1576
+ const values = schema.enum ?? [];
1577
+ const validator = required2 ? { type: "string", required: true, enum: values } : { type: "string", enum: values };
1578
+ if (values.length <= SELECT_THRESHOLD) {
1579
+ return cleanFields({
1580
+ kind: "input",
1581
+ type: "select",
1556
1582
  path,
1557
- expression: expr,
1558
- message: "Expression uses `$form`/`$meta` directly as a boolean (relies on truthy/falsy coercion).",
1559
- suggestion: 'Form data values can be `undefined`. Compare explicitly: `$form.x !== undefined`, `$form.x === "value"`, `$form.items?.length > 0`. For default values use nullish coalescing: `$form.x ?? defaultValue`.'
1583
+ label,
1584
+ validator,
1585
+ props: {
1586
+ options: values.map((v) => ({ label: humanLabel(v), value: v }))
1587
+ }
1560
1588
  });
1561
1589
  }
1562
- const refForCmpRe = /\$(?:form|meta)(?:\.[\w?]+)+/g;
1563
- let r5Flagged = false;
1564
- let cmpMatch;
1565
- while ((cmpMatch = refForCmpRe.exec(trimmed)) !== null) {
1566
- const start = cmpMatch.index;
1567
- const end = start + cmpMatch[0].length;
1568
- const before = trimmed.slice(0, start).trimEnd();
1569
- const after = trimmed.slice(end).trimStart();
1570
- const opAfter = /^(?:<=?|>=?|[+\-*/%])(?!=)/.test(after);
1571
- const opBefore = /(?<![=!])[<>+\-*/%]$/.test(before);
1572
- if (!(opAfter || opBefore)) continue;
1573
- if (/&&|\|\|/.test(before)) continue;
1574
- if (!r5Flagged) {
1575
- out.push({
1576
- path,
1577
- expression: expr,
1578
- message: "Expression applies a comparison or arithmetic operator to a `$form`/`$meta` reference whose leaf may be `undefined`.",
1579
- suggestion: "Guard the value first: `$form.x !== undefined && $form.x > 180`. Or default it: `($form.x ?? 0) > 180`. Strict equality (`$form.x === 180`) is also safe since it evaluates to `false` when undefined."
1580
- });
1581
- r5Flagged = true;
1590
+ return cleanFields({
1591
+ kind: "input",
1592
+ type: "dropdown",
1593
+ path,
1594
+ label,
1595
+ validator,
1596
+ props: {
1597
+ labelField: "label",
1598
+ valueField: "value",
1599
+ items: values.map((v) => ({ label: humanLabel(v), value: v }))
1582
1600
  }
1583
- }
1601
+ });
1602
+ }
1603
+ function buildNumberField(path, label, schema, required2, t) {
1604
+ const validator = buildNumberValidator(schema, required2, t);
1605
+ return cleanFields({
1606
+ kind: "input",
1607
+ type: "number",
1608
+ path,
1609
+ label,
1610
+ validator,
1611
+ props: schema.description ? { hint: schema.description } : void 0
1612
+ });
1584
1613
  }
1585
- function lintStringInterpolations(formDefinition) {
1586
- const findings = [];
1587
- walk(formDefinition, "", findings);
1588
- return findings;
1614
+ function buildBooleanField(path, label, schema, required2) {
1615
+ const validator = buildBooleanValidator(schema, required2);
1616
+ return cleanFields({ kind: "input", type: "checkbox", path, label, validator });
1589
1617
  }
1590
- const SLOT_REGEX = /\{\{([^}]*(?:\}[^}]+)*)\}\}/g;
1591
- function walk(node, path, out) {
1592
- if (node === null || typeof node !== "object") {
1593
- return;
1618
+ function buildObjectGroup(path, _name, _label, schema, unmapped) {
1619
+ const required2 = new Set(schema.required ?? []);
1620
+ const children = [];
1621
+ for (const [childName, childSchema] of Object.entries(schema.properties ?? {})) {
1622
+ const w = mapProperty(childName, unwrap(childSchema), required2.has(childName), path, unmapped);
1623
+ if (w) children.push(w);
1594
1624
  }
1595
- if (Array.isArray(node)) {
1596
- node.forEach((item, i) => walk(item, `${path}/${i}`, out));
1597
- return;
1625
+ if (!children.length) {
1626
+ unmapped.push({ path, reason: "Object has no mappable properties." });
1627
+ return null;
1598
1628
  }
1599
- const obj = node;
1600
- const isTranslationConfig = typeof obj["key"] === "string" && obj["params"] !== null && typeof obj["params"] === "object" && !Array.isArray(obj["params"]);
1601
- if (isTranslationConfig) {
1602
- const params = obj["params"];
1603
- for (const [paramKey, paramValue] of Object.entries(params)) {
1604
- if (typeof paramValue === "string") {
1605
- checkParamExpression(paramValue, `${path}/params/${paramKey}`, out);
1606
- }
1607
- }
1629
+ return {
1630
+ kind: "layout",
1631
+ type: "flex",
1632
+ props: { direction: "column", gap: 8 },
1633
+ children
1634
+ };
1635
+ }
1636
+ function buildArrayField(path, _name, label, schema, required2, unmapped) {
1637
+ const items = schema.items ? unwrap(schema.items) : void 0;
1638
+ if (!items) {
1639
+ unmapped.push({ path, reason: "Array without `items` schema cannot be mapped." });
1640
+ return null;
1608
1641
  }
1609
- for (const [key, value] of Object.entries(obj)) {
1610
- if (key === "params" && isTranslationConfig) {
1611
- continue;
1612
- }
1613
- if (key === "defaultValue" || key.startsWith("defaultValue.")) {
1614
- continue;
1642
+ if (items.type === "object") {
1643
+ const templateChildren = [];
1644
+ const required22 = new Set(items.required ?? []);
1645
+ const childParent = `${path}.items`;
1646
+ for (const [childName, childSchema] of Object.entries(items.properties ?? {})) {
1647
+ const w = mapProperty(
1648
+ childName,
1649
+ unwrap(childSchema),
1650
+ required22.has(childName),
1651
+ childParent,
1652
+ unmapped
1653
+ );
1654
+ if (w) templateChildren.push(w);
1615
1655
  }
1616
- const childPath = `${path}/${key}`;
1617
- if (typeof value === "string") {
1618
- checkTemplate(value, childPath, out);
1619
- } else {
1620
- walk(value, childPath, out);
1656
+ if (!templateChildren.length) {
1657
+ unmapped.push({ path, reason: "Array of objects has no mappable item properties." });
1658
+ return null;
1621
1659
  }
1622
- }
1623
- }
1624
- function checkParamExpression(value, path, out) {
1625
- if (value.includes("{{") || value.includes("}}")) {
1626
- out.push({
1627
- path,
1628
- slot: value,
1629
- message: "i18n param expression should not use `{{` / `}}` delimiters.",
1630
- suggestion: 'Use a bare expression: `"$form.fieldName"` not `"{{$form.fieldName}}"`.'
1631
- });
1632
- return;
1633
- }
1634
- if (value.startsWith("$") && /(?<![=!<>])=(?![=>])/.test(value)) {
1635
- out.push({
1660
+ return {
1661
+ kind: "input",
1662
+ type: "repeater",
1636
1663
  path,
1637
- slot: value,
1638
- message: "i18n param expression contains a single `=` (assignment).",
1639
- suggestion: "Param expressions are read-only. Did you mean `===` for equality?"
1640
- });
1664
+ label,
1665
+ props: {
1666
+ addLabel: `Add ${singular(label).toLowerCase()}`,
1667
+ removeLabel: "Remove",
1668
+ template: {
1669
+ kind: "layout",
1670
+ type: "flex",
1671
+ props: { direction: "column", gap: 8 },
1672
+ children: templateChildren
1673
+ }
1674
+ }
1675
+ };
1641
1676
  }
1642
- }
1643
- function checkTemplate(value, path, out) {
1644
- const openCount = (value.match(/\{\{/g) ?? []).length;
1645
- const closeCount = (value.match(/\}\}/g) ?? []).length;
1646
- if (openCount !== closeCount) {
1647
- out.push({
1677
+ if (items.type === "string" || items.type === "number" || items.type === "integer") {
1678
+ const validator = buildArrayValidator(schema, required2);
1679
+ return cleanFields({
1680
+ kind: "input",
1681
+ type: "tags",
1648
1682
  path,
1649
- slot: value,
1650
- message: "String template has unbalanced `{{` / `}}` delimiters.",
1651
- suggestion: "Every `{{` must have a matching `}}`."
1683
+ label,
1684
+ validator,
1685
+ props: schema.description ? { placeholder: "Add and press Enter", hint: schema.description } : { placeholder: "Add and press Enter" }
1652
1686
  });
1653
- return;
1654
- }
1655
- if (openCount === 0) return;
1656
- let match;
1657
- SLOT_REGEX.lastIndex = 0;
1658
- while ((match = SLOT_REGEX.exec(value)) !== null) {
1659
- const slot = match[0];
1660
- const expr = match[1];
1661
- checkSlot(expr, slot, path, out);
1662
1687
  }
1688
+ unmapped.push({
1689
+ path,
1690
+ reason: "Array shape is not mappable (only arrays of objects → repeater, or arrays of strings/numbers → tags)."
1691
+ });
1692
+ return null;
1663
1693
  }
1664
- function checkSlot(expr, slot, path, out) {
1665
- const trimmed = expr.trim();
1666
- if (!trimmed) {
1667
- out.push({
1668
- path,
1669
- slot,
1670
- message: "String interpolation slot is empty.",
1671
- suggestion: "Add an expression, e.g. `{{$form.fieldName}}`."
1672
- });
1673
- return;
1694
+ function buildArrayValidator(s, required2) {
1695
+ const v = { type: "array" };
1696
+ let used = false;
1697
+ if (required2) {
1698
+ v.required = true;
1699
+ used = true;
1674
1700
  }
1675
- if (trimmed.includes("{{")) {
1676
- out.push({
1677
- path,
1678
- slot,
1679
- message: "Interpolation slot contains nested `{{`.",
1680
- suggestion: "Slots cannot be nested. Check for a copy-paste error."
1681
- });
1682
- return;
1701
+ if (typeof s.minItems === "number") {
1702
+ v.minItems = s.minItems;
1703
+ used = true;
1683
1704
  }
1684
- if (!/\$form\b|\$meta\b|\$errors\b|\$formIsInvalid\b/.test(trimmed)) {
1685
- out.push({
1686
- path,
1687
- slot,
1688
- message: "Interpolation slot does not reference `$form`, `$meta`, `$errors`, or `$formIsInvalid`.",
1689
- suggestion: "GolemUI template slots read data via `$form.fieldName`, metadata via `$meta.key`, validation errors via `$errors.fieldName`, or the built-in `$formIsInvalid` boolean."
1690
- });
1705
+ if (typeof s.maxItems === "number") {
1706
+ v.maxItems = s.maxItems;
1707
+ used = true;
1691
1708
  }
1692
- if (/(?<![=!<>])=(?![=>])/.test(trimmed)) {
1693
- out.push({
1694
- path,
1695
- slot,
1696
- message: "Interpolation slot contains a single `=` (assignment).",
1697
- suggestion: "Template slots are read-only. Did you mean `===` for equality?"
1698
- });
1709
+ if (s.uniqueItems === true) {
1710
+ v.uniqueItems = true;
1711
+ used = true;
1699
1712
  }
1713
+ return used ? v : void 0;
1700
1714
  }
1701
- function validateFormDefinition(input) {
1702
- const validate = getFormValidator();
1703
- const ajvOk = validate(input.formDefinition);
1704
- const { errors, warnings } = formatAjvErrors(validate.errors, input.formDefinition);
1705
- const expressionWarnings = lintReactiveExpressions(input.formDefinition);
1706
- const interpolationWarnings = lintStringInterpolations(input.formDefinition);
1707
- if (!ajvOk && errors.length === 0 && warnings.length === 0) {
1708
- errors.push({
1709
- path: "/",
1710
- keyword: "oneOf",
1711
- message: "Form failed schema validation but no specific error could be localized. The form may contain a widget at a position the form schema does not allow, or a structural shape that our targeted validator missed. Verify each widget against its `get_widget_spec` entry."
1712
- });
1713
- }
1714
- return {
1715
- valid: errors.length === 0,
1716
- errors,
1717
- warnings,
1718
- expressionWarnings,
1719
- interpolationWarnings
1720
- };
1715
+ function humanLabel(name) {
1716
+ if (!name) return "";
1717
+ return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/\s+/g, " ").trim().replace(/^./, (c) => c.toUpperCase());
1721
1718
  }
1722
- const VALIDATE_FORM_DEFINITION_TOOL = {
1723
- name: "validate_form_definition",
1724
- description: "Validate a GolemUI form definition against the bundled JSON Schemas. Use this AFTER generating or modifying a form definition to guarantee it is correct before the user pastes it into their codebase. Returns `{ valid, errors, warnings, expressionWarnings, interpolationWarnings }`. Hard mistakes (typos in widget `type`, missing required props, invalid validator shapes) show up in `errors` and flip `valid` to false. Likely-custom widgets (a `type` value that isn't a built-in and isn't close to one) show up in `warnings` instead — they don't affect `valid`. Reactive expressions (`include.when`, `disabled.when`, etc.) are linted separately into `expressionWarnings`. String interpolation templates (`{{$form.x}}`, `{{$meta.y}}`, expressions like `{{$form.count + 1}}`, etc.) in widget props, and bare expressions inside i18n `params` objects, are linted into `interpolationWarnings`.",
1725
- inputSchema: {
1726
- type: "object",
1727
- properties: {
1728
- formDefinition: {
1729
- type: "object",
1730
- additionalProperties: true,
1731
- description: "The full GolemUI form definition object, shaped as `{ form: [...widgets], states?: {...} }`. Pass the JSON object, not a stringified version."
1732
- }
1733
- },
1734
- required: ["formDefinition"]
1719
+ function singular(label) {
1720
+ if (label.endsWith("ies") && label.length > 3) return label.slice(0, -3) + "y";
1721
+ if (label.endsWith("s") && !label.endsWith("ss")) return label.slice(0, -1);
1722
+ return label;
1723
+ }
1724
+ function cleanFields(o) {
1725
+ for (const k of Object.keys(o)) {
1726
+ if (o[k] === void 0) delete o[k];
1735
1727
  }
1736
- };
1728
+ return o;
1729
+ }
1737
1730
  function generateFromJsonSchema(input) {
1738
1731
  const opts = {
1739
1732
  submitAction: input.submitAction,
@@ -1866,66 +1859,368 @@ function defaultSubmitLabel(method, op) {
1866
1859
  };
1867
1860
  return verbs[method.toLowerCase()] ?? "Submit";
1868
1861
  }
1869
- function derefSchema(schema, doc, depth = 0) {
1870
- if (depth > 16) return schema;
1871
- if (typeof schema.$ref === "string") {
1872
- const target = resolveLocalRef(schema.$ref, doc);
1873
- if (target) return derefSchema(target, doc, depth + 1);
1874
- return schema;
1862
+ function derefSchema(schema, doc, depth = 0) {
1863
+ if (depth > 16) return schema;
1864
+ if (typeof schema.$ref === "string") {
1865
+ const target = resolveLocalRef(schema.$ref, doc);
1866
+ if (target) return derefSchema(target, doc, depth + 1);
1867
+ return schema;
1868
+ }
1869
+ const result = { ...schema };
1870
+ if (result.properties) {
1871
+ result.properties = Object.fromEntries(
1872
+ Object.entries(result.properties).map(([k, v]) => [k, derefSchema(v, doc, depth + 1)])
1873
+ );
1874
+ }
1875
+ if (result.items) result.items = derefSchema(result.items, doc, depth + 1);
1876
+ if (result.oneOf) result.oneOf = result.oneOf.map((s) => derefSchema(s, doc, depth + 1));
1877
+ if (result.anyOf) result.anyOf = result.anyOf.map((s) => derefSchema(s, doc, depth + 1));
1878
+ return result;
1879
+ }
1880
+ function resolveLocalRef(ref, doc) {
1881
+ if (!ref.startsWith("#/")) return null;
1882
+ const parts = ref.slice(2).split("/");
1883
+ let cur = doc;
1884
+ for (const p of parts) {
1885
+ if (cur && typeof cur === "object") {
1886
+ cur = cur[p];
1887
+ } else {
1888
+ return null;
1889
+ }
1890
+ }
1891
+ return cur ?? null;
1892
+ }
1893
+ const GENERATE_FROM_OPENAPI_TOOL = {
1894
+ name: "generate_from_openapi",
1895
+ description: 'Generate a GolemUI form for a specific OpenAPI 3.x operation (e.g. "POST /users"). Resolves the operation\'s JSON request body, dereferences `$ref`s, then maps it to a form definition that is validated against the GolemUI JSON Schemas before being returned, so it is guaranteed syntactically correct. Falls back to operation parameters when no request body is present. Anything the mapper cannot handle is reported in `unmapped` rather than silently dropped — use that list to surface remaining work to the user. Pass either a parsed `document` or a `documentUrl` to fetch.',
1896
+ inputSchema: {
1897
+ type: "object",
1898
+ properties: {
1899
+ documentUrl: {
1900
+ type: "string",
1901
+ description: "URL of a JSON OpenAPI document. Either this or `document` is required."
1902
+ },
1903
+ document: {
1904
+ type: "object",
1905
+ additionalProperties: true,
1906
+ description: "Parsed OpenAPI document (JSON object). Either this or `documentUrl` is required."
1907
+ },
1908
+ operation: {
1909
+ type: "string",
1910
+ description: 'The operation to generate a form for. Either "METHOD /path" (e.g. "POST /users") or an exact operationId.'
1911
+ },
1912
+ submitAction: {
1913
+ type: "boolean",
1914
+ description: "Append a submit button. Defaults to true."
1915
+ },
1916
+ submitLabel: {
1917
+ type: "string",
1918
+ description: "Label for the submit button. Defaults to the operation summary or a verb derived from the HTTP method."
1919
+ }
1920
+ },
1921
+ required: ["operation"]
1875
1922
  }
1876
- const result = { ...schema };
1877
- if (result.properties) {
1878
- result.properties = Object.fromEntries(
1879
- Object.entries(result.properties).map(([k, v]) => [k, derefSchema(v, doc, depth + 1)])
1880
- );
1923
+ };
1924
+ const EXAMPLES = {
1925
+ accordion: {
1926
+ kind: "layout",
1927
+ type: "accordion",
1928
+ props: {
1929
+ sections: [
1930
+ { uid: "personal", label: "Personal" },
1931
+ { uid: "preferences", label: "Preferences" }
1932
+ ]
1933
+ },
1934
+ children: [
1935
+ { kind: "input", type: "textinput", path: "firstName", label: "First name" },
1936
+ { kind: "input", type: "checkbox", path: "newsletter", label: "Newsletter" }
1937
+ ]
1938
+ },
1939
+ textinput: {
1940
+ kind: "input",
1941
+ type: "textinput",
1942
+ path: "firstName",
1943
+ label: "First name",
1944
+ validator: { type: "string", required: true, minLength: 1 },
1945
+ props: { placeholder: "Jane" }
1946
+ },
1947
+ textarea: {
1948
+ kind: "input",
1949
+ type: "textarea",
1950
+ path: "bio",
1951
+ label: "Bio",
1952
+ props: { minimumHeight: 120, autoGrow: true }
1953
+ },
1954
+ tags: {
1955
+ kind: "input",
1956
+ type: "tags",
1957
+ path: "keywords",
1958
+ label: "Keywords",
1959
+ defaultValue: [],
1960
+ props: {
1961
+ placeholder: "Add a keyword and press Enter",
1962
+ separators: ["Enter", ","],
1963
+ trim: true,
1964
+ allowDuplicates: false,
1965
+ limit: 10
1966
+ },
1967
+ validator: {
1968
+ type: "array",
1969
+ minItems: 1,
1970
+ maxItems: 10
1971
+ }
1972
+ },
1973
+ password: {
1974
+ kind: "input",
1975
+ type: "password",
1976
+ path: "password",
1977
+ label: "Password",
1978
+ validator: { type: "string", required: true, minLength: 8 }
1979
+ },
1980
+ number: {
1981
+ kind: "input",
1982
+ type: "number",
1983
+ path: "age",
1984
+ label: "Age",
1985
+ validator: { type: "integer", required: true, minimum: 0 }
1986
+ },
1987
+ currency: {
1988
+ kind: "input",
1989
+ type: "currency",
1990
+ path: "salary",
1991
+ label: "Salary",
1992
+ props: { currency: "USD" }
1993
+ },
1994
+ checkbox: {
1995
+ kind: "input",
1996
+ type: "checkbox",
1997
+ path: "termsAccepted",
1998
+ label: "I accept the terms",
1999
+ validator: { type: "boolean", required: true, const: true }
2000
+ },
2001
+ toggle: {
2002
+ kind: "input",
2003
+ type: "toggle",
2004
+ path: "notifications",
2005
+ label: "Email notifications"
2006
+ },
2007
+ dropdown: {
2008
+ kind: "input",
2009
+ type: "dropdown",
2010
+ path: "country",
2011
+ label: "Country",
2012
+ props: {
2013
+ labelField: "label",
2014
+ valueField: "value",
2015
+ items: [
2016
+ { label: "United States", value: "us", flag: "🇺🇸" },
2017
+ { label: "Canada", value: "ca", flag: "🇨🇦" }
2018
+ ]
2019
+ }
2020
+ },
2021
+ select: {
2022
+ kind: "input",
2023
+ type: "select",
2024
+ path: "plan",
2025
+ label: "Plan",
2026
+ props: {
2027
+ options: [
2028
+ { label: "Free", value: "free" },
2029
+ { label: "Pro", value: "pro" }
2030
+ ]
2031
+ }
2032
+ },
2033
+ radiogroup: {
2034
+ kind: "input",
2035
+ type: "radiogroup",
2036
+ path: "shippingSpeed",
2037
+ label: "Shipping",
2038
+ props: {
2039
+ options: [
2040
+ { label: "Standard", value: "standard" },
2041
+ { label: "Express", value: "express" }
2042
+ ]
2043
+ }
2044
+ },
2045
+ dateInput: {
2046
+ kind: "input",
2047
+ type: "dateInput",
2048
+ path: "birthDate",
2049
+ label: "Birth date"
2050
+ },
2051
+ datePicker: {
2052
+ kind: "input",
2053
+ type: "datePicker",
2054
+ path: "startDate",
2055
+ label: "Start date"
2056
+ },
2057
+ calendar: {
2058
+ kind: "input",
2059
+ type: "calendar",
2060
+ path: "eventDate",
2061
+ label: "Event date"
2062
+ },
2063
+ button: {
2064
+ kind: "action",
2065
+ type: "button",
2066
+ actionType: "submit",
2067
+ label: "Submit",
2068
+ props: { variant: "filled" }
2069
+ },
2070
+ alert: {
2071
+ kind: "display",
2072
+ type: "alert",
2073
+ props: { level: "info", text: "Heads up!" }
2074
+ },
2075
+ markdownText: {
2076
+ kind: "display",
2077
+ type: "markdownText",
2078
+ props: { md: "Welcome to the form." }
2079
+ },
2080
+ flex: {
2081
+ kind: "layout",
2082
+ type: "flex",
2083
+ props: { direction: "column", gap: 12 },
2084
+ children: [
2085
+ {
2086
+ kind: "input",
2087
+ type: "textinput",
2088
+ path: "firstName",
2089
+ label: "First name"
2090
+ }
2091
+ ]
2092
+ },
2093
+ grid: {
2094
+ kind: "layout",
2095
+ type: "grid",
2096
+ props: { columnGap: 12, rowGap: 12 },
2097
+ children: [
2098
+ { kind: "input", type: "textinput", path: "firstName", label: "First name" },
2099
+ { kind: "input", type: "textinput", path: "lastName", label: "Last name" }
2100
+ ]
2101
+ },
2102
+ tabs: {
2103
+ kind: "layout",
2104
+ type: "tabs",
2105
+ props: {
2106
+ tabs: [
2107
+ { uid: "personal", label: "Personal" },
2108
+ { uid: "address", label: "Address" }
2109
+ ]
2110
+ },
2111
+ children: [
2112
+ { kind: "input", type: "textinput", path: "firstName", label: "First name" },
2113
+ { kind: "input", type: "textinput", path: "street", label: "Street" }
2114
+ ]
2115
+ },
2116
+ repeater: {
2117
+ kind: "input",
2118
+ type: "repeater",
2119
+ path: "addresses",
2120
+ label: "Addresses",
2121
+ props: {
2122
+ addLabel: "Add address",
2123
+ removeLabel: "Remove",
2124
+ template: {
2125
+ kind: "layout",
2126
+ type: "flex",
2127
+ props: { direction: "column" },
2128
+ children: [
2129
+ // Child paths inside a repeater template MUST be `<repeater.path>.items.<field>` —
2130
+ // `items` is the reserved segment that the runtime expands per array entry.
2131
+ { kind: "input", type: "textinput", path: "addresses.items.street", label: "Street" },
2132
+ { kind: "input", type: "textinput", path: "addresses.items.city", label: "City" }
2133
+ ]
2134
+ }
2135
+ }
1881
2136
  }
1882
- if (result.items) result.items = derefSchema(result.items, doc, depth + 1);
1883
- if (result.oneOf) result.oneOf = result.oneOf.map((s) => derefSchema(s, doc, depth + 1));
1884
- if (result.anyOf) result.anyOf = result.anyOf.map((s) => derefSchema(s, doc, depth + 1));
1885
- return result;
2137
+ };
2138
+ const NOTES = {
2139
+ textinput: [
2140
+ "`path` is the dot-path into form data this field writes to.",
2141
+ "`validator.format` supports: `email`, `hostname`, `ipv4`, `ipv6`, `url`, `uuid`, `date`, `time`, `date-time`, `duration`.",
2142
+ 'Root props `label`, `disabled`, `readonly`, `validator`, and `size` accept state suffixes — e.g. `"label.<stateName>": "New label"` overrides the label only when that named state is active. Props inside `props` (e.g. `hint`, `placeholder`) also accept suffixes as `"hint.<stateName>"`. Call `get_concept({ concept: "states" })` for the full pattern.'
2143
+ ],
2144
+ markdownText: [
2145
+ "Display-only widget for rendering markdown. Can be used as a top-level form widget (inside any layout) or inside templates like `dropdown.props.items[].template`.",
2146
+ "The required prop is `md` (the markdown string), not `text`."
2147
+ ],
2148
+ tags: [
2149
+ "Use `tags` for free-form arrays of primitive values (typically `string[]`) — e.g. keywords, email lists, hashtags. Backing data is `string[]`. For arrays of structured objects, use `repeater` instead.",
2150
+ '`props.separators` controls which keys/characters commit a new tag. Defaults emit on `Enter` and `,`; you can also include `"Tab"` or `"blur"`. `props.trim` strips whitespace from each tag; `props.allowDuplicates: false` rejects repeats; `props.limit` caps the array length.',
2151
+ 'Validate with an `arrayValidator`: `{ type: "array", minItems, maxItems, uniqueItems }`. The `tags` widget complements that with UI-level enforcement (`allowDuplicates`, `limit`) but the validator is still authoritative for form-level required/min/max constraints.'
2152
+ ],
2153
+ dropdown: [
2154
+ "PREFER `select` for plain label/value lists (countries, plans, sizes). `dropdown` is for richer item shapes: when each item has extra fields (icons, flags, metadata) that an `itemRenderer` or `labelField`/`valueField` can use.",
2155
+ '`props.items` is EITHER primitives `["one", 2, ...]` OR arbitrary objects `[{ label, value, ...extras }]`. Pair the object form with `labelField`/`valueField` to tell the dropdown which fields to render as label and which to use as the value. **Widget templates inside items (the `{template, value}` shape) are NOT supported — only `repeater` accepts widget templates.**'
2156
+ ],
2157
+ select: [
2158
+ '`props.options[]` is the simple form: `[{ label: "United States", value: "us" }, ...]`. Use this for ANY plain text list — countries, plans, sizes, status enums. Only switch to `dropdown` if you need custom per-item rendering (icons, flags, rich layouts).',
2159
+ "For very large lists (>50 items) consider `dropdown` for its virtualization (`height`, `itemHeight`, `searchFields`)."
2160
+ ],
2161
+ flex: ["`children` is an array of any widgets (inputs, displays, nested layouts)."],
2162
+ grid: ["`children` is an array of any widgets. `props.columns` controls layout."],
2163
+ tabs: [
2164
+ "Children are associated with tabs by **`uid` matching**, not by array order: each direct child must have a `uid` field at the widget level (alongside `kind`/`type`) whose string value equals one of the `props.tabs[].uid` entries. There is no `tag` property — that is not a real GolemUI field.",
2165
+ "Children are typically written in display order for readability, but rearranging them does not change which tab they belong to — only the `uid` string match does."
2166
+ ],
2167
+ accordion: [
2168
+ "Children are associated with sections by **`uid` matching** (same pattern as `tabs`): each direct child must have a `uid` field at the widget level whose value equals one of the `props.sections[].uid` entries. There is no `tag` property.",
2169
+ "`props.defaultOpen` is a map of `{ <sectionUid>: boolean }` controlling which sections start expanded."
2170
+ ],
2171
+ repeater: [
2172
+ "`props.template` must be a layout widget (flex/grid/tabs/accordion) whose children are the per-item fields.",
2173
+ 'Child paths inside the template MUST follow the form `<repeater.path>.items.<fieldName>`. The `items` segment is reserved — the runtime substitutes it with the current array index per row. For example, a repeater at `path: "users"` with a child `firstName` uses `path: "users.items.firstName"`. Plain `firstName` will NOT bind to the array.',
2174
+ 'Nested repeaters chain the convention: a repeater at `path: "teams"` whose template contains a repeater at `path: "teams.items.members"` whose children use `path: "teams.items.members.items.<field>"`.',
2175
+ '`addLabel` supports state suffixes: `"addLabel.<stateName>": "Limit reached"` swaps the add-button label when a named state is active — useful for capping array length. Call `get_concept({ concept: "states" })` for the full pattern.'
2176
+ ],
2177
+ button: [
2178
+ "`actionType` controls the button's role. `actionType: \"submit\"` makes the button fire the form's `formSubmit` event natively — the host listens for it via `(formSubmit)` (Angular), `@formSubmit` (Vue), `onFormSubmit` (React), or the `form-submit` event (Lit). No custom handler needed. Use this for the primary submit button on a form.",
2179
+ '`actionType: "button"` (the default, can be omitted) is a regular action button. Wire it via `on.click: "<handlerName>"` where `<handlerName>` is registered in the form config\'s event handlers.',
2180
+ 'Supports state-suffixed props: `"label.<stateName>"` and `"disabled.<stateName>"` swap the label or disabled state when a named state is active — e.g. disable the submit button until terms are accepted, then re-enable it. Call `get_concept({ concept: "states" })` for the full pattern.'
2181
+ ],
2182
+ checkbox: ["Set `validator.const: true` to require the user to tick it (e.g. terms acceptance)."],
2183
+ alert: [
2184
+ 'Use `include: { in: ["stateName"] }` to show this alert only when a named state is active, or `exclude: { from: ["stateName"] }` to hide it when a state is active. This is cleaner than `include: { when: "..." }` when the same condition is reused across multiple widgets. Call `get_concept({ concept: "states" })` for the full states pattern.',
2185
+ 'Props inside `props` (e.g. `text`, `level`) accept state suffixes: `"text.<stateName>": "New message"` swaps the message when that state is active.'
2186
+ ]
2187
+ };
2188
+ function synthesizeExample(widgetType, schema) {
2189
+ const props = schema["properties"];
2190
+ const kindProp = props?.["kind"]?.const ?? "input";
2191
+ const ex = { kind: kindProp, type: widgetType };
2192
+ if (props?.["path"]) ex["path"] = "field";
2193
+ if (props?.["label"]) ex["label"] = "Field";
2194
+ return ex;
1886
2195
  }
1887
- function resolveLocalRef(ref, doc) {
1888
- if (!ref.startsWith("#/")) return null;
1889
- const parts = ref.slice(2).split("/");
1890
- let cur = doc;
1891
- for (const p of parts) {
1892
- if (cur && typeof cur === "object") {
1893
- cur = cur[p];
1894
- } else {
1895
- return null;
1896
- }
2196
+ function getWidgetSpec(input) {
2197
+ const schema = COMPONENT_SCHEMAS[input.widgetType];
2198
+ if (!schema) {
2199
+ const known = Object.keys(COMPONENT_SCHEMAS).sort().join(", ");
2200
+ throw new Error(`Unknown widget type \`${input.widgetType}\`. Known: ${known}.`);
1897
2201
  }
1898
- return cur ?? null;
2202
+ const props = schema["properties"];
2203
+ const kindProp = props?.["kind"]?.const ?? "unknown";
2204
+ return {
2205
+ widgetType: input.widgetType,
2206
+ schema,
2207
+ kind: kindProp,
2208
+ example: EXAMPLES[input.widgetType] ?? synthesizeExample(input.widgetType, schema),
2209
+ notes: NOTES[input.widgetType] ?? []
2210
+ };
1899
2211
  }
1900
- const GENERATE_FROM_OPENAPI_TOOL = {
1901
- name: "generate_from_openapi",
1902
- description: 'Generate a GolemUI form for a specific OpenAPI 3.x operation (e.g. "POST /users"). Resolves the operation\'s JSON request body, dereferences `$ref`s, then maps it to a form definition that is validated against the GolemUI JSON Schemas before being returned, so it is guaranteed syntactically correct. Falls back to operation parameters when no request body is present. Anything the mapper cannot handle is reported in `unmapped` rather than silently dropped — use that list to surface remaining work to the user. Pass either a parsed `document` or a `documentUrl` to fetch.',
2212
+ const GET_WIDGET_SPEC_TOOL = {
2213
+ name: "get_widget_spec",
2214
+ description: "Look up the JSON Schema and a minimal working example for a single GolemUI widget. Use this when you need to know which `props` a widget accepts, what `kind` value it uses, or what shape its `validator` takes. Cheaper than dumping the whole API into context.",
1903
2215
  inputSchema: {
1904
2216
  type: "object",
1905
2217
  properties: {
1906
- documentUrl: {
1907
- type: "string",
1908
- description: "URL of a JSON OpenAPI document. Either this or `document` is required."
1909
- },
1910
- document: {
1911
- type: "object",
1912
- additionalProperties: true,
1913
- description: "Parsed OpenAPI document (JSON object). Either this or `documentUrl` is required."
1914
- },
1915
- operation: {
1916
- type: "string",
1917
- description: 'The operation to generate a form for. Either "METHOD /path" (e.g. "POST /users") or an exact operationId.'
1918
- },
1919
- submitAction: {
1920
- type: "boolean",
1921
- description: "Append a submit button. Defaults to true."
1922
- },
1923
- submitLabel: {
2218
+ widgetType: {
1924
2219
  type: "string",
1925
- description: "Label for the submit button. Defaults to the operation summary or a verb derived from the HTTP method."
2220
+ description: "The widget `type` constant. One of: " + Object.keys(COMPONENT_SCHEMAS).sort().map((t) => `\`${t}\``).join(", ") + "."
1926
2221
  }
1927
2222
  },
1928
- required: ["operation"]
2223
+ required: ["widgetType"]
1929
2224
  }
1930
2225
  };
1931
2226
  const STATES_CONCEPT = {
@@ -2026,152 +2321,28 @@ const STATES_CONCEPT = {
2026
2321
  kind: "display",
2027
2322
  type: "alert",
2028
2323
  props: {
2029
- level: "warning",
2030
- text: "Please accept the terms to continue.",
2031
- "text.termsAccepted": "Ready to submit!"
2032
- },
2033
- exclude: { from: ["busy"] }
2034
- },
2035
- {
2036
- kind: "input",
2037
- type: "repeater",
2038
- path: "users",
2039
- label: "Users",
2040
- addLabel: "Add user",
2041
- "addLabel.limitReached": "Limit reached — can't add more",
2042
- props: {
2043
- removeLabel: "Remove",
2044
- template: {
2045
- kind: "layout",
2046
- type: "flex",
2047
- props: { direction: "column" },
2048
- children: [
2049
- { kind: "input", type: "textinput", path: "users.items.name", label: "Name" }
2050
- ]
2051
- }
2052
- }
2053
- }
2054
- ]
2055
- }
2056
- }
2057
- ],
2058
- rules: [
2059
- 'Every state name used in `include.in`, `exclude.from`, or as a property suffix MUST be declared in the root `"states"` map.',
2060
- "Child state expressions must contain only the *additional* (incremental) condition — ancestor conditions are ANDed in automatically by the runtime. Duplicating a parent condition in a child expression is wrong and redundant.",
2061
- 'A sub-state is only ever active when all of its ancestor states are also active. When using `include.in: ["register:adult"]` you do NOT need to also add `"register"` to the array.',
2062
- "State-suffixed root props — only these support suffixes at the widget root level: `label`, `disabled`, `readonly`, `validator`, `size`. All other overridable properties live inside `props`.",
2063
- 'State-suffixed props inside `props` — any key inside the `props` object can be suffixed: `"hint.<state>"`, `"placeholder.<state>"`, `"items.<state>"`, `"addLabel.<state>"`, etc.',
2064
- 'Suffix names must not contain dots (the dot is the separator between property and state name): `"label.myState"` ✅, `"label.register:adult"` ✅ — `"label.my.state"` ❌.',
2065
- 'Reactive expressions must reference `$form`, `$meta`, or `$formIsInvalid`. A bare identifier like `termsAccepted` without a root reference is invalid. `$formIsInvalid` is a built-in boolean (no property chain — use it as-is: `disabled: { when: "$formIsInvalid" }` or inside a state expression: `states: { formInvalid: "$formIsInvalid" }`).',
2066
- "Use `===` / `!==` for equality, `&&` / `||` for logic. Avoid `=` (assignment), `==`/`!=` (loose equality), or bitwise `&`/`|`.",
2067
- 'When multiple states are active at the same time and a property has more than one matching suffix, the longest state name wins (most specific takes priority). Example: if both `register` and `register:adult` are active, `"label.register:adult"` overrides `"label.register"`.',
2068
- "The `include.when` / `exclude.when` inline form is an alternative to named states for one-off conditions, but it cannot replace state-suffixed props — those require a named state.",
2069
- '`include.in` and `exclude.from` each accept an Array of state names. A widget included `in: ["a", "b"]` renders when state `a` OR state `b` is active.',
2070
- "Use optional chaining (`?.`) when accessing nested fields that may not yet exist in the form data: `$form.user?.age >= 18` not `$form.user.age >= 18`."
2071
- ]
2072
- };
2073
- const STRING_INTERPOLATION_CONCEPT = {
2074
- concept: "string-interpolation",
2075
- summary: "GolemUI supports live data binding in text props via `{{expression}}` template slots. Any string-valued property (e.g. `props.text`, `props.hint`, `label`) can embed one or more `{{...}}` slots. Each slot is a JavaScript-like expression evaluated against the live form state using a safe subset of JavaScript (no side effects, no function calls). i18n translation `params` objects accept a matching bare-expression format — the same expressions but without the `{{}}` delimiters.",
2076
- patterns: [
2077
- {
2078
- name: "Template slots in display text",
2079
- description: "Embed `{{expression}}` in any string prop to inject live values. Available scopes: `$form` (all current field values), `$meta` (host-supplied metadata), `$errors` (current validation error messages keyed by field uid), `$formIsInvalid` (boolean — `true` when any field currently fails validation). Use optional chaining (`?.`) when accessing nested fields that may not yet exist. Multiple slots can appear in a single string.",
2080
- example: {
2081
- $schema: "https://golemui.com/schemas/form.schema.json",
2082
- form: [
2083
- {
2084
- uid: "userName",
2085
- kind: "input",
2086
- type: "textinput",
2087
- path: "userName",
2088
- validator: { type: "string", required: true }
2089
- },
2090
- {
2091
- uid: "submitBtn",
2092
- kind: "action",
2093
- type: "button",
2094
- label: "Submit",
2095
- actionType: "submit"
2096
- },
2097
- {
2098
- uid: "greeting",
2099
- kind: "display",
2100
- type: "alert",
2101
- props: { text: "Hello {{$form.userName}}" }
2102
- },
2103
- {
2104
- uid: "status",
2105
- kind: "display",
2106
- type: "alert",
2107
- props: {
2108
- text: "Error: {{$errors.userName}} | Form invalid: {{$formIsInvalid}}"
2109
- }
2110
- },
2111
- {
2112
- uid: "meta-info",
2113
- kind: "display",
2114
- type: "alert",
2115
- props: { text: "Connected as {{$meta.role}} on {{$meta.server}}" }
2116
- }
2117
- ]
2118
- }
2119
- },
2120
- {
2121
- name: "Expressions in slots",
2122
- description: "Slots support full JavaScript-like expressions: arithmetic, string concatenation, ternary conditionals, and optional chaining. The expression is evaluated against the same scope object (`$form`, `$meta`, `$errors`, `$formIsInvalid`). If the expression evaluates to `null` or `undefined`, the slot renders as an empty string.",
2123
- example: {
2124
- $schema: "https://golemui.com/schemas/form.schema.json",
2125
- data: { firstName: "Jane", lastName: "Doe", count: 4, role: "admin" },
2126
- form: [
2127
- {
2128
- uid: "full-name",
2129
- kind: "display",
2130
- type: "alert",
2131
- props: { text: "Full name: {{$form.firstName + ' ' + $form.lastName}}" }
2132
- },
2133
- {
2134
- uid: "next-count",
2135
- kind: "display",
2136
- type: "alert",
2137
- props: { text: "Next item: {{$form.count + 1}}" }
2138
- },
2139
- {
2140
- uid: "role-label",
2141
- kind: "display",
2142
- type: "alert",
2143
- props: { text: "Role: {{$form.role === 'admin' ? 'Administrator' : 'User'}}" }
2144
- },
2145
- {
2146
- uid: "nested",
2147
- kind: "display",
2148
- type: "alert",
2149
- props: { text: "City: {{$form.address?.city}}" }
2150
- }
2151
- ]
2152
- }
2153
- },
2154
- {
2155
- name: "i18n param expressions",
2156
- description: "When using i18n translations, `params` values support the same expression language as `{{}}` slots, but as **bare expressions** without the `{{}}` delimiters. Params that start with a `$` scope prefix are evaluated; others are passed as static strings. The expression has access to `$form`, `$meta`, `$errors`, and `$formIsInvalid`.",
2157
- example: {
2158
- $schema: "https://golemui.com/schemas/form.schema.json",
2159
- data: { firstName: "Jane", lastName: "Doe", count: 4 },
2160
- meta: { connectionStatus: "online" },
2161
- form: [
2162
- {
2163
- uid: "greeting",
2164
- kind: "display",
2165
- type: "alert",
2166
- props: {
2167
- text: {
2168
- key: "user.greeting",
2169
- params: {
2170
- hello: "Hola",
2171
- fullName: "$form.firstName + ' ' + $form.lastName",
2172
- n: "$form.count + 1",
2173
- status: "$meta.connectionStatus"
2174
- }
2324
+ level: "warning",
2325
+ text: "Please accept the terms to continue.",
2326
+ "text.termsAccepted": "Ready to submit!"
2327
+ },
2328
+ exclude: { from: ["busy"] }
2329
+ },
2330
+ {
2331
+ kind: "input",
2332
+ type: "repeater",
2333
+ path: "users",
2334
+ label: "Users",
2335
+ addLabel: "Add user",
2336
+ "addLabel.limitReached": "Limit reached — can't add more",
2337
+ props: {
2338
+ removeLabel: "Remove",
2339
+ template: {
2340
+ kind: "layout",
2341
+ type: "flex",
2342
+ props: { direction: "column" },
2343
+ children: [
2344
+ { kind: "input", type: "textinput", path: "users.items.name", label: "Name" }
2345
+ ]
2175
2346
  }
2176
2347
  }
2177
2348
  }
@@ -2180,413 +2351,179 @@ const STRING_INTERPOLATION_CONCEPT = {
2180
2351
  }
2181
2352
  ],
2182
2353
  rules: [
2183
- "Slots must reference at least one of `$form`, `$meta`, `$errors`, or `$formIsInvalid`. A bare identifier without a scope prefix is invalid inside `{{}}`: use `{{$form.name}}` not `{{name}}`.",
2184
- "`$formIsInvalid` is a built-in boolean — use it as-is: `{{$formIsInvalid}}`. Do not chain properties onto it.",
2185
- "If an expression evaluates to `null` or `undefined`, the slot renders as an empty string in display text.",
2186
- "Use optional chaining (`?.`) when accessing nested fields that may not yet exist: `{{$form.address?.city}}` not `{{$form.address.city}}`.",
2187
- "Do not use assignment `=` inside a slot — slots are read-only. Use `===` for equality checks.",
2188
- "Slots cannot be nested: `{{$form.a {{$form.b}}}}` is invalid.",
2189
- "Every `{{` must have a matching `}}`. Unbalanced delimiters cause a lint warning.",
2190
- 'i18n `params` values are bare expressions — do NOT wrap them in `{{}}`. Write `"$form.name"` not `"{{$form.name}}"`.',
2191
- 'Static string params (not starting with `$`) are passed through as-is — use them for constant values like `"Hola"` or `"px"`.',
2192
- "Supported operators in expressions: arithmetic (`+`, `-`, `*`, `/`, `%`), comparison (`===`, `!==`, `<`, `>`, `<=`, `>=`), logical (`&&`, `||`, `!`), ternary (`? :`), optional chaining (`?.`), nullish coalescing (`??`).",
2193
- "Expressions are evaluated using a safe subset of JavaScript — no `eval`, no function calls, no side effects."
2354
+ 'Every state name used in `include.in`, `exclude.from`, or as a property suffix MUST be declared in the root `"states"` map.',
2355
+ "Child state expressions must contain only the *additional* (incremental) condition — ancestor conditions are ANDed in automatically by the runtime. Duplicating a parent condition in a child expression is wrong and redundant.",
2356
+ 'A sub-state is only ever active when all of its ancestor states are also active. When using `include.in: ["register:adult"]` you do NOT need to also add `"register"` to the array.',
2357
+ "State-suffixed root props — only these support suffixes at the widget root level: `label`, `disabled`, `readonly`, `validator`, `size`. All other overridable properties live inside `props`.",
2358
+ 'State-suffixed props inside `props` — any key inside the `props` object can be suffixed: `"hint.<state>"`, `"placeholder.<state>"`, `"items.<state>"`, `"addLabel.<state>"`, etc.',
2359
+ 'Suffix names must not contain dots (the dot is the separator between property and state name): `"label.myState"` ✅, `"label.register:adult"` ✅ — `"label.my.state"` ❌.',
2360
+ 'Reactive expressions must reference `$form`, `$meta`, or `$formIsInvalid`. A bare identifier like `termsAccepted` without a root reference is invalid. `$formIsInvalid` is a built-in boolean (no property chain — use it as-is: `disabled: { when: "$formIsInvalid" }` or inside a state expression: `states: { formInvalid: "$formIsInvalid" }`).',
2361
+ "Use `===` / `!==` for equality, `&&` / `||` for logic. Avoid `=` (assignment), `==`/`!=` (loose equality), or bitwise `&`/`|`.",
2362
+ 'When multiple states are active at the same time and a property has more than one matching suffix, the longest state name wins (most specific takes priority). Example: if both `register` and `register:adult` are active, `"label.register:adult"` overrides `"label.register"`.',
2363
+ "The `include.when` / `exclude.when` inline form is an alternative to named states for one-off conditions, but it cannot replace state-suffixed props — those require a named state.",
2364
+ '`include.in` and `exclude.from` each accept an Array of state names. A widget included `in: ["a", "b"]` renders when state `a` OR state `b` is active.',
2365
+ "Use optional chaining (`?.`) when accessing nested fields that may not yet exist in the form data: `$form.user?.age >= 18` not `$form.user.age >= 18`."
2194
2366
  ]
2195
2367
  };
2196
- const CONCEPTS = {
2197
- states: STATES_CONCEPT,
2198
- "string-interpolation": STRING_INTERPOLATION_CONCEPT
2199
- };
2200
- function getConcept(input) {
2201
- const result = CONCEPTS[input.concept];
2202
- if (!result) {
2203
- const known = Object.keys(CONCEPTS).sort().map((c) => `\`${c}\``).join(", ");
2204
- throw new Error(`Unknown concept \`${input.concept}\`. Known concepts: ${known}.`);
2205
- }
2206
- return result;
2207
- }
2208
- const GET_CONCEPT_TOOL = {
2209
- name: "get_concept",
2210
- description: 'Return a detailed guide for a cross-cutting GolemUI form concept — things that span multiple widgets and affect the whole form, rather than the API of a single widget. Call this when you need to: (1) change a widget\'s props based on form state (state-suffixed props like `"label.stateName": "…"`), or (2) reuse the same condition across multiple widgets (`include: { in: […] }` / `exclude: { from: […] }`). For a one-off show/hide on a single widget, use `include: { when: "…" }` or `exclude: { when: "…" }` directly — no states needed, no need to call this tool. Currently supported concepts: `states`, `string-interpolation`.',
2211
- inputSchema: {
2212
- type: "object",
2213
- properties: {
2214
- concept: {
2215
- type: "string",
2216
- description: 'The concept to explain. Currently supported: `"states"`, `"string-interpolation"`.',
2217
- enum: Object.keys(CONCEPTS)
2218
- }
2219
- },
2220
- required: ["concept"]
2221
- }
2222
- };
2223
- const EXAMPLES = {
2224
- accordion: {
2225
- kind: "layout",
2226
- type: "accordion",
2227
- props: {
2228
- sections: [
2229
- { uid: "personal", label: "Personal" },
2230
- { uid: "preferences", label: "Preferences" }
2231
- ]
2232
- },
2233
- children: [
2234
- { kind: "input", type: "textinput", path: "firstName", label: "First name" },
2235
- { kind: "input", type: "checkbox", path: "newsletter", label: "Newsletter" }
2236
- ]
2237
- },
2238
- textinput: {
2239
- kind: "input",
2240
- type: "textinput",
2241
- path: "firstName",
2242
- label: "First name",
2243
- validator: { type: "string", required: true, minLength: 1 },
2244
- props: { placeholder: "Jane" }
2245
- },
2246
- textarea: {
2247
- kind: "input",
2248
- type: "textarea",
2249
- path: "bio",
2250
- label: "Bio",
2251
- props: { minimumHeight: 120, autoGrow: true }
2252
- },
2253
- tags: {
2254
- kind: "input",
2255
- type: "tags",
2256
- path: "keywords",
2257
- label: "Keywords",
2258
- defaultValue: [],
2259
- props: {
2260
- placeholder: "Add a keyword and press Enter",
2261
- separators: ["Enter", ","],
2262
- trim: true,
2263
- allowDuplicates: false,
2264
- limit: 10
2265
- },
2266
- validator: {
2267
- type: "array",
2268
- minItems: 1,
2269
- maxItems: 10
2270
- }
2271
- },
2272
- password: {
2273
- kind: "input",
2274
- type: "password",
2275
- path: "password",
2276
- label: "Password",
2277
- validator: { type: "string", required: true, minLength: 8 }
2278
- },
2279
- number: {
2280
- kind: "input",
2281
- type: "number",
2282
- path: "age",
2283
- label: "Age",
2284
- validator: { type: "integer", required: true, minimum: 0 }
2285
- },
2286
- currency: {
2287
- kind: "input",
2288
- type: "currency",
2289
- path: "salary",
2290
- label: "Salary",
2291
- props: { currency: "USD" }
2292
- },
2293
- checkbox: {
2294
- kind: "input",
2295
- type: "checkbox",
2296
- path: "termsAccepted",
2297
- label: "I accept the terms",
2298
- validator: { type: "boolean", required: true, const: true }
2299
- },
2300
- toggle: {
2301
- kind: "input",
2302
- type: "toggle",
2303
- path: "notifications",
2304
- label: "Email notifications"
2305
- },
2306
- dropdown: {
2307
- kind: "input",
2308
- type: "dropdown",
2309
- path: "country",
2310
- label: "Country",
2311
- props: {
2312
- labelField: "label",
2313
- valueField: "value",
2314
- items: [
2315
- { label: "United States", value: "us", flag: "🇺🇸" },
2316
- { label: "Canada", value: "ca", flag: "🇨🇦" }
2317
- ]
2318
- }
2319
- },
2320
- select: {
2321
- kind: "input",
2322
- type: "select",
2323
- path: "plan",
2324
- label: "Plan",
2325
- props: {
2326
- options: [
2327
- { label: "Free", value: "free" },
2328
- { label: "Pro", value: "pro" }
2329
- ]
2330
- }
2331
- },
2332
- radiogroup: {
2333
- kind: "input",
2334
- type: "radiogroup",
2335
- path: "shippingSpeed",
2336
- label: "Shipping",
2337
- props: {
2338
- options: [
2339
- { label: "Standard", value: "standard" },
2340
- { label: "Express", value: "express" }
2341
- ]
2342
- }
2343
- },
2344
- dateInput: {
2345
- kind: "input",
2346
- type: "dateInput",
2347
- path: "birthDate",
2348
- label: "Birth date"
2349
- },
2350
- datePicker: {
2351
- kind: "input",
2352
- type: "datePicker",
2353
- path: "startDate",
2354
- label: "Start date"
2355
- },
2356
- calendar: {
2357
- kind: "input",
2358
- type: "calendar",
2359
- path: "eventDate",
2360
- label: "Event date"
2361
- },
2362
- button: {
2363
- kind: "action",
2364
- type: "button",
2365
- actionType: "submit",
2366
- label: "Submit",
2367
- props: { variant: "filled" }
2368
- },
2369
- alert: {
2370
- kind: "display",
2371
- type: "alert",
2372
- props: { level: "info", text: "Heads up!" }
2373
- },
2374
- markdownText: {
2375
- kind: "display",
2376
- type: "markdownText",
2377
- props: { md: "Welcome to the form." }
2378
- },
2379
- flex: {
2380
- kind: "layout",
2381
- type: "flex",
2382
- props: { direction: "column", gap: 12 },
2383
- children: [
2384
- {
2385
- kind: "input",
2386
- type: "textinput",
2387
- path: "firstName",
2388
- label: "First name"
2368
+ const STRING_INTERPOLATION_CONCEPT = {
2369
+ concept: "string-interpolation",
2370
+ summary: "GolemUI supports live data binding in text props via `{{expression}}` template slots. Any string-valued property (e.g. `props.text`, `props.hint`, `label`) can embed one or more `{{...}}` slots. Each slot is a JavaScript-like expression evaluated against the live form state using a safe subset of JavaScript (no side effects, no function calls). i18n translation `params` objects accept a matching bare-expression format — the same expressions but without the `{{}}` delimiters.",
2371
+ patterns: [
2372
+ {
2373
+ name: "Template slots in display text",
2374
+ description: "Embed `{{expression}}` in any string prop to inject live values. Available scopes: `$form` (all current field values), `$meta` (host-supplied metadata), `$errors` (current validation error messages keyed by field uid), `$formIsInvalid` (boolean — `true` when any field currently fails validation). Use optional chaining (`?.`) when accessing nested fields that may not yet exist. Multiple slots can appear in a single string.",
2375
+ example: {
2376
+ $schema: "https://golemui.com/schemas/form.schema.json",
2377
+ form: [
2378
+ {
2379
+ uid: "userName",
2380
+ kind: "input",
2381
+ type: "textinput",
2382
+ path: "userName",
2383
+ validator: { type: "string", required: true }
2384
+ },
2385
+ {
2386
+ uid: "submitBtn",
2387
+ kind: "action",
2388
+ type: "button",
2389
+ label: "Submit",
2390
+ actionType: "submit"
2391
+ },
2392
+ {
2393
+ uid: "greeting",
2394
+ kind: "display",
2395
+ type: "alert",
2396
+ props: { text: "Hello {{$form.userName}}" }
2397
+ },
2398
+ {
2399
+ uid: "status",
2400
+ kind: "display",
2401
+ type: "alert",
2402
+ props: {
2403
+ text: "Error: {{$errors.userName}} | Form invalid: {{$formIsInvalid}}"
2404
+ }
2405
+ },
2406
+ {
2407
+ uid: "meta-info",
2408
+ kind: "display",
2409
+ type: "alert",
2410
+ props: { text: "Connected as {{$meta.role}} on {{$meta.server}}" }
2411
+ }
2412
+ ]
2389
2413
  }
2390
- ]
2391
- },
2392
- grid: {
2393
- kind: "layout",
2394
- type: "grid",
2395
- props: { columnGap: 12, rowGap: 12 },
2396
- children: [
2397
- { kind: "input", type: "textinput", path: "firstName", label: "First name" },
2398
- { kind: "input", type: "textinput", path: "lastName", label: "Last name" }
2399
- ]
2400
- },
2401
- tabs: {
2402
- kind: "layout",
2403
- type: "tabs",
2404
- props: {
2405
- tabs: [
2406
- { uid: "personal", label: "Personal" },
2407
- { uid: "address", label: "Address" }
2408
- ]
2409
2414
  },
2410
- children: [
2411
- { kind: "input", type: "textinput", path: "firstName", label: "First name" },
2412
- { kind: "input", type: "textinput", path: "street", label: "Street" }
2413
- ]
2414
- },
2415
- repeater: {
2416
- kind: "input",
2417
- type: "repeater",
2418
- path: "addresses",
2419
- label: "Addresses",
2420
- props: {
2421
- addLabel: "Add address",
2422
- removeLabel: "Remove",
2423
- template: {
2424
- kind: "layout",
2425
- type: "flex",
2426
- props: { direction: "column" },
2427
- children: [
2428
- // Child paths inside a repeater template MUST be `<repeater.path>.items.<field>` —
2429
- // `items` is the reserved segment that the runtime expands per array entry.
2430
- { kind: "input", type: "textinput", path: "addresses.items.street", label: "Street" },
2431
- { kind: "input", type: "textinput", path: "addresses.items.city", label: "City" }
2415
+ {
2416
+ name: "Expressions in slots",
2417
+ description: "Slots support full JavaScript-like expressions: arithmetic, string concatenation, ternary conditionals, and optional chaining. The expression is evaluated against the same scope object (`$form`, `$meta`, `$errors`, `$formIsInvalid`). If the expression evaluates to `null` or `undefined`, the slot renders as an empty string.",
2418
+ example: {
2419
+ $schema: "https://golemui.com/schemas/form.schema.json",
2420
+ data: { firstName: "Jane", lastName: "Doe", count: 4, role: "admin" },
2421
+ form: [
2422
+ {
2423
+ uid: "full-name",
2424
+ kind: "display",
2425
+ type: "alert",
2426
+ props: { text: "Full name: {{$form.firstName + ' ' + $form.lastName}}" }
2427
+ },
2428
+ {
2429
+ uid: "next-count",
2430
+ kind: "display",
2431
+ type: "alert",
2432
+ props: { text: "Next item: {{$form.count + 1}}" }
2433
+ },
2434
+ {
2435
+ uid: "role-label",
2436
+ kind: "display",
2437
+ type: "alert",
2438
+ props: { text: "Role: {{$form.role === 'admin' ? 'Administrator' : 'User'}}" }
2439
+ },
2440
+ {
2441
+ uid: "nested",
2442
+ kind: "display",
2443
+ type: "alert",
2444
+ props: { text: "City: {{$form.address?.city}}" }
2445
+ }
2446
+ ]
2447
+ }
2448
+ },
2449
+ {
2450
+ name: "i18n param expressions",
2451
+ description: "When using i18n translations, `params` values support the same expression language as `{{}}` slots, but as **bare expressions** without the `{{}}` delimiters. Params that start with a `$` scope prefix are evaluated; others are passed as static strings. The expression has access to `$form`, `$meta`, `$errors`, and `$formIsInvalid`.",
2452
+ example: {
2453
+ $schema: "https://golemui.com/schemas/form.schema.json",
2454
+ data: { firstName: "Jane", lastName: "Doe", count: 4 },
2455
+ meta: { connectionStatus: "online" },
2456
+ form: [
2457
+ {
2458
+ uid: "greeting",
2459
+ kind: "display",
2460
+ type: "alert",
2461
+ props: {
2462
+ text: {
2463
+ key: "user.greeting",
2464
+ params: {
2465
+ hello: "Hola",
2466
+ fullName: "$form.firstName + ' ' + $form.lastName",
2467
+ n: "$form.count + 1",
2468
+ status: "$meta.connectionStatus"
2469
+ }
2470
+ }
2471
+ }
2472
+ }
2432
2473
  ]
2433
2474
  }
2434
2475
  }
2435
- }
2436
- };
2437
- const NOTES = {
2438
- textinput: [
2439
- "`path` is the dot-path into form data this field writes to.",
2440
- "`validator.format` supports: `email`, `hostname`, `ipv4`, `ipv6`, `url`, `uuid`, `date`, `time`, `date-time`, `duration`.",
2441
- 'Root props `label`, `disabled`, `readonly`, `validator`, and `size` accept state suffixes — e.g. `"label.<stateName>": "New label"` overrides the label only when that named state is active. Props inside `props` (e.g. `hint`, `placeholder`) also accept suffixes as `"hint.<stateName>"`. Call `get_concept({ concept: "states" })` for the full pattern.'
2442
- ],
2443
- markdownText: [
2444
- "Display-only widget for rendering markdown. Can be used as a top-level form widget (inside any layout) or inside templates like `dropdown.props.items[].template`.",
2445
- "The required prop is `md` (the markdown string), not `text`."
2446
- ],
2447
- tags: [
2448
- "Use `tags` for free-form arrays of primitive values (typically `string[]`) — e.g. keywords, email lists, hashtags. Backing data is `string[]`. For arrays of structured objects, use `repeater` instead.",
2449
- '`props.separators` controls which keys/characters commit a new tag. Defaults emit on `Enter` and `,`; you can also include `"Tab"` or `"blur"`. `props.trim` strips whitespace from each tag; `props.allowDuplicates: false` rejects repeats; `props.limit` caps the array length.',
2450
- 'Validate with an `arrayValidator`: `{ type: "array", minItems, maxItems, uniqueItems }`. The `tags` widget complements that with UI-level enforcement (`allowDuplicates`, `limit`) but the validator is still authoritative for form-level required/min/max constraints.'
2451
- ],
2452
- dropdown: [
2453
- "PREFER `select` for plain label/value lists (countries, plans, sizes). `dropdown` is for richer item shapes: when each item has extra fields (icons, flags, metadata) that an `itemRenderer` or `labelField`/`valueField` can use.",
2454
- '`props.items` is EITHER primitives `["one", 2, ...]` OR arbitrary objects `[{ label, value, ...extras }]`. Pair the object form with `labelField`/`valueField` to tell the dropdown which fields to render as label and which to use as the value. **Widget templates inside items (the `{template, value}` shape) are NOT supported — only `repeater` accepts widget templates.**'
2455
- ],
2456
- select: [
2457
- '`props.options[]` is the simple form: `[{ label: "United States", value: "us" }, ...]`. Use this for ANY plain text list — countries, plans, sizes, status enums. Only switch to `dropdown` if you need custom per-item rendering (icons, flags, rich layouts).',
2458
- "For very large lists (>50 items) consider `dropdown` for its virtualization (`height`, `itemHeight`, `searchFields`)."
2459
- ],
2460
- flex: ["`children` is an array of any widgets (inputs, displays, nested layouts)."],
2461
- grid: ["`children` is an array of any widgets. `props.columns` controls layout."],
2462
- tabs: [
2463
- "Children are associated with tabs by **`uid` matching**, not by array order: each direct child must have a `uid` field at the widget level (alongside `kind`/`type`) whose string value equals one of the `props.tabs[].uid` entries. There is no `tag` property — that is not a real GolemUI field.",
2464
- "Children are typically written in display order for readability, but rearranging them does not change which tab they belong to — only the `uid` string match does."
2465
- ],
2466
- accordion: [
2467
- "Children are associated with sections by **`uid` matching** (same pattern as `tabs`): each direct child must have a `uid` field at the widget level whose value equals one of the `props.sections[].uid` entries. There is no `tag` property.",
2468
- "`props.defaultOpen` is a map of `{ <sectionUid>: boolean }` controlling which sections start expanded."
2469
- ],
2470
- repeater: [
2471
- "`props.template` must be a layout widget (flex/grid/tabs/accordion) whose children are the per-item fields.",
2472
- 'Child paths inside the template MUST follow the form `<repeater.path>.items.<fieldName>`. The `items` segment is reserved — the runtime substitutes it with the current array index per row. For example, a repeater at `path: "users"` with a child `firstName` uses `path: "users.items.firstName"`. Plain `firstName` will NOT bind to the array.',
2473
- 'Nested repeaters chain the convention: a repeater at `path: "teams"` whose template contains a repeater at `path: "teams.items.members"` whose children use `path: "teams.items.members.items.<field>"`.',
2474
- '`addLabel` supports state suffixes: `"addLabel.<stateName>": "Limit reached"` swaps the add-button label when a named state is active — useful for capping array length. Call `get_concept({ concept: "states" })` for the full pattern.'
2475
- ],
2476
- button: [
2477
- "`actionType` controls the button's role. `actionType: \"submit\"` makes the button fire the form's `formSubmit` event natively — the host listens for it via `(formSubmit)` (Angular), `@formSubmit` (Vue), `onFormSubmit` (React), or the `form-submit` event (Lit). No custom handler needed. Use this for the primary submit button on a form.",
2478
- '`actionType: "button"` (the default, can be omitted) is a regular action button. Wire it via `on.click: "<handlerName>"` where `<handlerName>` is registered in the form config\'s event handlers.',
2479
- 'Supports state-suffixed props: `"label.<stateName>"` and `"disabled.<stateName>"` swap the label or disabled state when a named state is active — e.g. disable the submit button until terms are accepted, then re-enable it. Call `get_concept({ concept: "states" })` for the full pattern.'
2480
2476
  ],
2481
- checkbox: ["Set `validator.const: true` to require the user to tick it (e.g. terms acceptance)."],
2482
- alert: [
2483
- 'Use `include: { in: ["stateName"] }` to show this alert only when a named state is active, or `exclude: { from: ["stateName"] }` to hide it when a state is active. This is cleaner than `include: { when: "..." }` when the same condition is reused across multiple widgets. Call `get_concept({ concept: "states" })` for the full states pattern.',
2484
- 'Props inside `props` (e.g. `text`, `level`) accept state suffixes: `"text.<stateName>": "New message"` swaps the message when that state is active.'
2477
+ rules: [
2478
+ "Slots must reference at least one of `$form`, `$meta`, `$errors`, or `$formIsInvalid`. A bare identifier without a scope prefix is invalid inside `{{}}`: use `{{$form.name}}` not `{{name}}`.",
2479
+ "`$formIsInvalid` is a built-in boolean — use it as-is: `{{$formIsInvalid}}`. Do not chain properties onto it.",
2480
+ "If an expression evaluates to `null` or `undefined`, the slot renders as an empty string in display text.",
2481
+ "Use optional chaining (`?.`) when accessing nested fields that may not yet exist: `{{$form.address?.city}}` not `{{$form.address.city}}`.",
2482
+ "Do not use assignment `=` inside a slot — slots are read-only. Use `===` for equality checks.",
2483
+ "Slots cannot be nested: `{{$form.a {{$form.b}}}}` is invalid.",
2484
+ "Every `{{` must have a matching `}}`. Unbalanced delimiters cause a lint warning.",
2485
+ 'i18n `params` values are bare expressions — do NOT wrap them in `{{}}`. Write `"$form.name"` not `"{{$form.name}}"`.',
2486
+ 'Static string params (not starting with `$`) are passed through as-is — use them for constant values like `"Hola"` or `"px"`.',
2487
+ "Supported operators in expressions: arithmetic (`+`, `-`, `*`, `/`, `%`), comparison (`===`, `!==`, `<`, `>`, `<=`, `>=`), logical (`&&`, `||`, `!`), ternary (`? :`), optional chaining (`?.`), nullish coalescing (`??`).",
2488
+ "Expressions are evaluated using a safe subset of JavaScript — no `eval`, no function calls, no side effects."
2485
2489
  ]
2486
2490
  };
2487
- function synthesizeExample(widgetType, schema) {
2488
- const props = schema["properties"];
2489
- const kindProp = props?.["kind"]?.const ?? "input";
2490
- const ex = { kind: kindProp, type: widgetType };
2491
- if (props?.["path"]) ex["path"] = "field";
2492
- if (props?.["label"]) ex["label"] = "Field";
2493
- return ex;
2494
- }
2495
- function getWidgetSpec(input) {
2496
- const schema = COMPONENT_SCHEMAS[input.widgetType];
2497
- if (!schema) {
2498
- const known = Object.keys(COMPONENT_SCHEMAS).sort().join(", ");
2499
- throw new Error(`Unknown widget type \`${input.widgetType}\`. Known: ${known}.`);
2491
+ const CONCEPTS = {
2492
+ states: STATES_CONCEPT,
2493
+ "string-interpolation": STRING_INTERPOLATION_CONCEPT
2494
+ };
2495
+ function getConcept(input) {
2496
+ const result = CONCEPTS[input.concept];
2497
+ if (!result) {
2498
+ const known = Object.keys(CONCEPTS).sort().map((c) => `\`${c}\``).join(", ");
2499
+ throw new Error(`Unknown concept \`${input.concept}\`. Known concepts: ${known}.`);
2500
2500
  }
2501
- const props = schema["properties"];
2502
- const kindProp = props?.["kind"]?.const ?? "unknown";
2503
- return {
2504
- widgetType: input.widgetType,
2505
- schema,
2506
- kind: kindProp,
2507
- example: EXAMPLES[input.widgetType] ?? synthesizeExample(input.widgetType, schema),
2508
- notes: NOTES[input.widgetType] ?? []
2509
- };
2501
+ return result;
2510
2502
  }
2511
- const GET_WIDGET_SPEC_TOOL = {
2512
- name: "get_widget_spec",
2513
- description: "Look up the JSON Schema and a minimal working example for a single GolemUI widget. Use this when you need to know which `props` a widget accepts, what `kind` value it uses, or what shape its `validator` takes. Cheaper than dumping the whole API into context.",
2503
+ const GET_CONCEPT_TOOL = {
2504
+ name: "get_concept",
2505
+ description: 'Return a detailed guide for a cross-cutting GolemUI form concept — things that span multiple widgets and affect the whole form, rather than the API of a single widget. Call this when you need to: (1) change a widget\'s props based on form state (state-suffixed props like `"label.stateName": "…"`), or (2) reuse the same condition across multiple widgets (`include: { in: […] }` / `exclude: { from: […] }`). For a one-off show/hide on a single widget, use `include: { when: "…" }` or `exclude: { when: "…" }` directly — no states needed, no need to call this tool. Currently supported concepts: `states`, `string-interpolation`.',
2514
2506
  inputSchema: {
2515
2507
  type: "object",
2516
2508
  properties: {
2517
- widgetType: {
2509
+ concept: {
2518
2510
  type: "string",
2519
- description: "The widget `type` constant. One of: " + Object.keys(COMPONENT_SCHEMAS).sort().map((t) => `\`${t}\``).join(", ") + "."
2511
+ description: 'The concept to explain. Currently supported: `"states"`, `"string-interpolation"`.',
2512
+ enum: Object.keys(CONCEPTS)
2520
2513
  }
2521
2514
  },
2522
- required: ["widgetType"]
2515
+ required: ["concept"]
2523
2516
  }
2524
2517
  };
2525
- function readPackageMeta() {
2526
- const here = dirname(fileURLToPath(import.meta.url));
2527
- for (const candidate of [join(here, "package.json"), join(here, "..", "package.json")]) {
2528
- try {
2529
- const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
2530
- if (pkg.name && pkg.version) return { name: pkg.name, version: pkg.version };
2531
- } catch {
2532
- }
2533
- }
2534
- return { name: "@golemui/gui-mcp", version: "0.0.0" };
2535
- }
2536
- const { name: PKG_NAME, version: PKG_VERSION } = readPackageMeta();
2537
- const TOOLS = [
2538
- VALIDATE_FORM_DEFINITION_TOOL,
2539
- GENERATE_FROM_JSON_SCHEMA_TOOL,
2540
- GENERATE_FROM_OPENAPI_TOOL,
2541
- GET_WIDGET_SPEC_TOOL,
2542
- GET_CONCEPT_TOOL
2543
- ];
2544
- const SERVER_INSTRUCTIONS = 'This server builds and validates GolemUI form definitions — declarative, JSON-serializable forms shaped as `{ form: [...widgets], states?: {...} }`. Its job is to help you produce a form definition that is guaranteed correct before the user pastes it into their codebase.\n\nRecommended workflow:\n1. Starting from an existing schema? Use a generator — both return a pre-validated definition, so check the returned `unmapped` list and surface anything left over to the user. For a raw JSON Schema (e.g. an API request body), call `generate_from_json_schema`. For an OpenAPI 3.x spec, call `generate_from_openapi`: pass `operation` as "METHOD /path" (e.g. "POST /users") or an exact operationId, plus the spec as a parsed `document` or a `documentUrl` to fetch — it resolves the operation\'s request body, dereferences `$ref`s, and falls back to the operation\'s parameters when there is no request body.\n2. Building or editing by hand? Look up a single widget with `get_widget_spec` (its `kind`, `props`, and `validator` shape), and cross-cutting behavior that spans widgets — conditional rendering, per-state prop overrides — with `get_concept`.\n3. ALWAYS finish by calling `validate_form_definition`. It checks the definition against the bundled JSON Schemas and returns `{ valid, errors, warnings, expressionWarnings }`. Treat `errors` as blocking: fix them and re-validate until `valid` is true. `warnings` (likely-custom widgets) and `expressionWarnings` (linted reactive expressions) are advisory — surface them, but they do not flip `valid`.\n\nDo not hand a form definition to the user until `validate_form_definition` reports `valid: true`.';
2545
- const server = new Server(
2546
- { name: PKG_NAME, version: PKG_VERSION },
2547
- { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
2548
- );
2549
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
2550
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2551
- const { name, arguments: args } = request.params;
2552
- try {
2553
- switch (name) {
2554
- case "validate_form_definition":
2555
- return ok(validateFormDefinition(args));
2556
- case "generate_from_json_schema":
2557
- return ok(generateFromJsonSchema(args));
2558
- case "generate_from_openapi":
2559
- return ok(await generateFromOpenapi(args));
2560
- case "get_widget_spec":
2561
- return ok(getWidgetSpec(args));
2562
- case "get_concept":
2563
- return ok(getConcept(args));
2564
- default:
2565
- return err(`Unknown tool: ${name}`);
2566
- }
2567
- } catch (e) {
2568
- return err(e.message);
2569
- }
2570
- });
2571
- function ok(payload) {
2572
- return {
2573
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
2574
- };
2575
- }
2576
- function err(message) {
2577
- return {
2578
- isError: true,
2579
- content: [{ type: "text", text: message }]
2580
- };
2581
- }
2582
- async function main() {
2583
- const transport = new StdioServerTransport();
2584
- await server.connect(transport);
2585
- process.stderr.write(`${PKG_NAME} v${PKG_VERSION} ready on stdio
2586
- `);
2587
- }
2588
- main().catch((e) => {
2589
- process.stderr.write(`${PKG_NAME} failed to start: ${e.stack ?? e}
2590
- `);
2591
- process.exit(1);
2592
- });
2518
+ export {
2519
+ GENERATE_FROM_JSON_SCHEMA_TOOL as G,
2520
+ VALIDATE_FORM_DEFINITION_TOOL as V,
2521
+ generateFromOpenapi as a,
2522
+ GENERATE_FROM_OPENAPI_TOOL as b,
2523
+ getWidgetSpec as c,
2524
+ GET_WIDGET_SPEC_TOOL as d,
2525
+ getConcept as e,
2526
+ GET_CONCEPT_TOOL as f,
2527
+ generateFromJsonSchema as g,
2528
+ validateFormDefinition as v
2529
+ };