@ic-reactor/candid 3.0.2-beta.0 → 3.0.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.
Files changed (83) hide show
  1. package/README.md +33 -1
  2. package/dist/adapter.js +2 -1
  3. package/dist/adapter.js.map +1 -1
  4. package/dist/display-reactor.d.ts +4 -13
  5. package/dist/display-reactor.d.ts.map +1 -1
  6. package/dist/display-reactor.js +22 -8
  7. package/dist/display-reactor.js.map +1 -1
  8. package/dist/index.d.ts +3 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/metadata-display-reactor.d.ts +108 -0
  13. package/dist/metadata-display-reactor.d.ts.map +1 -0
  14. package/dist/metadata-display-reactor.js +141 -0
  15. package/dist/metadata-display-reactor.js.map +1 -0
  16. package/dist/reactor.d.ts +1 -1
  17. package/dist/reactor.d.ts.map +1 -1
  18. package/dist/reactor.js +10 -6
  19. package/dist/reactor.js.map +1 -1
  20. package/dist/types.d.ts +38 -7
  21. package/dist/types.d.ts.map +1 -1
  22. package/dist/utils.d.ts +4 -4
  23. package/dist/utils.d.ts.map +1 -1
  24. package/dist/utils.js +33 -10
  25. package/dist/utils.js.map +1 -1
  26. package/dist/visitor/arguments/helpers.d.ts +55 -0
  27. package/dist/visitor/arguments/helpers.d.ts.map +1 -0
  28. package/dist/visitor/arguments/helpers.js +123 -0
  29. package/dist/visitor/arguments/helpers.js.map +1 -0
  30. package/dist/visitor/arguments/index.d.ts +101 -0
  31. package/dist/visitor/arguments/index.d.ts.map +1 -0
  32. package/dist/visitor/arguments/index.js +780 -0
  33. package/dist/visitor/arguments/index.js.map +1 -0
  34. package/dist/visitor/arguments/types.d.ts +270 -0
  35. package/dist/visitor/arguments/types.d.ts.map +1 -0
  36. package/dist/visitor/arguments/types.js +26 -0
  37. package/dist/visitor/arguments/types.js.map +1 -0
  38. package/dist/visitor/constants.d.ts +4 -0
  39. package/dist/visitor/constants.d.ts.map +1 -0
  40. package/dist/visitor/constants.js +73 -0
  41. package/dist/visitor/constants.js.map +1 -0
  42. package/dist/visitor/helpers.d.ts +30 -0
  43. package/dist/visitor/helpers.d.ts.map +1 -0
  44. package/dist/visitor/helpers.js +204 -0
  45. package/dist/visitor/helpers.js.map +1 -0
  46. package/dist/visitor/index.d.ts +5 -0
  47. package/dist/visitor/index.d.ts.map +1 -0
  48. package/dist/visitor/index.js +5 -0
  49. package/dist/visitor/index.js.map +1 -0
  50. package/dist/visitor/returns/index.d.ts +38 -0
  51. package/dist/visitor/returns/index.d.ts.map +1 -0
  52. package/dist/visitor/returns/index.js +460 -0
  53. package/dist/visitor/returns/index.js.map +1 -0
  54. package/dist/visitor/returns/types.d.ts +202 -0
  55. package/dist/visitor/returns/types.d.ts.map +1 -0
  56. package/dist/visitor/returns/types.js +2 -0
  57. package/dist/visitor/returns/types.js.map +1 -0
  58. package/dist/visitor/types.d.ts +19 -0
  59. package/dist/visitor/types.d.ts.map +1 -0
  60. package/dist/visitor/types.js +2 -0
  61. package/dist/visitor/types.js.map +1 -0
  62. package/package.json +16 -7
  63. package/src/adapter.ts +446 -0
  64. package/src/constants.ts +11 -0
  65. package/src/display-reactor.ts +337 -0
  66. package/src/index.ts +8 -0
  67. package/src/metadata-display-reactor.ts +230 -0
  68. package/src/reactor.ts +199 -0
  69. package/src/types.ts +127 -0
  70. package/src/utils.ts +60 -0
  71. package/src/visitor/arguments/helpers.ts +153 -0
  72. package/src/visitor/arguments/index.test.ts +1439 -0
  73. package/src/visitor/arguments/index.ts +981 -0
  74. package/src/visitor/arguments/schema.test.ts +324 -0
  75. package/src/visitor/arguments/types.ts +387 -0
  76. package/src/visitor/constants.ts +76 -0
  77. package/src/visitor/helpers.test.ts +274 -0
  78. package/src/visitor/helpers.ts +223 -0
  79. package/src/visitor/index.ts +4 -0
  80. package/src/visitor/returns/index.test.ts +2377 -0
  81. package/src/visitor/returns/index.ts +658 -0
  82. package/src/visitor/returns/types.ts +302 -0
  83. package/src/visitor/types.ts +75 -0
