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