@ic-reactor/candid 3.0.3-beta.4 → 3.0.3

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 (82) 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 -15
  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.map +1 -1
  17. package/dist/reactor.js +9 -5
  18. package/dist/reactor.js.map +1 -1
  19. package/dist/types.d.ts +38 -5
  20. package/dist/types.d.ts.map +1 -1
  21. package/dist/utils.d.ts +4 -4
  22. package/dist/utils.d.ts.map +1 -1
  23. package/dist/utils.js +33 -10
  24. package/dist/utils.js.map +1 -1
  25. package/dist/visitor/arguments/helpers.d.ts +55 -0
  26. package/dist/visitor/arguments/helpers.d.ts.map +1 -0
  27. package/dist/visitor/arguments/helpers.js +123 -0
  28. package/dist/visitor/arguments/helpers.js.map +1 -0
  29. package/dist/visitor/arguments/index.d.ts +101 -0
  30. package/dist/visitor/arguments/index.d.ts.map +1 -0
  31. package/dist/visitor/arguments/index.js +779 -0
  32. package/dist/visitor/arguments/index.js.map +1 -0
  33. package/dist/visitor/arguments/types.d.ts +268 -0
  34. package/dist/visitor/arguments/types.d.ts.map +1 -0
  35. package/dist/visitor/arguments/types.js +26 -0
  36. package/dist/visitor/arguments/types.js.map +1 -0
  37. package/dist/visitor/constants.d.ts +4 -0
  38. package/dist/visitor/constants.d.ts.map +1 -0
  39. package/dist/visitor/constants.js +73 -0
  40. package/dist/visitor/constants.js.map +1 -0
  41. package/dist/visitor/helpers.d.ts +30 -0
  42. package/dist/visitor/helpers.d.ts.map +1 -0
  43. package/dist/visitor/helpers.js +204 -0
  44. package/dist/visitor/helpers.js.map +1 -0
  45. package/dist/visitor/index.d.ts +5 -0
  46. package/dist/visitor/index.d.ts.map +1 -0
  47. package/dist/visitor/index.js +5 -0
  48. package/dist/visitor/index.js.map +1 -0
  49. package/dist/visitor/returns/index.d.ts +38 -0
  50. package/dist/visitor/returns/index.d.ts.map +1 -0
  51. package/dist/visitor/returns/index.js +460 -0
  52. package/dist/visitor/returns/index.js.map +1 -0
  53. package/dist/visitor/returns/types.d.ts +202 -0
  54. package/dist/visitor/returns/types.d.ts.map +1 -0
  55. package/dist/visitor/returns/types.js +2 -0
  56. package/dist/visitor/returns/types.js.map +1 -0
  57. package/dist/visitor/types.d.ts +19 -0
  58. package/dist/visitor/types.d.ts.map +1 -0
  59. package/dist/visitor/types.js +2 -0
  60. package/dist/visitor/types.js.map +1 -0
  61. package/package.json +16 -7
  62. package/src/adapter.ts +446 -0
  63. package/src/constants.ts +11 -0
  64. package/src/display-reactor.ts +337 -0
  65. package/src/index.ts +8 -0
  66. package/src/metadata-display-reactor.ts +230 -0
  67. package/src/reactor.ts +199 -0
  68. package/src/types.ts +127 -0
  69. package/src/utils.ts +60 -0
  70. package/src/visitor/arguments/helpers.ts +153 -0
  71. package/src/visitor/arguments/index.test.ts +1439 -0
  72. package/src/visitor/arguments/index.ts +980 -0
  73. package/src/visitor/arguments/schema.test.ts +324 -0
  74. package/src/visitor/arguments/types.ts +385 -0
  75. package/src/visitor/constants.ts +76 -0
  76. package/src/visitor/helpers.test.ts +274 -0
  77. package/src/visitor/helpers.ts +223 -0
  78. package/src/visitor/index.ts +4 -0
  79. package/src/visitor/returns/index.test.ts +2377 -0
  80. package/src/visitor/returns/index.ts +658 -0
  81. package/src/visitor/returns/types.ts +302 -0
  82. package/src/visitor/types.ts +75 -0