@@ -0,0 +1,981 @@
1
+ import { isQuery } from "../helpers"
2
+ import { checkTextFormat, checkNumberFormat } from "../constants"
3
+ import { MetadataError } from "./types"
4
+ import type {
5
+ FieldNode,
6
+ RecordField,
7
+ VariantField,
8
+ TupleField,
9
+ OptionalField,
10
+ VectorField,
11
+ BlobField,
12
+ RecursiveField,
13
+ PrincipalField,
14
+ NumberField,
15
+ BooleanField,
16
+ NullField,
17
+ TextField,
18
+ UnknownField,
19
+ ArgumentsMeta,
20
+ ArgumentsServiceMeta,
21
+ RenderHint,
22
+ PrimitiveInputProps,
23
+ BlobLimits,
24
+ BlobValidationResult,
25
+ TextFormat,
26
+ NumberFormat,
27
+ } from "./types"
28
+
29
+ import { IDL } from "@icp-sdk/core/candid"
30
+ import { Principal } from "@icp-sdk/core/principal"
31
+ import { BaseActor, FunctionName } from "@ic-reactor/core"
32
+ import * as z from "zod"
33
+ import { formatLabel } from "./helpers"
34
+
35
+ export * from "./types"
36
+ export * from "./helpers"
37
+ export { checkTextFormat, checkNumberFormat } from "../constants"
38
+
39
+ // ════════════════════════════════════════════════════════════════════════════
40
+ // Render Hint Helpers
41
+ // ════════════════════════════════════════════════════════════════════════════
42
+
43
+ const COMPOUND_RENDER_HINT: RenderHint = {
44
+ isCompound: true,
45
+ isPrimitive: false,
46
+ }
47
+
48
+ const TEXT_RENDER_HINT: RenderHint = {
49
+ isCompound: false,
50
+ isPrimitive: true,
51
+ inputType: "text",
52
+ }
53
+
54
+ const NUMBER_RENDER_HINT: RenderHint = {
55
+ isCompound: false,
56
+ isPrimitive: true,
57
+ inputType: "number",
58
+ }
59
+
60
+ const CHECKBOX_RENDER_HINT: RenderHint = {
61
+ isCompound: false,
62
+ isPrimitive: true,
63
+ inputType: "checkbox",
64
+ }
65
+
66
+ const FILE_RENDER_HINT: RenderHint = {
67
+ isCompound: false,
68
+ isPrimitive: true,
69
+ inputType: "file",
70
+ }
71
+
72
+ // ════════════════════════════════════════════════════════════════════════════
73
+ // Blob Field Helpers
74
+ // ════════════════════════════════════════════════════════════════════════════
75
+
76
+ const DEFAULT_BLOB_LIMITS: BlobLimits = {
77
+ maxHexBytes: 512,
78
+ maxFileBytes: 2 * 1024 * 1024, // 2MB
79
+ maxHexDisplayLength: 128,
80
+ }
81
+
82
+ function normalizeHex(input: string): string {
83
+ // Remove 0x prefix and convert to lowercase
84
+ let hex = input.toLowerCase()
85
+ if (hex.startsWith("0x")) {
86
+ hex = hex.slice(2)
87
+ }
88
+ // Remove any whitespace
89
+ hex = hex.replace(/\s/g, "")
90
+ return hex
91
+ }
92
+
93
+ function validateBlobInput(
94
+ value: string | Uint8Array,
95
+ limits: BlobLimits
96
+ ): BlobValidationResult {
97
+ if (value instanceof Uint8Array) {
98
+ if (value.length > limits.maxFileBytes) {
99
+ return {
100
+ valid: false,
101
+ error: `File size exceeds maximum of ${limits.maxFileBytes} bytes`,
102
+ }
103
+ }
104
+ return { valid: true }
105
+ }
106
+
107
+ // String input (hex)
108
+ const normalized = normalizeHex(value)
109
+ if (normalized.length === 0) {
110
+ return { valid: true } // Empty is valid
111
+ }
112
+
113
+ if (!/^[0-9a-f]*$/.test(normalized)) {
114
+ return { valid: false, error: "Invalid hex characters" }
115
+ }
116
+
117
+ if (normalized.length % 2 !== 0) {
118
+ return { valid: false, error: "Hex string must have even length" }
119
+ }
120
+
121
+ const byteLength = normalized.length / 2
122
+ if (byteLength > limits.maxHexBytes) {
123
+ return {
124
+ valid: false,
125
+ error: `Hex input exceeds maximum of ${limits.maxHexBytes} bytes`,
126
+ }
127
+ }
128
+
129
+ return { valid: true }
130
+ }
131
+
132
+ /**
133
+ * FieldVisitor generates metadata for form input fields from Candid IDL types.
134
+ *
135
+ * ## Design Principles
136
+ *
137
+ * 1. **Works with raw IDL types** - generates metadata at initialization time
138
+ * 2. **No value dependencies** - metadata is independent of actual values
139
+ * 3. **Form-framework agnostic** - output can be used with TanStack, React Hook Form, etc.
140
+ * 4. **Efficient** - single traversal, no runtime type checking
141
+ * 5. **TanStack Form optimized** - name paths compatible with TanStack Form patterns
142
+ *
143
+ * ## Output Structure
144
+ *
145
+ * Each field has:
146
+ * - `type`: The field type (record, variant, text, number, etc.)
147
+ * - `label`: Raw label from Candid
148
+ * - `displayLabel`: Human-readable formatted label
149
+ * - `name`: TanStack Form compatible path (e.g., "[0]", "[0].owner", "tags[1]")
150
+ * - `component`: Suggested component type for rendering
151
+ * - `renderHint`: Hints for UI rendering strategy
152
+ * - `defaultValue`: Initial value for the form
153
+ * - `schema`: Zod schema for validation
154
+ * - Type-specific properties (options for variant, fields for record, etc.)
155
+ * - Helper methods for dynamic forms (getOptionDefault, getItemDefault, etc.)
156
+ *
157
+ * ## Usage with TanStack Form
158
+ *
159
+ * @example
160
+ * ```typescript
161
+ * import { useForm } from '@tanstack/react-form'
162
+ * import { FieldVisitor } from '@ic-reactor/candid'
163
+ *
164
+ * const visitor = new FieldVisitor()
165
+ * const serviceMeta = service.accept(visitor, null)
166
+ * const methodMeta = serviceMeta["icrc1_transfer"]
167
+ *
168
+ * const form = useForm({
169
+ * defaultValues: methodMeta.defaultValue,
170
+ * validators: { onBlur: methodMeta.schema },
171
+ * onSubmit: async ({ value }) => {
172
+ * await actor.icrc1_transfer(...value)
173
+ * }
174
+ * })
175
+ *
176
+ * // Render fields dynamically
177
+ * methodMeta.fields.map((field, index) => (
178
+ * <form.Field key={index} name={field.name}>
179
+ * {(fieldApi) => <DynamicInput field={field} fieldApi={fieldApi} />}
180
+ * </form.Field>
181
+ * ))
182
+ * ```
183
+ */
184
+ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
185
+ string,
186
+ FieldNode | ArgumentsMeta<A> | ArgumentsServiceMeta<A>
187
+ > {
188
+ public recursiveSchemas: Map<string, z.ZodTypeAny> = new Map()
189
+
190
+ private nameStack: string[] = []
191
+
192
+ /**
193
+ * Execute function with a name segment pushed onto the stack.
194
+ * Automatically manages stack cleanup.
195
+ */
196
+ private withName<T>(name: string, fn: () => T): T {
197
+ this.nameStack.push(name)
198
+ try {
199
+ return fn()
200
+ } finally {
201
+ this.nameStack.pop()
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Get the current full name path for form binding.
207
+ * Returns empty string for root level.
208
+ */
209
+ private currentName(): string {
210
+ return this.nameStack.join("")
211
+ }
212
+
213
+ // ════════════════════════════════════════════════════════════════════════
214
+ // Service & Function Level
215
+ // ════════════════════════════════════════════════════════════════════════
216
+
217
+ public visitService(t: IDL.ServiceClass): ArgumentsServiceMeta<A> {
218
+ const result = {} as ArgumentsServiceMeta<A>
219
+
220
+ for (const [functionName, func] of t._fields) {
221
+ result[functionName as FunctionName<A>] = func.accept(
222
+ this,
223
+ functionName
224
+ ) as ArgumentsMeta<A>
225
+ }
226
+
227
+ return result
228
+ }
229
+
230
+ public visitFunc(
231
+ t: IDL.FuncClass,
232
+ functionName: FunctionName<A>
233
+ ): ArgumentsMeta<A> {
234
+ const functionType = isQuery(t) ? "query" : "update"
235
+ const argCount = t.argTypes.length
236
+
237
+ const args = t.argTypes.map((arg, index) => {
238
+ return this.withName(`[${index}]`, () =>
239
+ arg.accept(this, `__arg${index}`)
240
+ ) as FieldNode
241
+ })
242
+
243
+ const defaults = args.map((field) => field.defaultValue)
244
+
245
+ // Handle empty args case for schema
246
+ // For no-arg functions, use an empty array schema
247
+ // For functions with args, use a proper tuple schema
248
+ const schema =
249
+ argCount === 0
250
+ ? (z.tuple([]) as unknown as z.ZodTuple<
251
+ [z.ZodTypeAny, ...z.ZodTypeAny[]]
252
+ >)
253
+ : z.tuple(
254
+ args.map((field) => field.schema) as [
255
+ z.ZodTypeAny,
256
+ ...z.ZodTypeAny[],
257
+ ]
258
+ )
259
+
260
+ return {
261
+ candidType: t.name,
262
+ functionType,
263
+ functionName,
264
+ args,
265
+ defaults,
266
+ schema,
267
+ argCount,
268
+ isEmpty: argCount === 0,
269
+ }
270
+ }
271
+
272
+ // ════════════════════════════════════════════════════════════════════════
273
+ // Compound Types
274
+ // ════════════════════════════════════════════════════════════════════════
275
+
276
+ public visitRecord(
277
+ _t: IDL.RecordClass,
278
+ fields_: Array<[string, IDL.Type]>,
279
+ label: string
280
+ ): RecordField {
281
+ const name = this.currentName()
282
+ const fields: FieldNode[] = []
283
+ const defaultValue: Record<string, unknown> = {}
284
+ const schemaShape: Record<string, z.ZodTypeAny> = {}
285
+
286
+ for (const [key, type] of fields_) {
287
+ const field = this.withName(name ? `.${key}` : key, () =>
288
+ type.accept(this, key)
289
+ ) as FieldNode
290
+
291
+ fields.push(field)
292
+ defaultValue[key] = field.defaultValue
293
+ schemaShape[key] = field.schema
294
+ }
295
+
296
+ const schema = z.object(schemaShape)
297
+
298
+ return {
299
+ type: "record",
300
+ label,
301
+ displayLabel: formatLabel(label),
302
+ name,
303
+ component: "record-container",
304
+ renderHint: COMPOUND_RENDER_HINT,
305
+ fields,
306
+ defaultValue,
307
+ schema,
308
+ candidType: "record",
309
+ }
310
+ }
311
+
312
+ public visitVariant(
313
+ _t: IDL.VariantClass,
314
+ fields_: Array<[string, IDL.Type]>,
315
+ label: string
316
+ ): VariantField {
317
+ const name = this.currentName()
318
+ const options: FieldNode[] = []
319
+ const variantSchemas: z.ZodTypeAny[] = []
320
+
321
+ for (const [key, type] of fields_) {
322
+ const field = this.withName(`.${key}`, () =>
323
+ type.accept(this, key)
324
+ ) as FieldNode
325
+
326
+ options.push(field)
327
+
328
+ if (field.type === "null") {
329
+ variantSchemas.push(z.object({ _type: z.literal(key) }))
330
+ } else {
331
+ variantSchemas.push(
332
+ z.object({
333
+ _type: z.literal(key),
334
+ [key]: field.schema,
335
+ })
336
+ )
337
+ }
338
+ }
339
+
340
+ const firstOption = options[0]
341
+ const defaultOption = firstOption.label
342
+
343
+ const defaultValue =
344
+ firstOption.type === "null"
345
+ ? { _type: defaultOption }
346
+ : {
347
+ _type: defaultOption,
348
+ [defaultOption]: firstOption.defaultValue,
349
+ }
350
+
351
+ const schema = z.union(variantSchemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
352
+
353
+ // Helper to get default value for any option
354
+ const getOptionDefault = (option: string): Record<string, unknown> => {
355
+ const optField = options.find((f) => f.label === option)
356
+ if (!optField) {
357
+ throw new MetadataError(
358
+ `Unknown variant option: "${option}". Available: ${options.map((o) => o.label).join(", ")}`,
359
+ name,
360
+ "variant"
361
+ )
362
+ }
363
+ return optField.type === "null"
364
+ ? { _type: option }
365
+ : { _type: option, [option]: optField.defaultValue }
366
+ }
367
+
368
+ // Helper to get field for a specific option
369
+ const getOption = (option: string): FieldNode => {
370
+ const optField = options.find((f) => f.label === option)
371
+ if (!optField) {
372
+ throw new MetadataError(
373
+ `Unknown variant option: "${option}". Available: ${options.map((o) => o.label).join(", ")}`,
374
+ name,
375
+ "variant"
376
+ )
377
+ }
378
+ return optField
379
+ }
380
+
381
+ // Helper to get currently selected option key from a value
382
+ const getSelectedKey = (value: Record<string, unknown>): string => {
383
+ if (value._type && typeof value._type === "string") {
384
+ return value._type
385
+ }
386
+ const validKeys = Object.keys(value).filter((k) =>
387
+ options.some((f) => f.label === k)
388
+ )
389
+ return validKeys[0] ?? defaultOption
390
+ }
391
+
392
+ // Helper to get the field for the currently selected option
393
+ const getSelectedOption = (value: Record<string, unknown>): FieldNode => {
394
+ const selectedKey = getSelectedKey(value)
395
+ return getOption(selectedKey)
396
+ }
397
+
398
+ return {
399
+ type: "variant",
400
+ label,
401
+ displayLabel: formatLabel(label),
402
+ name,
403
+ component: "variant-select",
404
+ renderHint: COMPOUND_RENDER_HINT,
405
+ options,
406
+ defaultOption,
407
+ defaultValue,
408
+ schema,
409
+ getOptionDefault,
410
+ getOption,
411
+ getSelectedKey,
412
+ getSelectedOption,
413
+ candidType: "variant",
414
+ }
415
+ }
416
+
417
+ public visitTuple<T extends IDL.Type[]>(
418
+ _t: IDL.TupleClass<T>,
419
+ components: IDL.Type[],
420
+ label: string
421
+ ): TupleField {
422
+ const name = this.currentName()
423
+ const fields: FieldNode[] = []
424
+ const defaultValue: unknown[] = []
425
+ const schemas: z.ZodTypeAny[] = []
426
+
427
+ for (let index = 0; index < components.length; index++) {
428
+ const type = components[index]
429
+ const field = this.withName(`[${index}]`, () =>
430
+ type.accept(this, `_${index}_`)
431
+ ) as FieldNode
432
+
433
+ fields.push(field)
434
+ defaultValue.push(field.defaultValue)
435
+ schemas.push(field.schema)
436
+ }
437
+
438
+ const schema = z.tuple(schemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
439
+
440
+ return {
441
+ type: "tuple",
442
+ label,
443
+ displayLabel: formatLabel(label),
444
+ name,
445
+ component: "tuple-container",
446
+ renderHint: COMPOUND_RENDER_HINT,
447
+ fields,
448
+ defaultValue,
449
+ schema,
450
+ candidType: "tuple",
451
+ }
452
+ }
453
+
454
+ public visitOpt<T>(
455
+ _t: IDL.OptClass<T>,
456
+ ty: IDL.Type<T>,
457
+ label: string
458
+ ): OptionalField {
459
+ const name = this.currentName()
460
+
461
+ // For optional, the inner field keeps the same name path
462
+ // because the value replaces null directly (not nested)
463
+ const innerField = ty.accept(this, label) as FieldNode
464
+
465
+ const schema = z.union([
466
+ innerField.schema,
467
+ z.null(),
468
+ z.undefined().transform(() => null),
469
+ ])
470
+
471
+ // Helper to get the inner default when enabling the optional
472
+ const getInnerDefault = (): unknown => innerField.defaultValue
473
+
474
+ // Helper to check if a value represents an enabled optional
475
+ const isEnabled = (value: unknown): boolean => {
476
+ return value !== null && typeof value !== "undefined"
477
+ }
478
+
479
+ return {
480
+ type: "optional",
481
+ label,
482
+ displayLabel: formatLabel(label),
483
+ name,
484
+ component: "optional-toggle",
485
+ renderHint: COMPOUND_RENDER_HINT,
486
+ innerField,
487
+ defaultValue: null,
488
+ schema,
489
+ getInnerDefault,
490
+ isEnabled,
491
+ candidType: "opt",
492
+ }
493
+ }
494
+
495
+ public visitVec<T>(
496
+ _t: IDL.VecClass<T>,
497
+ ty: IDL.Type<T>,
498
+ label: string
499
+ ): VectorField | BlobField {
500
+ const name = this.currentName()
501
+
502
+ // Check if it's blob (vec nat8)
503
+ const isBlob = ty instanceof IDL.FixedNatClass && ty._bits === 8
504
+
505
+ // Item field uses [0] as template path
506
+ const itemField = this.withName("[0]", () =>
507
+ ty.accept(this, `${label}_item`)
508
+ ) as FieldNode
509
+
510
+ if (isBlob) {
511
+ const schema = z.union([
512
+ z.string(),
513
+ z.array(z.number()),
514
+ z.instanceof(Uint8Array),
515
+ ])
516
+
517
+ const limits = { ...DEFAULT_BLOB_LIMITS }
518
+
519
+ return {
520
+ type: "blob",
521
+ label,
522
+ displayLabel: formatLabel(label),
523
+ name,
524
+ component: "blob-upload",
525
+ renderHint: FILE_RENDER_HINT,
526
+ itemField,
527
+ defaultValue: "",
528
+ schema,
529
+ acceptedFormats: ["hex", "base64", "file"],
530
+ limits,
531
+ normalizeHex,
532
+ validateInput: (value: string | Uint8Array) =>
533
+ validateBlobInput(value, limits),
534
+ candidType: "blob",
535
+ }
536
+ }
537
+
538
+ const schema = z.array(itemField.schema)
539
+
540
+ // Helper to get a new item with default values
541
+ const getItemDefault = (): unknown => itemField.defaultValue
542
+
543
+ // Helper to create an item field for a specific index
544
+ const createItemField = (
545
+ index: number,
546
+ overrides?: { label?: string }
547
+ ): FieldNode => {
548
+ // Replace [0] in template with actual index
549
+ const itemName = name ? `${name}[${index}]` : `[${index}]`
550
+ const itemLabel = overrides?.label ?? `Item ${index}`
551
+
552
+ return {
553
+ ...itemField,
554
+ name: itemName,
555
+ label: itemLabel,
556
+ displayLabel: formatLabel(itemLabel),
557
+ }
558
+ }
559
+
560
+ return {
561
+ type: "vector",
562
+ label,
563
+ displayLabel: formatLabel(label),
564
+ name,
565
+ component: "vector-list",
566
+ renderHint: COMPOUND_RENDER_HINT,
567
+ itemField,
568
+ defaultValue: [],
569
+ schema,
570
+ getItemDefault,
571
+ createItemField,
572
+ candidType: "vec",
573
+ }
574
+ }
575
+
576
+ public visitRec<T>(
577
+ _t: IDL.RecClass<T>,
578
+ ty: IDL.ConstructType<T>,
579
+ label: string
580
+ ): RecursiveField {
581
+ const name = this.currentName()
582
+ const typeName = ty.name || "RecursiveType"
583
+
584
+ let schema: z.ZodTypeAny
585
+
586
+ if (this.recursiveSchemas.has(typeName)) {
587
+ schema = this.recursiveSchemas.get(typeName)!
588
+ } else {
589
+ schema = z.lazy(() => (ty.accept(this, label) as FieldNode).schema)
590
+ this.recursiveSchemas.set(typeName, schema)
591
+ }
592
+
593
+ // Lazy extraction to prevent infinite loops
594
+ const extract = (): FieldNode =>
595
+ this.withName(name, () => ty.accept(this, label)) as FieldNode
596
+
597
+ // Helper to get inner default (evaluates lazily)
598
+ const getInnerDefault = (): unknown => extract().defaultValue
599
+
600
+ return {
601
+ type: "recursive",
602
+ label,
603
+ displayLabel: formatLabel(label),
604
+ name,
605
+ component: "recursive-lazy",
606
+ renderHint: COMPOUND_RENDER_HINT,
607
+ typeName,
608
+ extract,
609
+ defaultValue: undefined,
610
+ schema,
611
+ getInnerDefault,
612
+ candidType: "rec",
613
+ }
614
+ }
615
+
616
+ // ════════════════════════════════════════════════════════════════════════
617
+ // Primitive Types
618
+ // ════════════════════════════════════════════════════════════════════════
619
+
620
+ public visitPrincipal(_t: IDL.PrincipalClass, label: string): PrincipalField {
621
+ const schema = z.custom<Principal>(
622
+ (val) => {
623
+ if (val instanceof Principal) return true
624
+ if (typeof val === "string") {
625
+ try {
626
+ Principal.fromText(val)
627
+ return true
628
+ } catch {
629
+ return false
630
+ }
631
+ }
632
+ return false
633
+ },
634
+ {
635
+ message: "Invalid Principal format",
636
+ }
637
+ )
638
+
639
+ const inputProps: PrimitiveInputProps = {
640
+ type: "text",
641
+ placeholder: "aaaaa-aa or full principal ID",
642
+ minLength: 7,
643
+ maxLength: 64,
644
+ spellCheck: false,
645
+ autoComplete: "off",
646
+ }
647
+
648
+ return {
649
+ type: "principal",
650
+ label,
651
+ displayLabel: formatLabel(label),
652
+ name: this.currentName(),
653
+ component: "principal-input",
654
+ renderHint: TEXT_RENDER_HINT,
655
+ defaultValue: "",
656
+ maxLength: 64,
657
+ minLength: 7,
658
+ format: checkTextFormat(label) as TextFormat,
659
+ schema,
660
+ inputProps,
661
+ candidType: "principal",
662
+ }
663
+ }
664
+
665
+ public visitText(_t: IDL.TextClass, label: string): TextField {
666
+ const format = checkTextFormat(label) as TextFormat
667
+
668
+ // Generate format-specific inputProps
669
+ const inputProps = this.getTextInputProps(format)
670
+
671
+ // Generate format-specific schema
672
+ const schema = this.getTextSchema(format)
673
+
674
+ return {
675
+ type: "text",
676
+ label,
677
+ displayLabel: formatLabel(label),
678
+ name: this.currentName(),
679
+ component: "text-input",
680
+ renderHint: TEXT_RENDER_HINT,
681
+ defaultValue: "",
682
+ format,
683
+ schema,
684
+ inputProps,
685
+ candidType: "text",
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Generate format-specific input props for text fields.
691
+ */
692
+ private getTextInputProps(format: TextFormat): PrimitiveInputProps {
693
+ switch (format) {
694
+ case "email":
695
+ return {
696
+ type: "email",
697
+ placeholder: "email@example.com",
698
+ inputMode: "email",
699
+ autoComplete: "email",
700
+ spellCheck: false,
701
+ }
702
+ case "url":
703
+ return {
704
+ type: "url",
705
+ placeholder: "https://example.com",
706
+ inputMode: "url",
707
+ autoComplete: "url",
708
+ spellCheck: false,
709
+ }
710
+ case "phone":
711
+ return {
712
+ type: "tel",
713
+ placeholder: "+1 (555) 123-4567",
714
+ inputMode: "tel",
715
+ autoComplete: "tel",
716
+ spellCheck: false,
717
+ }
718
+ case "uuid":
719
+ return {
720
+ type: "text",
721
+ placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
722
+ pattern:
723
+ "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
724
+ spellCheck: false,
725
+ autoComplete: "off",
726
+ }
727
+ case "btc":
728
+ return {
729
+ type: "text",
730
+ placeholder: "bc1... or 1... or 3...",
731
+ spellCheck: false,
732
+ autoComplete: "off",
733
+ }
734
+ case "eth":
735
+ return {
736
+ type: "text",
737
+ placeholder: "0x...",
738
+ pattern: "0x[0-9a-fA-F]{40}",
739
+ spellCheck: false,
740
+ autoComplete: "off",
741
+ }
742
+ case "account-id":
743
+ return {
744
+ type: "text",
745
+ placeholder: "64-character hex string",
746
+ pattern: "[0-9a-fA-F]{64}",
747
+ maxLength: 64,
748
+ spellCheck: false,
749
+ autoComplete: "off",
750
+ }
751
+ case "principal":
752
+ return {
753
+ type: "text",
754
+ placeholder: "aaaaa-aa or full principal ID",
755
+ minLength: 7,
756
+ maxLength: 64,
757
+ spellCheck: false,
758
+ autoComplete: "off",
759
+ }
760
+ default:
761
+ return {
762
+ type: "text",
763
+ placeholder: "Enter text...",
764
+ spellCheck: true,
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Generate format-specific zod schema for text fields.
771
+ */
772
+ private getTextSchema(format: TextFormat): z.ZodTypeAny {
773
+ switch (format) {
774
+ case "email":
775
+ return z.email("Invalid email address")
776
+ case "url":
777
+ return z.url("Invalid URL")
778
+ case "uuid":
779
+ return z.uuid("Invalid UUID")
780
+ default:
781
+ return z.string().min(1, "Required")
782
+ }
783
+ }
784
+
785
+ public visitBool(_t: IDL.BoolClass, label: string): BooleanField {
786
+ const inputProps: PrimitiveInputProps = {
787
+ type: "checkbox",
788
+ }
789
+
790
+ return {
791
+ type: "boolean",
792
+ label,
793
+ displayLabel: formatLabel(label),
794
+ name: this.currentName(),
795
+ component: "boolean-checkbox",
796
+ renderHint: CHECKBOX_RENDER_HINT,
797
+ defaultValue: false,
798
+ schema: z.boolean(),
799
+ inputProps,
800
+ candidType: "bool",
801
+ }
802
+ }
803
+
804
+ public visitNull(_t: IDL.NullClass, label: string): NullField {
805
+ return {
806
+ type: "null",
807
+ label,
808
+ displayLabel: formatLabel(label),
809
+ name: this.currentName(),
810
+ component: "null-hidden",
811
+ renderHint: {
812
+ isCompound: false,
813
+ isPrimitive: true,
814
+ },
815
+ defaultValue: null,
816
+ schema: z.null(),
817
+ candidType: "null",
818
+ }
819
+ }
820
+
821
+ // ════════════════════════════════════════════════════════════════════════
822
+ // Number Types with Constraints
823
+ // ════════════════════════════════════════════════════════════════════════
824
+
825
+ private visitNumberType(
826
+ label: string,
827
+ candidType: string,
828
+ options: {
829
+ unsigned: boolean
830
+ isFloat: boolean
831
+ bits?: number
832
+ min?: string
833
+ max?: string
834
+ }
835
+ ): NumberField | TextField {
836
+ const format = checkNumberFormat(label) as NumberFormat
837
+
838
+ let schema = z.string().min(1, "Required")
839
+
840
+ if (options.isFloat) {
841
+ schema = schema.refine((val) => !isNaN(Number(val)), "Must be a number")
842
+ } else if (options.unsigned) {
843
+ schema = schema.regex(/^\d+$/, "Must be a positive number")
844
+ } else {
845
+ schema = schema.regex(/^-?\d+$/, "Must be a number")
846
+ }
847
+
848
+ // Use "text" type for large numbers (BigInt) to ensure precision and better UI handling
849
+ // Standard number input has issues with large integers
850
+ const isBigInt = !options.isFloat && (!options.bits || options.bits > 32)
851
+ const type = isBigInt ? "text" : "number"
852
+
853
+ if (type === "text") {
854
+ // Propagate timestamp/cycle format if detected, otherwise default to plain
855
+ let textFormat: TextFormat = "plain"
856
+ if (format === "timestamp") textFormat = "timestamp"
857
+ if (format === "cycle") textFormat = "cycle"
858
+
859
+ const inputProps: PrimitiveInputProps = {
860
+ type: "text",
861
+ placeholder: options.unsigned ? "e.g. 100000" : "e.g. -100000",
862
+ inputMode: "numeric",
863
+ pattern: options.unsigned ? "\\d+" : "-?\\d+",
864
+ spellCheck: false,
865
+ autoComplete: "off",
866
+ }
867
+
868
+ return {
869
+ type: "text",
870
+ label,
871
+ displayLabel: formatLabel(label),
872
+ name: this.currentName(),
873
+ component: "text-input",
874
+ renderHint: TEXT_RENDER_HINT,
875
+ defaultValue: "",
876
+ format: textFormat,
877
+ candidType,
878
+ schema,
879
+ inputProps,
880
+ }
881
+ }
882
+
883
+ const inputProps: PrimitiveInputProps = {
884
+ type: "number",
885
+ placeholder: options.isFloat ? "0.0" : "0",
886
+ inputMode: options.isFloat ? "decimal" : "numeric",
887
+ min: options.min,
888
+ max: options.max,
889
+ step: options.isFloat ? "any" : "1",
890
+ }
891
+
892
+ return {
893
+ type: "number",
894
+ label,
895
+ displayLabel: formatLabel(label),
896
+ name: this.currentName(),
897
+ component: "number-input",
898
+ renderHint: NUMBER_RENDER_HINT,
899
+ defaultValue: "",
900
+ candidType,
901
+ format,
902
+ schema,
903
+ inputProps,
904
+ ...options,
905
+ }
906
+ }
907
+
908
+ public visitInt(_t: IDL.IntClass, label: string): NumberField | TextField {
909
+ return this.visitNumberType(label, "int", {
910
+ unsigned: false,
911
+ isFloat: false,
912
+ })
913
+ }
914
+
915
+ public visitNat(_t: IDL.NatClass, label: string): NumberField | TextField {
916
+ return this.visitNumberType(label, "nat", {
917
+ unsigned: true,
918
+ isFloat: false,
919
+ })
920
+ }
921
+
922
+ public visitFloat(t: IDL.FloatClass, label: string): NumberField {
923
+ return this.visitNumberType(label, `float${t._bits}`, {
924
+ unsigned: false,
925
+ isFloat: true,
926
+ bits: t._bits,
927
+ }) as NumberField
928
+ }
929
+
930
+ public visitFixedInt(
931
+ t: IDL.FixedIntClass,
932
+ label: string
933
+ ): NumberField | TextField {
934
+ const bits = t._bits
935
+ // Calculate min/max for signed integers
936
+ const max = (BigInt(2) ** BigInt(bits - 1) - BigInt(1)).toString()
937
+ const min = (-(BigInt(2) ** BigInt(bits - 1))).toString()
938
+
939
+ return this.visitNumberType(label, `int${bits}`, {
940
+ unsigned: false,
941
+ isFloat: false,
942
+ bits,
943
+ min,
944
+ max,
945
+ })
946
+ }
947
+
948
+ public visitFixedNat(
949
+ t: IDL.FixedNatClass,
950
+ label: string
951
+ ): NumberField | TextField {
952
+ const bits = t._bits
953
+ // Calculate max for unsigned integers
954
+ const max = (BigInt(2) ** BigInt(bits) - BigInt(1)).toString()
955
+
956
+ return this.visitNumberType(label, `nat${bits}`, {
957
+ unsigned: true,
958
+ isFloat: false,
959
+ bits,
960
+ min: "0",
961
+ max,
962
+ })
963
+ }
964
+
965
+ public visitType<T>(_t: IDL.Type<T>, label: string): UnknownField {
966
+ return {
967
+ type: "unknown",
968
+ label,
969
+ displayLabel: formatLabel(label),
970
+ name: this.currentName(),
971
+ component: "unknown-fallback",
972
+ renderHint: {
973
+ isCompound: false,
974
+ isPrimitive: false,
975
+ },
976
+ defaultValue: undefined,
977
+ candidType: "unknown",
978
+ schema: z.any(),
979
+ }
980
+ }
981
+ }