@@ -0,0 +1,980 @@
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
+ functionType,
262
+ functionName,
263
+ args,
264
+ defaults,
265
+ schema,
266
+ argCount,
267
+ isEmpty: argCount === 0,
268
+ }
269
+ }
270
+
271
+ // ════════════════════════════════════════════════════════════════════════
272
+ // Compound Types
273
+ // ════════════════════════════════════════════════════════════════════════
274
+
275
+ public visitRecord(
276
+ _t: IDL.RecordClass,
277
+ fields_: Array<[string, IDL.Type]>,
278
+ label: string
279
+ ): RecordField {
280
+ const name = this.currentName()
281
+ const fields: FieldNode[] = []
282
+ const defaultValue: Record<string, unknown> = {}
283
+ const schemaShape: Record<string, z.ZodTypeAny> = {}
284
+
285
+ for (const [key, type] of fields_) {
286
+ const field = this.withName(name ? `.${key}` : key, () =>
287
+ type.accept(this, key)
288
+ ) as FieldNode
289
+
290
+ fields.push(field)
291
+ defaultValue[key] = field.defaultValue
292
+ schemaShape[key] = field.schema
293
+ }
294
+
295
+ const schema = z.object(schemaShape)
296
+
297
+ return {
298
+ type: "record",
299
+ label,
300
+ displayLabel: formatLabel(label),
301
+ name,
302
+ component: "record-container",
303
+ renderHint: COMPOUND_RENDER_HINT,
304
+ fields,
305
+ defaultValue,
306
+ schema,
307
+ candidType: "record",
308
+ }
309
+ }
310
+
311
+ public visitVariant(
312
+ _t: IDL.VariantClass,
313
+ fields_: Array<[string, IDL.Type]>,
314
+ label: string
315
+ ): VariantField {
316
+ const name = this.currentName()
317
+ const options: FieldNode[] = []
318
+ const variantSchemas: z.ZodTypeAny[] = []
319
+
320
+ for (const [key, type] of fields_) {
321
+ const field = this.withName(`.${key}`, () =>
322
+ type.accept(this, key)
323
+ ) as FieldNode
324
+
325
+ options.push(field)
326
+
327
+ if (field.type === "null") {
328
+ variantSchemas.push(z.object({ _type: z.literal(key) }))
329
+ } else {
330
+ variantSchemas.push(
331
+ z.object({
332
+ _type: z.literal(key),
333
+ [key]: field.schema,
334
+ })
335
+ )
336
+ }
337
+ }
338
+
339
+ const firstOption = options[0]
340
+ const defaultOption = firstOption.label
341
+
342
+ const defaultValue =
343
+ firstOption.type === "null"
344
+ ? { _type: defaultOption }
345
+ : {
346
+ _type: defaultOption,
347
+ [defaultOption]: firstOption.defaultValue,
348
+ }
349
+
350
+ const schema = z.union(variantSchemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
351
+
352
+ // Helper to get default value for any option
353
+ const getOptionDefault = (option: string): Record<string, unknown> => {
354
+ const optField = options.find((f) => f.label === option)
355
+ if (!optField) {
356
+ throw new MetadataError(
357
+ `Unknown variant option: "${option}". Available: ${options.map((o) => o.label).join(", ")}`,
358
+ name,
359
+ "variant"
360
+ )
361
+ }
362
+ return optField.type === "null"
363
+ ? { _type: option }
364
+ : { _type: option, [option]: optField.defaultValue }
365
+ }
366
+
367
+ // Helper to get field for a specific option
368
+ const getOption = (option: string): FieldNode => {
369
+ const optField = options.find((f) => f.label === option)
370
+ if (!optField) {
371
+ throw new MetadataError(
372
+ `Unknown variant option: "${option}". Available: ${options.map((o) => o.label).join(", ")}`,
373
+ name,
374
+ "variant"
375
+ )
376
+ }
377
+ return optField
378
+ }
379
+
380
+ // Helper to get currently selected option key from a value
381
+ const getSelectedKey = (value: Record<string, unknown>): string => {
382
+ if (value._type && typeof value._type === "string") {
383
+ return value._type
384
+ }
385
+ const validKeys = Object.keys(value).filter((k) =>
386
+ options.some((f) => f.label === k)
387
+ )
388
+ return validKeys[0] ?? defaultOption
389
+ }
390
+
391
+ // Helper to get the field for the currently selected option
392
+ const getSelectedOption = (value: Record<string, unknown>): FieldNode => {
393
+ const selectedKey = getSelectedKey(value)
394
+ return getOption(selectedKey)
395
+ }
396
+
397
+ return {
398
+ type: "variant",
399
+ label,
400
+ displayLabel: formatLabel(label),
401
+ name,
402
+ component: "variant-select",
403
+ renderHint: COMPOUND_RENDER_HINT,
404
+ options,
405
+ defaultOption,
406
+ defaultValue,
407
+ schema,
408
+ getOptionDefault,
409
+ getOption,
410
+ getSelectedKey,
411
+ getSelectedOption,
412
+ candidType: "variant",
413
+ }
414
+ }
415
+
416
+ public visitTuple<T extends IDL.Type[]>(
417
+ _t: IDL.TupleClass<T>,
418
+ components: IDL.Type[],
419
+ label: string
420
+ ): TupleField {
421
+ const name = this.currentName()
422
+ const fields: FieldNode[] = []
423
+ const defaultValue: unknown[] = []
424
+ const schemas: z.ZodTypeAny[] = []
425
+
426
+ for (let index = 0; index < components.length; index++) {
427
+ const type = components[index]
428
+ const field = this.withName(`[${index}]`, () =>
429
+ type.accept(this, `_${index}_`)
430
+ ) as FieldNode
431
+
432
+ fields.push(field)
433
+ defaultValue.push(field.defaultValue)
434
+ schemas.push(field.schema)
435
+ }
436
+
437
+ const schema = z.tuple(schemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
438
+
439
+ return {
440
+ type: "tuple",
441
+ label,
442
+ displayLabel: formatLabel(label),
443
+ name,
444
+ component: "tuple-container",
445
+ renderHint: COMPOUND_RENDER_HINT,
446
+ fields,
447
+ defaultValue,
448
+ schema,
449
+ candidType: "tuple",
450
+ }
451
+ }
452
+
453
+ public visitOpt<T>(
454
+ _t: IDL.OptClass<T>,
455
+ ty: IDL.Type<T>,
456
+ label: string
457
+ ): OptionalField {
458
+ const name = this.currentName()
459
+
460
+ // For optional, the inner field keeps the same name path
461
+ // because the value replaces null directly (not nested)
462
+ const innerField = ty.accept(this, label) as FieldNode
463
+
464
+ const schema = z.union([
465
+ innerField.schema,
466
+ z.null(),
467
+ z.undefined().transform(() => null),
468
+ ])
469
+
470
+ // Helper to get the inner default when enabling the optional
471
+ const getInnerDefault = (): unknown => innerField.defaultValue
472
+
473
+ // Helper to check if a value represents an enabled optional
474
+ const isEnabled = (value: unknown): boolean => {
475
+ return value !== null && typeof value !== "undefined"
476
+ }
477
+
478
+ return {
479
+ type: "optional",
480
+ label,
481
+ displayLabel: formatLabel(label),
482
+ name,
483
+ component: "optional-toggle",
484
+ renderHint: COMPOUND_RENDER_HINT,
485
+ innerField,
486
+ defaultValue: null,
487
+ schema,
488
+ getInnerDefault,
489
+ isEnabled,
490
+ candidType: "opt",
491
+ }
492
+ }
493
+
494
+ public visitVec<T>(
495
+ _t: IDL.VecClass<T>,
496
+ ty: IDL.Type<T>,
497
+ label: string
498
+ ): VectorField | BlobField {
499
+ const name = this.currentName()
500
+
501
+ // Check if it's blob (vec nat8)
502
+ const isBlob = ty instanceof IDL.FixedNatClass && ty._bits === 8
503
+
504
+ // Item field uses [0] as template path
505
+ const itemField = this.withName("[0]", () =>
506
+ ty.accept(this, `${label}_item`)
507
+ ) as FieldNode
508
+
509
+ if (isBlob) {
510
+ const schema = z.union([
511
+ z.string(),
512
+ z.array(z.number()),
513
+ z.instanceof(Uint8Array),
514
+ ])
515
+
516
+ const limits = { ...DEFAULT_BLOB_LIMITS }
517
+
518
+ return {
519
+ type: "blob",
520
+ label,
521
+ displayLabel: formatLabel(label),
522
+ name,
523
+ component: "blob-upload",
524
+ renderHint: FILE_RENDER_HINT,
525
+ itemField,
526
+ defaultValue: "",
527
+ schema,
528
+ acceptedFormats: ["hex", "base64", "file"],
529
+ limits,
530
+ normalizeHex,
531
+ validateInput: (value: string | Uint8Array) =>
532
+ validateBlobInput(value, limits),
533
+ candidType: "blob",
534
+ }
535
+ }
536
+
537
+ const schema = z.array(itemField.schema)
538
+
539
+ // Helper to get a new item with default values
540
+ const getItemDefault = (): unknown => itemField.defaultValue
541
+
542
+ // Helper to create an item field for a specific index
543
+ const createItemField = (
544
+ index: number,
545
+ overrides?: { label?: string }
546
+ ): FieldNode => {
547
+ // Replace [0] in template with actual index
548
+ const itemName = name ? `${name}[${index}]` : `[${index}]`
549
+ const itemLabel = overrides?.label ?? `Item ${index}`
550
+
551
+ return {
552
+ ...itemField,
553
+ name: itemName,
554
+ label: itemLabel,
555
+ displayLabel: formatLabel(itemLabel),
556
+ }
557
+ }
558
+
559
+ return {
560
+ type: "vector",
561
+ label,
562
+ displayLabel: formatLabel(label),
563
+ name,
564
+ component: "vector-list",
565
+ renderHint: COMPOUND_RENDER_HINT,
566
+ itemField,
567
+ defaultValue: [],
568
+ schema,
569
+ getItemDefault,
570
+ createItemField,
571
+ candidType: "vec",
572
+ }
573
+ }
574
+
575
+ public visitRec<T>(
576
+ _t: IDL.RecClass<T>,
577
+ ty: IDL.ConstructType<T>,
578
+ label: string
579
+ ): RecursiveField {
580
+ const name = this.currentName()
581
+ const typeName = ty.name || "RecursiveType"
582
+
583
+ let schema: z.ZodTypeAny
584
+
585
+ if (this.recursiveSchemas.has(typeName)) {
586
+ schema = this.recursiveSchemas.get(typeName)!
587
+ } else {
588
+ schema = z.lazy(() => (ty.accept(this, label) as FieldNode).schema)
589
+ this.recursiveSchemas.set(typeName, schema)
590
+ }
591
+
592
+ // Lazy extraction to prevent infinite loops
593
+ const extract = (): FieldNode =>
594
+ this.withName(name, () => ty.accept(this, label)) as FieldNode
595
+
596
+ // Helper to get inner default (evaluates lazily)
597
+ const getInnerDefault = (): unknown => extract().defaultValue
598
+
599
+ return {
600
+ type: "recursive",
601
+ label,
602
+ displayLabel: formatLabel(label),
603
+ name,
604
+ component: "recursive-lazy",
605
+ renderHint: COMPOUND_RENDER_HINT,
606
+ typeName,
607
+ extract,
608
+ defaultValue: undefined,
609
+ schema,
610
+ getInnerDefault,
611
+ candidType: "rec",
612
+ }
613
+ }
614
+
615
+ // ════════════════════════════════════════════════════════════════════════
616
+ // Primitive Types
617
+ // ════════════════════════════════════════════════════════════════════════
618
+
619
+ public visitPrincipal(_t: IDL.PrincipalClass, label: string): PrincipalField {
620
+ const schema = z.custom<Principal>(
621
+ (val) => {
622
+ if (val instanceof Principal) return true
623
+ if (typeof val === "string") {
624
+ try {
625
+ Principal.fromText(val)
626
+ return true
627
+ } catch {
628
+ return false
629
+ }
630
+ }
631
+ return false
632
+ },
633
+ {
634
+ message: "Invalid Principal format",
635
+ }
636
+ )
637
+
638
+ const inputProps: PrimitiveInputProps = {
639
+ type: "text",
640
+ placeholder: "aaaaa-aa or full principal ID",
641
+ minLength: 7,
642
+ maxLength: 64,
643
+ spellCheck: false,
644
+ autoComplete: "off",
645
+ }
646
+
647
+ return {
648
+ type: "principal",
649
+ label,
650
+ displayLabel: formatLabel(label),
651
+ name: this.currentName(),
652
+ component: "principal-input",
653
+ renderHint: TEXT_RENDER_HINT,
654
+ defaultValue: "",
655
+ maxLength: 64,
656
+ minLength: 7,
657
+ format: checkTextFormat(label) as TextFormat,
658
+ schema,
659
+ inputProps,
660
+ candidType: "principal",
661
+ }
662
+ }
663
+
664
+ public visitText(_t: IDL.TextClass, label: string): TextField {
665
+ const format = checkTextFormat(label) as TextFormat
666
+
667
+ // Generate format-specific inputProps
668
+ const inputProps = this.getTextInputProps(format)
669
+
670
+ // Generate format-specific schema
671
+ const schema = this.getTextSchema(format)
672
+
673
+ return {
674
+ type: "text",
675
+ label,
676
+ displayLabel: formatLabel(label),
677
+ name: this.currentName(),
678
+ component: "text-input",
679
+ renderHint: TEXT_RENDER_HINT,
680
+ defaultValue: "",
681
+ format,
682
+ schema,
683
+ inputProps,
684
+ candidType: "text",
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Generate format-specific input props for text fields.
690
+ */
691
+ private getTextInputProps(format: TextFormat): PrimitiveInputProps {
692
+ switch (format) {
693
+ case "email":
694
+ return {
695
+ type: "email",
696
+ placeholder: "email@example.com",
697
+ inputMode: "email",
698
+ autoComplete: "email",
699
+ spellCheck: false,
700
+ }
701
+ case "url":
702
+ return {
703
+ type: "url",
704
+ placeholder: "https://example.com",
705
+ inputMode: "url",
706
+ autoComplete: "url",
707
+ spellCheck: false,
708
+ }
709
+ case "phone":
710
+ return {
711
+ type: "tel",
712
+ placeholder: "+1 (555) 123-4567",
713
+ inputMode: "tel",
714
+ autoComplete: "tel",
715
+ spellCheck: false,
716
+ }
717
+ case "uuid":
718
+ return {
719
+ type: "text",
720
+ placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
721
+ pattern:
722
+ "[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}",
723
+ spellCheck: false,
724
+ autoComplete: "off",
725
+ }
726
+ case "btc":
727
+ return {
728
+ type: "text",
729
+ placeholder: "bc1... or 1... or 3...",
730
+ spellCheck: false,
731
+ autoComplete: "off",
732
+ }
733
+ case "eth":
734
+ return {
735
+ type: "text",
736
+ placeholder: "0x...",
737
+ pattern: "0x[0-9a-fA-F]{40}",
738
+ spellCheck: false,
739
+ autoComplete: "off",
740
+ }
741
+ case "account-id":
742
+ return {
743
+ type: "text",
744
+ placeholder: "64-character hex string",
745
+ pattern: "[0-9a-fA-F]{64}",
746
+ maxLength: 64,
747
+ spellCheck: false,
748
+ autoComplete: "off",
749
+ }
750
+ case "principal":
751
+ return {
752
+ type: "text",
753
+ placeholder: "aaaaa-aa or full principal ID",
754
+ minLength: 7,
755
+ maxLength: 64,
756
+ spellCheck: false,
757
+ autoComplete: "off",
758
+ }
759
+ default:
760
+ return {
761
+ type: "text",
762
+ placeholder: "Enter text...",
763
+ spellCheck: true,
764
+ }
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Generate format-specific zod schema for text fields.
770
+ */
771
+ private getTextSchema(format: TextFormat): z.ZodTypeAny {
772
+ switch (format) {
773
+ case "email":
774
+ return z.email("Invalid email address")
775
+ case "url":
776
+ return z.url("Invalid URL")
777
+ case "uuid":
778
+ return z.uuid("Invalid UUID")
779
+ default:
780
+ return z.string().min(1, "Required")
781
+ }
782
+ }
783
+
784
+ public visitBool(_t: IDL.BoolClass, label: string): BooleanField {
785
+ const inputProps: PrimitiveInputProps = {
786
+ type: "checkbox",
787
+ }
788
+
789
+ return {
790
+ type: "boolean",
791
+ label,
792
+ displayLabel: formatLabel(label),
793
+ name: this.currentName(),
794
+ component: "boolean-checkbox",
795
+ renderHint: CHECKBOX_RENDER_HINT,
796
+ defaultValue: false,
797
+ schema: z.boolean(),
798
+ inputProps,
799
+ candidType: "bool",
800
+ }
801
+ }
802
+
803
+ public visitNull(_t: IDL.NullClass, label: string): NullField {
804
+ return {
805
+ type: "null",
806
+ label,
807
+ displayLabel: formatLabel(label),
808
+ name: this.currentName(),
809
+ component: "null-hidden",
810
+ renderHint: {
811
+ isCompound: false,
812
+ isPrimitive: true,
813
+ },
814
+ defaultValue: null,
815
+ schema: z.null(),
816
+ candidType: "null",
817
+ }
818
+ }
819
+
820
+ // ════════════════════════════════════════════════════════════════════════
821
+ // Number Types with Constraints
822
+ // ════════════════════════════════════════════════════════════════════════
823
+
824
+ private visitNumberType(
825
+ label: string,
826
+ candidType: string,
827
+ options: {
828
+ unsigned: boolean
829
+ isFloat: boolean
830
+ bits?: number
831
+ min?: string
832
+ max?: string
833
+ }
834
+ ): NumberField | TextField {
835
+ const format = checkNumberFormat(label) as NumberFormat
836
+
837
+ let schema = z.string().min(1, "Required")
838
+
839
+ if (options.isFloat) {
840
+ schema = schema.refine((val) => !isNaN(Number(val)), "Must be a number")
841
+ } else if (options.unsigned) {
842
+ schema = schema.regex(/^\d+$/, "Must be a positive number")
843
+ } else {
844
+ schema = schema.regex(/^-?\d+$/, "Must be a number")
845
+ }
846
+
847
+ // Use "text" type for large numbers (BigInt) to ensure precision and better UI handling
848
+ // Standard number input has issues with large integers
849
+ const isBigInt = !options.isFloat && (!options.bits || options.bits > 32)
850
+ const type = isBigInt ? "text" : "number"
851
+
852
+ if (type === "text") {
853
+ // Propagate timestamp/cycle format if detected, otherwise default to plain
854
+ let textFormat: TextFormat = "plain"
855
+ if (format === "timestamp") textFormat = "timestamp"
856
+ if (format === "cycle") textFormat = "cycle"
857
+
858
+ const inputProps: PrimitiveInputProps = {
859
+ type: "text",
860
+ placeholder: options.unsigned ? "e.g. 100000" : "e.g. -100000",
861
+ inputMode: "numeric",
862
+ pattern: options.unsigned ? "\\d+" : "-?\\d+",
863
+ spellCheck: false,
864
+ autoComplete: "off",
865
+ }
866
+
867
+ return {
868
+ type: "text",
869
+ label,
870
+ displayLabel: formatLabel(label),
871
+ name: this.currentName(),
872
+ component: "text-input",
873
+ renderHint: TEXT_RENDER_HINT,
874
+ defaultValue: "",
875
+ format: textFormat,
876
+ candidType,
877
+ schema,
878
+ inputProps,
879
+ }
880
+ }
881
+
882
+ const inputProps: PrimitiveInputProps = {
883
+ type: "number",
884
+ placeholder: options.isFloat ? "0.0" : "0",
885
+ inputMode: options.isFloat ? "decimal" : "numeric",
886
+ min: options.min,
887
+ max: options.max,
888
+ step: options.isFloat ? "any" : "1",
889
+ }
890
+
891
+ return {
892
+ type: "number",
893
+ label,
894
+ displayLabel: formatLabel(label),
895
+ name: this.currentName(),
896
+ component: "number-input",
897
+ renderHint: NUMBER_RENDER_HINT,
898
+ defaultValue: "",
899
+ candidType,
900
+ format,
901
+ schema,
902
+ inputProps,
903
+ ...options,
904
+ }
905
+ }
906
+
907
+ public visitInt(_t: IDL.IntClass, label: string): NumberField | TextField {
908
+ return this.visitNumberType(label, "int", {
909
+ unsigned: false,
910
+ isFloat: false,
911
+ })
912
+ }
913
+
914
+ public visitNat(_t: IDL.NatClass, label: string): NumberField | TextField {
915
+ return this.visitNumberType(label, "nat", {
916
+ unsigned: true,
917
+ isFloat: false,
918
+ })
919
+ }
920
+
921
+ public visitFloat(t: IDL.FloatClass, label: string): NumberField {
922
+ return this.visitNumberType(label, `float${t._bits}`, {
923
+ unsigned: false,
924
+ isFloat: true,
925
+ bits: t._bits,
926
+ }) as NumberField
927
+ }
928
+
929
+ public visitFixedInt(
930
+ t: IDL.FixedIntClass,
931
+ label: string
932
+ ): NumberField | TextField {
933
+ const bits = t._bits
934
+ // Calculate min/max for signed integers
935
+ const max = (BigInt(2) ** BigInt(bits - 1) - BigInt(1)).toString()
936
+ const min = (-(BigInt(2) ** BigInt(bits - 1))).toString()
937
+
938
+ return this.visitNumberType(label, `int${bits}`, {
939
+ unsigned: false,
940
+ isFloat: false,
941
+ bits,
942
+ min,
943
+ max,
944
+ })
945
+ }
946
+
947
+ public visitFixedNat(
948
+ t: IDL.FixedNatClass,
949
+ label: string
950
+ ): NumberField | TextField {
951
+ const bits = t._bits
952
+ // Calculate max for unsigned integers
953
+ const max = (BigInt(2) ** BigInt(bits) - BigInt(1)).toString()
954
+
955
+ return this.visitNumberType(label, `nat${bits}`, {
956
+ unsigned: true,
957
+ isFloat: false,
958
+ bits,
959
+ min: "0",
960
+ max,
961
+ })
962
+ }
963
+
964
+ public visitType<T>(_t: IDL.Type<T>, label: string): UnknownField {
965
+ return {
966
+ type: "unknown",
967
+ label,
968
+ displayLabel: formatLabel(label),
969
+ name: this.currentName(),
970
+ component: "unknown-fallback",
971
+ renderHint: {
972
+ isCompound: false,
973
+ isPrimitive: false,
974
+ },
975
+ defaultValue: undefined,
976
+ candidType: "unknown",
977
+ schema: z.any(),
978
+ }
979
+ }
980
+ }