@whatsaaaa/form-builder-schema 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,28 @@
1
1
  import * as v from "valibot";
2
+ // src/shared/each-item.ts
3
+ /**
4
+ * Checks every item of an array with `schema` and reports the issues on the array itself.
5
+ * Formisch keeps no field for each item of an array value, so an issue with an item's index in
6
+ * its path would show nowhere.
7
+ */
8
+ function eachItem(schema) {
9
+ return v.rawCheck(({ dataset, config, addIssue }) => {
10
+ if (!dataset.typed || !Array.isArray(dataset.value)) return;
11
+ for (const item of dataset.value) {
12
+ const result = v.safeParse(schema, item, config);
13
+ for (const issue of result.issues ?? []) addIssue({
14
+ message: issue.message,
15
+ input: item,
16
+ expected: issue.expected ?? void 0,
17
+ received: issue.received
18
+ });
19
+ }
20
+ });
21
+ }
2
22
  // src/builder/build-field-schema.ts
3
23
  /**
4
24
  * The schema runs in this order: empty check, base schema, normalize rules, required check,
5
- * then the type's default rules and the field's rules.
25
+ * then the type's default rules and the field's rules, and last the type's `finalize()`.
6
26
  */
7
27
  function buildFieldSchema(checked, messages) {
8
28
  const { field, path, type, kind } = checked;
@@ -11,8 +31,14 @@ function buildFieldSchema(checked, messages) {
11
31
  field,
12
32
  path
13
33
  });
14
- const normalize = [];
15
- const validate = [];
34
+ const whole = {
35
+ normalize: [],
36
+ validate: []
37
+ };
38
+ const each = {
39
+ normalize: [],
40
+ validate: []
41
+ };
16
42
  for (const rule of checked.rules) {
17
43
  const message = rule.message === void 0 ? void 0 : messages.resolve(rule.message, {
18
44
  field,
@@ -20,25 +46,31 @@ function buildFieldSchema(checked, messages) {
20
46
  path
21
47
  });
22
48
  const action = rule.def.create(rule.params, message);
23
- if (rule.def.stage === "normalize") normalize.push(action);
24
- else validate.push(action);
49
+ const stage = rule.def.stage ?? "validate";
50
+ if (rule.each) each[stage].push(action);
51
+ else whole[stage].push(action);
25
52
  }
53
+ if (each.normalize.length) whole.normalize.unshift(v.array(pipe(v.unknown(), each.normalize)));
54
+ if (each.validate.length) whole.validate.unshift(eachItem(pipe(v.unknown(), each.validate)));
55
+ const finalize = type.finalize?.(field);
56
+ if (finalize) whole.validate.push(finalize);
26
57
  const base = type.schema(field, { path });
27
- const requiredCheck = required ? kind.required?.(requiredMessage) : void 0;
28
- const head = pipe(base, requiredCheck ? [...normalize, requiredCheck] : normalize);
58
+ const requiredCheck = !required ? void 0 : type.required ? type.required(requiredMessage) : kind.required?.(requiredMessage);
59
+ const head = pipe(base, requiredCheck ? [...whole.normalize, requiredCheck] : whole.normalize);
29
60
  const empty = required ? v.never(requiredMessage) : v.pipe(v.unknown(), v.transform(() => void 0));
30
61
  const whenFilled = (schema) => v.lazy((input) => kind.isEmpty(input) ? empty : schema);
31
- const schema = whenFilled(v.pipe(head, whenFilled(pipe(v.unknown(), validate))));
62
+ const schema = whenFilled(v.pipe(head, whenFilled(pipe(v.unknown(), whole.validate))));
32
63
  return required ? schema : v.optional(schema);
33
64
  }
34
- const pipe = (schema, actions) => actions.reduce((acc, action) => v.pipe(acc, action), schema);
35
- // src/field-types/default-field-types.ts
36
- const defaultFieldTypes = {
65
+ const pipe = (schema, items) => items.reduce((acc, item) => v.pipe(acc, item), schema);
66
+ // src/field-types/basic-types.ts
67
+ /** Types with one plain value and no props. Each maps to a native input. */
68
+ const basicTypes = {
37
69
  text: {
38
70
  kind: "string",
39
71
  schema: () => v.string()
40
72
  },
41
- password: {
73
+ textarea: {
42
74
  kind: "string",
43
75
  schema: () => v.string()
44
76
  },
@@ -47,10 +79,149 @@ const defaultFieldTypes = {
47
79
  schema: () => v.string(),
48
80
  defaultRules: [{ rule: "email" }]
49
81
  },
82
+ password: {
83
+ kind: "string",
84
+ schema: () => v.string()
85
+ },
86
+ url: {
87
+ kind: "string",
88
+ schema: () => v.string(),
89
+ defaultRules: [{ rule: "url" }]
90
+ },
91
+ tel: {
92
+ kind: "string",
93
+ schema: () => v.string()
94
+ },
50
95
  number: {
51
96
  kind: "number",
52
97
  schema: () => v.number()
98
+ },
99
+ checkbox: {
100
+ kind: "boolean",
101
+ schema: () => v.boolean()
102
+ }
103
+ };
104
+ // src/field-types/define-field-type.ts
105
+ const defineFieldType = (def) => def;
106
+ // src/field-types/choice-types.ts
107
+ const options = (value) => v.pipe(v.array(v.object({
108
+ value,
109
+ label: v.string()
110
+ })), v.minLength(1, "Give at least one option"), v.check((items) => new Set(items.map((item) => typeof item.value)).size <= 1, "Give every option a value of the same type"), v.check((items) => new Set(items.map((item) => item.value)).size === items.length, "Give every option a different value"));
111
+ const stringOrNumber = v.union([v.string(), v.number()]);
112
+ const anyValue = v.union([
113
+ v.string(),
114
+ v.number(),
115
+ v.boolean()
116
+ ]);
117
+ /** The options' value type. Every option has the same one. */
118
+ const kindOf = (items) => {
119
+ const [first] = items;
120
+ return first ? typeof first.value : "string";
121
+ };
122
+ /** One of the option values. `v.picklist` can't hold booleans, so this is a union of literals. */
123
+ const oneOf = (items) => v.union(items.map((item) => v.literal(item.value)));
124
+ const choiceTypes = {
125
+ select: defineFieldType({
126
+ kind: (field) => field.options ? kindOf(field.options) : field.valueType ?? "string",
127
+ props: v.object({
128
+ options: v.optional(options(stringOrNumber)),
129
+ /** The value's type while `options` is missing, e.g. until they load from an API. */
130
+ valueType: v.optional(v.picklist(["string", "number"]))
131
+ }),
132
+ schema: (field) => {
133
+ if (field.options) return oneOf(field.options);
134
+ return field.valueType === "number" ? v.number() : v.string();
135
+ }
136
+ }),
137
+ radio: defineFieldType({
138
+ kind: (field) => kindOf(field.options),
139
+ props: v.object({ options: options(anyValue) }),
140
+ schema: (field) => oneOf(field.options),
141
+ required: () => null
142
+ }),
143
+ multiselect: defineFieldType({
144
+ kind: "array",
145
+ itemKind: (field) => kindOf(field.options),
146
+ props: v.object({ options: options(stringOrNumber) }),
147
+ schema: (field) => v.array(oneOf(field.options))
148
+ })
149
+ };
150
+ // src/field-types/date-types.ts
151
+ const outputProps = v.object({
152
+ /** `'date'` turns the ISO string into a `Date`. Defaults to `'string'`. */
153
+ output: v.optional(v.picklist(["string", "date"])) });
154
+ const finalize = (field) => field.output === "date" ? v.toDate() : void 0;
155
+ /**
156
+ * Dates and times are ISO strings, the values of <input type="date|time|datetime-local">.
157
+ * The format is part of the type rather than a default rule, so these types don't depend on
158
+ * `dateRules` being registered.
159
+ */
160
+ const dateTypes = {
161
+ date: defineFieldType({
162
+ kind: "date",
163
+ props: outputProps,
164
+ schema: () => v.pipe(v.string(), v.isoDate()),
165
+ finalize
166
+ }),
167
+ time: {
168
+ kind: "date",
169
+ schema: () => v.pipe(v.string(), v.isoTime())
170
+ },
171
+ datetime: defineFieldType({
172
+ kind: "date",
173
+ props: outputProps,
174
+ schema: () => v.pipe(v.string(), v.isoDateTime()),
175
+ finalize
176
+ })
177
+ };
178
+ // src/field-types/display-types.ts
179
+ /**
180
+ * Elements without a value. They need no name and add no output key. Their `text` can be an
181
+ * i18n key: renderers resolve it with the same function they pass as `resolveMessage`.
182
+ */
183
+ const displayTypes = {
184
+ heading: defineFieldType({
185
+ kind: "none",
186
+ props: v.object({
187
+ text: v.string(),
188
+ /** 1 to 6. Defaults to 2. */
189
+ level: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(6)))
190
+ })
191
+ }),
192
+ paragraph: defineFieldType({
193
+ kind: "none",
194
+ props: v.object({ text: v.string() })
195
+ }),
196
+ divider: { kind: "none" }
197
+ };
198
+ // src/field-types/file-type.ts
199
+ const mimeTypes = (accept) => accept.split(",").map((type) => type.trim());
200
+ /**
201
+ * A File, or an array of them with `multiple`. Rules for files, like `maxSize`, run on each one;
202
+ * rules for arrays, like `maxLength`, limit the count.
203
+ */
204
+ const fileType = defineFieldType({
205
+ kind: (field) => field.multiple ? "array" : "file",
206
+ itemKind: "file",
207
+ props: v.object({
208
+ multiple: v.optional(v.boolean()),
209
+ accept: v.optional(v.pipe(v.string(), v.check((input) => mimeTypes(input).every((type) => /^[\w.+-]+\/[\w.+-]+$/.test(type)), "Use MIME types separated by commas, such as image/png,image/jpeg")))
210
+ }),
211
+ schema: (field) => {
212
+ const file = field.accept ? v.pipe(v.file(), v.mimeType(mimeTypes(field.accept))) : v.file();
213
+ if (!field.multiple) return file;
214
+ return field.accept ? v.pipe(v.array(v.file()), eachItem(file)) : v.array(file);
53
215
  }
216
+ });
217
+ // src/field-types/default-field-types.ts
218
+ /** Every built-in field type. */
219
+ const defaultFieldTypes = {
220
+ ...basicTypes,
221
+ ...dateTypes,
222
+ ...choiceTypes,
223
+ file: fileType,
224
+ ...displayTypes
54
225
  };
55
226
  // src/kinds/default-kinds.ts
56
227
  const isNil = (input) => input === void 0 || input === null;
@@ -78,58 +249,514 @@ const defaultKinds = {
78
249
  };
79
250
  // src/rules/define-rule.ts
80
251
  const defineRule = (def) => def;
81
- // src/rules/default-rules.ts
82
- const nonNegInt = v.pipe(v.number(), v.integer(), v.minValue(0));
83
- const defaultRules = {
84
- trim: defineRule({
252
+ // src/rules/param-schemas.ts
253
+ /** A length, count or size: a whole number from 0. */
254
+ const count = v.pipe(v.number(), v.integer(), v.minValue(0));
255
+ const countParams = v.object({ value: count });
256
+ const isIsoValue = (input) => v.ISO_DATE_REGEX.test(input) || v.ISO_DATE_TIME_REGEX.test(input) || v.ISO_TIME_REGEX.test(input);
257
+ /** Valibot compares dates as ISO strings, so the param must be one too. */
258
+ const isoValue = v.pipe(v.string(), v.check(isIsoValue, "Use an ISO date, date-time or time, such as 2026-01-01"));
259
+ const valueSchema = (kind) => {
260
+ switch (kind) {
261
+ case "date": return isoValue;
262
+ case "string": return v.string();
263
+ case "boolean": return v.boolean();
264
+ default: return v.number();
265
+ }
266
+ };
267
+ // src/rules/comparison-rules.ts
268
+ const ordered = ["number", "date"];
269
+ const any = [
270
+ "number",
271
+ "date",
272
+ "string",
273
+ "boolean"
274
+ ];
275
+ const meta$6 = (label) => ({
276
+ label,
277
+ category: "comparison"
278
+ });
279
+ const valueParams = (kind) => v.object({ value: valueSchema(kind) });
280
+ const valuesParams = (kind) => v.object({ values: v.pipe(v.array(valueSchema(kind)), v.minLength(1, "Give at least one value")) });
281
+ /** Compares the value with a param of the same type. */
282
+ const comparisonRules = {
283
+ minValue: defineRule({
284
+ kinds: ordered,
285
+ params: valueParams,
286
+ meta: meta$6("Minimum"),
287
+ create: (p, m) => v.minValue(p.value, m)
288
+ }),
289
+ maxValue: defineRule({
290
+ kinds: ordered,
291
+ params: valueParams,
292
+ meta: meta$6("Maximum"),
293
+ create: (p, m) => v.maxValue(p.value, m)
294
+ }),
295
+ gtValue: defineRule({
296
+ kinds: ordered,
297
+ params: valueParams,
298
+ meta: meta$6("Greater than"),
299
+ create: (p, m) => v.gtValue(p.value, m)
300
+ }),
301
+ ltValue: defineRule({
302
+ kinds: ordered,
303
+ params: valueParams,
304
+ meta: meta$6("Less than"),
305
+ create: (p, m) => v.ltValue(p.value, m)
306
+ }),
307
+ value: defineRule({
308
+ kinds: any,
309
+ params: valueParams,
310
+ meta: meta$6("Equals"),
311
+ create: (p, m) => v.value(p.value, m)
312
+ }),
313
+ notValue: defineRule({
314
+ kinds: any,
315
+ params: valueParams,
316
+ meta: meta$6("Does not equal"),
317
+ create: (p, m) => v.notValue(p.value, m)
318
+ }),
319
+ values: defineRule({
320
+ kinds: ordered,
321
+ params: valuesParams,
322
+ meta: meta$6("One of"),
323
+ create: (p, m) => v.values(p.values, m)
324
+ }),
325
+ notValues: defineRule({
326
+ kinds: ordered,
327
+ params: valuesParams,
328
+ meta: meta$6("None of"),
329
+ create: (p, m) => v.notValues(p.values, m)
330
+ })
331
+ };
332
+ // src/rules/content-rules.ts
333
+ const meta$5 = (label, description) => ({
334
+ label,
335
+ description,
336
+ category: "content"
337
+ });
338
+ const regexError = (pattern, flags = "") => {
339
+ try {
340
+ new RegExp(pattern, flags);
341
+ return;
342
+ } catch (error) {
343
+ return error.message;
344
+ }
345
+ };
346
+ const FLAGS = /^[dgimsuvy]*$/;
347
+ const regexParams = v.pipe(v.object({
348
+ pattern: v.string(),
349
+ flags: v.optional(v.pipe(v.string(), v.regex(FLAGS, "Flags can be d, g, i, m, s, u, v and y")))
350
+ }), v.forward(v.check((p) => !FLAGS.test(p.flags ?? "") || regexError(p.pattern, p.flags) === void 0, (issue) => regexError(issue.input.pattern, issue.input.flags) ?? "Invalid pattern"), ["pattern"]));
351
+ const item = v.union([
352
+ v.string(),
353
+ v.number(),
354
+ v.boolean()
355
+ ]);
356
+ const stringParams = v.object({ value: v.string() });
357
+ const contentParams = (kind) => v.object({ value: kind === "array" ? item : v.string() });
358
+ const contentRules = {
359
+ regex: defineRule({
85
360
  kinds: ["string"],
86
- stage: "normalize",
87
- create: () => v.trim()
361
+ params: regexParams,
362
+ meta: meta$5("Matches pattern", "A JavaScript regular expression, without the slashes."),
363
+ create: (p, m) => v.regex(new RegExp(p.pattern, p.flags?.replace(/[gy]/g, "")), m)
88
364
  }),
89
- minLength: defineRule({
365
+ startsWith: defineRule({
366
+ kinds: ["string"],
367
+ params: stringParams,
368
+ meta: meta$5("Starts with"),
369
+ create: (p, m) => v.startsWith(p.value, m)
370
+ }),
371
+ endsWith: defineRule({
90
372
  kinds: ["string"],
91
- params: v.object({ value: nonNegInt }),
373
+ params: stringParams,
374
+ meta: meta$5("Ends with"),
375
+ create: (p, m) => v.endsWith(p.value, m)
376
+ }),
377
+ includes: defineRule({
378
+ kinds: ["string", "array"],
379
+ params: contentParams,
380
+ meta: meta$5("Contains"),
381
+ create: (p, m) => v.includes(p.value, m)
382
+ }),
383
+ excludes: defineRule({
384
+ kinds: ["string", "array"],
385
+ params: contentParams,
386
+ meta: meta$5("Does not contain"),
387
+ create: (p, m) => v.excludes(p.value, m)
388
+ })
389
+ };
390
+ // src/rules/date-rules.ts
391
+ const iso = (label, description, create) => defineRule({
392
+ kinds: ["string", "date"],
393
+ meta: {
394
+ label,
395
+ description,
396
+ category: "date"
397
+ },
398
+ create: (_, message) => create(message)
399
+ });
400
+ const dateRules = {
401
+ isoDate: iso("ISO date", "Like 2026-10-28", (m) => v.isoDate(m)),
402
+ isoDateTime: iso("ISO date and time", "Like 2026-10-28T14:30", (m) => v.isoDateTime(m)),
403
+ isoTime: iso("ISO time", "Like 14:30", (m) => v.isoTime(m)),
404
+ isoTimeSecond: iso("ISO time with seconds", "Like 14:30:15", (m) => v.isoTimeSecond(m)),
405
+ isoTimestamp: iso("ISO timestamp", "Like 2026-10-28T14:30:15.000z", (m) => v.isoTimestamp(m)),
406
+ isoWeek: iso("ISO week", "Like 2026-W44", (m) => v.isoWeek(m))
407
+ };
408
+ // src/rules/file-rules.ts
409
+ const kinds$3 = ["file"];
410
+ const meta$4 = (label, description) => ({
411
+ label,
412
+ description,
413
+ category: "file"
414
+ });
415
+ const bytes = "In bytes, e.g. 1048576 for 1 MiB.";
416
+ const mimeTypeParams = v.object({ types: v.pipe(v.array(v.pipe(v.string(), v.regex(/^[\w.+-]+\/[\w.+-]+$/, "Use a MIME type, such as image/png"))), v.minLength(1, "Give at least one MIME type")) });
417
+ /** Size and type of a File or Blob. */
418
+ const fileRules = {
419
+ minSize: defineRule({
420
+ kinds: kinds$3,
421
+ params: countParams,
422
+ meta: meta$4("Minimum size", bytes),
423
+ create: (p, m) => v.minSize(p.value, m)
424
+ }),
425
+ maxSize: defineRule({
426
+ kinds: kinds$3,
427
+ params: countParams,
428
+ meta: meta$4("Maximum size", bytes),
429
+ create: (p, m) => v.maxSize(p.value, m)
430
+ }),
431
+ size: defineRule({
432
+ kinds: kinds$3,
433
+ params: countParams,
434
+ meta: meta$4("Exact size", bytes),
435
+ create: (p, m) => v.size(p.value, m)
436
+ }),
437
+ notSize: defineRule({
438
+ kinds: kinds$3,
439
+ params: countParams,
440
+ meta: meta$4("Not this size", bytes),
441
+ create: (p, m) => v.notSize(p.value, m)
442
+ }),
443
+ mimeType: defineRule({
444
+ kinds: kinds$3,
445
+ params: mimeTypeParams,
446
+ meta: meta$4("File type"),
447
+ create: (p, m) => v.mimeType(p.types, m)
448
+ })
449
+ };
450
+ // src/rules/format-rules.ts
451
+ /** A string format without params. */
452
+ const format = (label, create) => defineRule({
453
+ kinds: ["string"],
454
+ meta: {
455
+ label,
456
+ category: "format"
457
+ },
458
+ create: (_, message) => create(message)
459
+ });
460
+ const hashParams = v.object({ types: v.pipe(v.array(v.picklist([
461
+ "md4",
462
+ "md5",
463
+ "sha1",
464
+ "sha256",
465
+ "sha384",
466
+ "sha512",
467
+ "ripemd128",
468
+ "ripemd160",
469
+ "tiger128",
470
+ "tiger160",
471
+ "tiger192",
472
+ "crc32",
473
+ "crc32b",
474
+ "adler32"
475
+ ])), v.minLength(1, "Pick at least one hash type")) });
476
+ /** Formats Valibot can check on a string. */
477
+ const formatRules = {
478
+ email: format("Email", (m) => v.email(m)),
479
+ rfcEmail: format("Email (RFC 5322)", (m) => v.rfcEmail(m)),
480
+ url: format("URL", (m) => v.url(m)),
481
+ domain: format("Domain", (m) => v.domain(m)),
482
+ slug: format("Slug", (m) => v.slug(m)),
483
+ uuid: format("UUID", (m) => v.uuid(m)),
484
+ ulid: format("ULID", (m) => v.ulid(m)),
485
+ cuid2: format("CUID2", (m) => v.cuid2(m)),
486
+ nanoid: format("Nano ID", (m) => v.nanoid(m)),
487
+ ip: format("IP address", (m) => v.ip(m)),
488
+ ipv4: format("IPv4 address", (m) => v.ipv4(m)),
489
+ ipv6: format("IPv6 address", (m) => v.ipv6(m)),
490
+ mac: format("MAC address", (m) => v.mac(m)),
491
+ mac48: format("MAC-48 address", (m) => v.mac48(m)),
492
+ mac64: format("MAC-64 address", (m) => v.mac64(m)),
493
+ hexColor: format("Hex colour", (m) => v.hexColor(m)),
494
+ hexadecimal: format("Hexadecimal", (m) => v.hexadecimal(m)),
495
+ octal: format("Octal", (m) => v.octal(m)),
496
+ decimal: format("Decimal number", (m) => v.decimal(m)),
497
+ digits: format("Digits only", (m) => v.digits(m)),
498
+ base64: format("Base64", (m) => v.base64(m)),
499
+ bic: format("BIC (SWIFT code)", (m) => v.bic(m)),
500
+ creditCard: format("Credit card number", (m) => v.creditCard(m)),
501
+ imei: format("IMEI", (m) => v.imei(m)),
502
+ isbn: format("ISBN", (m) => v.isbn(m)),
503
+ isrc: format("ISRC", (m) => v.isrc(m)),
504
+ emoji: format("Emoji", (m) => v.emoji(m)),
505
+ hash: defineRule({
506
+ kinds: ["string"],
507
+ params: hashParams,
508
+ meta: {
509
+ label: "Hash",
510
+ category: "format"
511
+ },
512
+ create: (p, m) => v.hash(p.types, m)
513
+ })
514
+ };
515
+ // src/rules/length-rules.ts
516
+ const kinds$2 = ["string", "array"];
517
+ const meta$3 = (label) => ({
518
+ label,
519
+ category: "length"
520
+ });
521
+ /** Characters of a string, or items of an array */
522
+ const lengthRules = {
523
+ length: defineRule({
524
+ kinds: kinds$2,
525
+ params: countParams,
526
+ meta: meta$3("Exact length"),
527
+ create: (p, m) => v.length(p.value, m)
528
+ }),
529
+ minLength: defineRule({
530
+ kinds: kinds$2,
531
+ params: countParams,
532
+ meta: meta$3("Minimum length"),
92
533
  create: (p, m) => v.minLength(p.value, m)
93
534
  }),
94
535
  maxLength: defineRule({
95
- kinds: ["string"],
96
- params: v.object({ value: nonNegInt }),
536
+ kinds: kinds$2,
537
+ params: countParams,
538
+ meta: meta$3("Maximum length"),
97
539
  create: (p, m) => v.maxLength(p.value, m)
98
540
  }),
99
- regex: defineRule({
541
+ notLength: defineRule({
542
+ kinds: kinds$2,
543
+ params: countParams,
544
+ meta: meta$3("Not this length"),
545
+ create: (p, m) => v.notLength(p.value, m)
546
+ })
547
+ };
548
+ // src/rules/normalize-rules.ts
549
+ const stage = "normalize";
550
+ const meta$2 = (label, description) => ({
551
+ label,
552
+ description,
553
+ category: "normalize"
554
+ });
555
+ const limit = v.object({ value: v.number() });
556
+ /**
557
+ * Rules that change the value instead of checking it. They run before the required check, so
558
+ * `' '` with `trim` is empty. Their `message` is never shown.
559
+ */
560
+ const normalizeRules = {
561
+ trim: defineRule({
100
562
  kinds: ["string"],
101
- params: v.object({
102
- pattern: v.string(),
103
- flags: v.optional(v.string())
104
- }),
105
- create: (p, m) => v.regex(new RegExp(p.pattern, p.flags), m)
563
+ stage,
564
+ meta: meta$2("Trim"),
565
+ create: () => v.trim()
106
566
  }),
107
- email: defineRule({
567
+ trimStart: defineRule({
108
568
  kinds: ["string"],
109
- create: (_, m) => v.email(m)
569
+ stage,
570
+ meta: meta$2("Trim start"),
571
+ create: () => v.trimStart()
110
572
  }),
111
- url: defineRule({
573
+ trimEnd: defineRule({
112
574
  kinds: ["string"],
113
- create: (_, m) => v.url(m)
575
+ stage,
576
+ meta: meta$2("Trim end"),
577
+ create: () => v.trimEnd()
114
578
  }),
115
- minValue: defineRule({
116
- kinds: ["number"],
117
- params: v.object({ value: v.number() }),
118
- create: (p, m) => v.minValue(p.value, m)
579
+ toLowerCase: defineRule({
580
+ kinds: ["string"],
581
+ stage,
582
+ meta: meta$2("Lowercase"),
583
+ create: () => v.toLowerCase()
119
584
  }),
120
- maxValue: defineRule({
585
+ toUpperCase: defineRule({
586
+ kinds: ["string"],
587
+ stage,
588
+ meta: meta$2("Uppercase"),
589
+ create: () => v.toUpperCase()
590
+ }),
591
+ normalize: defineRule({
592
+ kinds: ["string"],
593
+ stage,
594
+ params: v.object({ form: v.optional(v.picklist([
595
+ "NFC",
596
+ "NFD",
597
+ "NFKC",
598
+ "NFKD"
599
+ ])) }),
600
+ meta: meta$2("Unicode normalize", "Defaults to NFC."),
601
+ create: (p) => v.normalize(p.form)
602
+ }),
603
+ toMinValue: defineRule({
121
604
  kinds: ["number"],
122
- params: v.object({ value: v.number() }),
123
- create: (p, m) => v.maxValue(p.value, m)
605
+ stage,
606
+ params: limit,
607
+ meta: meta$2("Raise to minimum", "Replaces a smaller number with the minimum."),
608
+ create: (p) => v.toMinValue(p.value)
124
609
  }),
125
- integer: defineRule({
610
+ toMaxValue: defineRule({
126
611
  kinds: ["number"],
612
+ stage,
613
+ params: limit,
614
+ meta: meta$2("Lower to maximum", "Replaces a larger number with the maximum."),
615
+ create: (p) => v.toMaxValue(p.value)
616
+ })
617
+ };
618
+ // src/rules/number-rules.ts
619
+ const kinds$1 = ["number"];
620
+ const meta$1 = (label) => ({
621
+ label,
622
+ category: "number"
623
+ });
624
+ const divisor = v.pipe(v.number(), v.finite(), v.notValue(0, "Can't be 0"));
625
+ /** Checks only numbers have. */
626
+ const numberRules = {
627
+ integer: defineRule({
628
+ kinds: kinds$1,
629
+ meta: meta$1("Whole number"),
127
630
  create: (_, m) => v.integer(m)
631
+ }),
632
+ safeInteger: defineRule({
633
+ kinds: kinds$1,
634
+ meta: meta$1("Safe whole number"),
635
+ create: (_, m) => v.safeInteger(m)
636
+ }),
637
+ finite: defineRule({
638
+ kinds: kinds$1,
639
+ meta: meta$1("Finite number"),
640
+ create: (_, m) => v.finite(m)
641
+ }),
642
+ multipleOf: defineRule({
643
+ kinds: kinds$1,
644
+ params: v.object({ value: divisor }),
645
+ meta: meta$1("Multiple of"),
646
+ create: (p, m) => v.multipleOf(p.value, m)
647
+ })
648
+ };
649
+ // src/rules/text-unit-rules.ts
650
+ const kinds = ["string"];
651
+ const meta = (label, description) => ({
652
+ label,
653
+ description,
654
+ category: "text"
655
+ });
656
+ const isLocale = (input) => {
657
+ try {
658
+ return Intl.getCanonicalLocales(input).length === 1;
659
+ } catch {
660
+ return false;
661
+ }
662
+ };
663
+ const wordsParams = v.object({
664
+ locale: v.optional(v.pipe(v.string(), v.check(isLocale, "Use a language tag, such as en or sr-Latn"))),
665
+ value: count
666
+ });
667
+ const graphemesInfo = "Counts characters as people see them, so an emoji is one.";
668
+ const bytesInfo = "Counts UTF-8 bytes, e.g. to fit a database column,";
669
+ const textUnitRules = {
670
+ graphemes: defineRule({
671
+ kinds,
672
+ params: countParams,
673
+ meta: meta("Exact characters", graphemesInfo),
674
+ create: (p, m) => v.graphemes(p.value, m)
675
+ }),
676
+ minGraphemes: defineRule({
677
+ kinds,
678
+ params: countParams,
679
+ meta: meta("Minimum characters", graphemesInfo),
680
+ create: (p, m) => v.minGraphemes(p.value, m)
681
+ }),
682
+ maxGraphemes: defineRule({
683
+ kinds,
684
+ params: countParams,
685
+ meta: meta("Maximum characters", graphemesInfo),
686
+ create: (p, m) => v.maxGraphemes(p.value, m)
687
+ }),
688
+ notGraphemes: defineRule({
689
+ kinds,
690
+ params: countParams,
691
+ meta: meta("Not this many characters", graphemesInfo),
692
+ create: (p, m) => v.notGraphemes(p.value, m)
693
+ }),
694
+ words: defineRule({
695
+ kinds,
696
+ params: wordsParams,
697
+ meta: meta("Exact words"),
698
+ create: (p, m) => v.words(p.locale, p.value, m)
699
+ }),
700
+ minWords: defineRule({
701
+ kinds,
702
+ params: wordsParams,
703
+ meta: meta("Minimum words"),
704
+ create: (p, m) => v.minWords(p.locale, p.value, m)
705
+ }),
706
+ maxWords: defineRule({
707
+ kinds,
708
+ params: wordsParams,
709
+ meta: meta("Maximum words"),
710
+ create: (p, m) => v.maxWords(p.locale, p.value, m)
711
+ }),
712
+ notWords: defineRule({
713
+ kinds,
714
+ params: wordsParams,
715
+ meta: meta("Not this many words"),
716
+ create: (p, m) => v.notWords(p.locale, p.value, m)
717
+ }),
718
+ bytes: defineRule({
719
+ kinds,
720
+ params: countParams,
721
+ meta: meta("Exact bytes", bytesInfo),
722
+ create: (p, m) => v.bytes(p.value, m)
723
+ }),
724
+ minBytes: defineRule({
725
+ kinds,
726
+ params: countParams,
727
+ meta: meta("Minimum bytes", bytesInfo),
728
+ create: (p, m) => v.minBytes(p.value, m)
729
+ }),
730
+ maxBytes: defineRule({
731
+ kinds,
732
+ params: countParams,
733
+ meta: meta("Maximum bytes", bytesInfo),
734
+ create: (p, m) => v.maxBytes(p.value, m)
735
+ }),
736
+ notBytes: defineRule({
737
+ kinds,
738
+ params: countParams,
739
+ meta: meta("Not this many bytes", bytesInfo),
740
+ create: (p, m) => v.notBytes(p.value, m)
128
741
  })
129
742
  };
743
+ // src/rules/default-rules.ts
744
+ /** Every built-in rule. Register single families with `includeDefaultRules: false`. */
745
+ const defaultRules = {
746
+ ...lengthRules,
747
+ ...textUnitRules,
748
+ ...contentRules,
749
+ ...formatRules,
750
+ ...dateRules,
751
+ ...comparisonRules,
752
+ ...numberRules,
753
+ ...fileRules,
754
+ ...normalizeRules
755
+ };
130
756
  // src/builder/registry.ts
131
757
  /** Consumer entries replace defaults with the same key. */
132
758
  function mergeRegistry(custom) {
759
+ const rules = custom.includeDefaultRules === false ? {} : defaultRules;
133
760
  return {
134
761
  kinds: Object.freeze({
135
762
  ...defaultKinds,
@@ -140,7 +767,7 @@ function mergeRegistry(custom) {
140
767
  ...custom.fieldTypes
141
768
  }),
142
769
  rules: Object.freeze({
143
- ...defaultRules,
770
+ ...rules,
144
771
  ...custom.rules
145
772
  })
146
773
  };
@@ -165,6 +792,22 @@ var DefinitionError = class extends Error {
165
792
  this.issues = issues;
166
793
  }
167
794
  };
795
+ // src/rules/rule-params.ts
796
+ const cache = /* @__PURE__ */ new WeakMap();
797
+ function paramsFor(def, kind) {
798
+ if (typeof def.params !== "function") return def.params;
799
+ let byKind = cache.get(def);
800
+ if (!byKind) {
801
+ byKind = /* @__PURE__ */ new Map();
802
+ cache.set(def, byKind);
803
+ }
804
+ let params = byKind.get(kind);
805
+ if (!params) {
806
+ params = def.params(kind);
807
+ byKind.set(kind, params);
808
+ }
809
+ return params;
810
+ }
168
811
  // src/shared/reserved-keys.ts
169
812
  /**
170
813
  * Field keys this package owns. A field type's props can't use them.
@@ -266,26 +909,53 @@ function checkField(raw, path, registry, names, issues) {
266
909
  }
267
910
  return;
268
911
  }
269
- const fieldRules = (shape.output.rules ?? []).flatMap((rule, j) => checkRule(rule, type?.kind, [
912
+ const withProps = props && {
913
+ ...field,
914
+ ...props
915
+ };
916
+ const kind = resolveKind(type.kind, withProps);
917
+ if (kind === void 0) return void 0;
918
+ const kinds = {
919
+ kind,
920
+ itemKind: kind === "array" ? resolveKind(type.itemKind, withProps) : void 0
921
+ };
922
+ const fieldRules = (shape.output.rules ?? []).flatMap((rule, j) => checkRule(rule, kinds, [
270
923
  ...path,
271
924
  "rules",
272
925
  j
273
926
  ], registry, issues) ?? []);
274
- if (issues.length > issueCount || !field.name) return void 0;
927
+ if (issues.length > issueCount || !field.name || !withProps) return void 0;
275
928
  const overridden = new Set(fieldRules.map((rule) => rule.name));
276
- const defaultRules = (type?.defaultRules ?? []).filter((rule) => !overridden.has(rule.rule)).flatMap((rule) => checkRule(rule, type?.kind, path, registry, []) ?? []);
929
+ const defaultRules = checkDefaultRules(field.type, type, kinds, registry).filter((rule) => !overridden.has(rule.name));
277
930
  return {
278
931
  name: field.name,
279
- field: {
280
- ...field,
281
- ...props
282
- },
932
+ field: withProps,
283
933
  path,
284
934
  type,
285
- kind: registry.kinds[type?.kind],
935
+ kind: kindDef(field.type, kind, registry),
286
936
  rules: [...defaultRules, ...fieldRules]
287
937
  };
288
938
  }
939
+ function resolveKind(kind, field) {
940
+ if (typeof kind !== "function") return kind;
941
+ return field && kind(field);
942
+ }
943
+ /** A kind that doesn't fit is a mistake in the field type, not in the definition. */
944
+ function kindDef(typeName, kind, registry) {
945
+ const at = `Field type "${typeName}"`;
946
+ if (kind === "none") throw new TypeError(`${at}: kind() can't return 'none'`);
947
+ const def = lookup(registry.kinds, kind);
948
+ if (!def) throw new TypeError(`${at}: kind "${kind}" is not registered`);
949
+ return def;
950
+ }
951
+ /** Throws for a default rule that doesn't fit, because that is a mistake in the type. */
952
+ function checkDefaultRules(typeName, type, kinds, registry) {
953
+ const issues = [];
954
+ const rules = (type.defaultRules ?? []).flatMap((rule, j) => checkRule(rule, kinds, ["defaultRules", j], registry, issues) ?? []);
955
+ const [first] = issues;
956
+ if (first) throw new TypeError(`Field type "${typeName}": ${formatPath(first.path)}: ${first.message}`);
957
+ return rules;
958
+ }
289
959
  function checkProps(field, type, path, issues) {
290
960
  const propKeys = Object.keys(type.props?.entries ?? {});
291
961
  const input = {};
@@ -298,9 +968,8 @@ function checkProps(field, type, path, issues) {
298
968
  const parsed = v.safeParse(type.props, input);
299
969
  if (parsed.success) return parsed.output;
300
970
  issues.push(...toIssues("invalid_props", path, parsed.issues));
301
- return {};
302
971
  }
303
- function checkRule(raw, kind, path, registry, issues) {
972
+ function checkRule(raw, { kind, itemKind }, path, registry, issues) {
304
973
  const shape = v.safeParse(ruleShape, raw);
305
974
  if (!shape.success) {
306
975
  issues.push(...toIssues("invalid_shape", path, shape.issues));
@@ -312,41 +981,44 @@ function checkRule(raw, kind, path, registry, issues) {
312
981
  issues.push(issue("unknown_rule", [...path, "rule"], `Unknown rule "${rule.rule}"`));
313
982
  return;
314
983
  }
315
- const issueCount = issues.length;
316
- if (!def.kinds.includes(kind)) {
317
- const message = `Rule "${rule.rule} can't be used on ${kind} fields`;
984
+ const each = !def.kinds.includes(kind) && !!itemKind && def.kinds.includes(itemKind);
985
+ if (!def.kinds.includes(kind) && !each) {
986
+ const items = itemKind ? ` or their ${itemKind} items` : "";
987
+ const message = `Rule "${rule.rule}" can't be used on ${kind} fields${items}`;
318
988
  issues.push(issue("kind_mismatch", [...path, "rule"], message));
989
+ return;
319
990
  }
320
- const paramKeys = Object.keys(def.params?.entries ?? {});
991
+ const issueCount = issues.length;
992
+ const params = paramsFor(def, each ? itemKind : kind);
993
+ const paramKeys = Object.keys(params?.entries ?? {});
321
994
  const input = {};
322
995
  for (const key of Object.keys(rule)) {
323
996
  if (isReservedRuleKey(key)) continue;
324
997
  if (paramKeys.includes(key)) input[key] = rule[key];
325
998
  else issues.push(issue("unknown_param", [...path, key], `Rule "${rule.rule}" has no param "${key}"`));
326
999
  }
327
- const parsed = v.safeParse(def.params ?? noParams, input);
1000
+ const parsed = v.safeParse(params ?? noParams, input);
328
1001
  if (!parsed.success) issues.push(...toIssues("invalid_params", path, parsed.issues));
329
1002
  if (!parsed.success || issues.length > issueCount) return void 0;
330
1003
  return {
331
1004
  name: rule.rule,
332
1005
  def,
333
1006
  params: parsed.output,
334
- message: rule.message
1007
+ message: rule.message,
1008
+ each
335
1009
  };
336
1010
  }
337
1011
  function checkRegistry(registry) {
338
- for (const [name, rule] of Object.entries(registry.rules)) for (const key of Object.keys(rule.params?.entries ?? {})) if (isReservedRuleKey(key)) throw new TypeError(`Rule "${name}": params can't use the reserved key "${key}"`);
1012
+ for (const [name, rule] of Object.entries(registry.rules)) for (const kind of rule.kinds) for (const key of Object.keys(paramsFor(rule, kind)?.entries ?? {})) if (isReservedRuleKey(key)) throw new TypeError(`Rule "${name}": params can't use the reserved key "${key}"`);
339
1013
  for (const [name, type] of Object.entries(registry.fieldTypes)) {
340
1014
  const at = `Field type "${name}"`;
341
- if (!lookup(registry.kinds, type.kind)) throw new TypeError(`${at}: kind "${type.kind}" is not registered`);
1015
+ for (const kind of [type.kind, type.itemKind]) if (typeof kind === "string" && !lookup(registry.kinds, kind)) throw new TypeError(`${at}: kind "${kind}" is not registered`);
342
1016
  if (type.kind !== "none" && !type.schema) throw new TypeError(`${at}: schema() is required unless the kind is 'none'`);
343
1017
  for (const key of Object.keys(type.props?.entries ?? {})) if (isReservedFieldKey(key)) throw new TypeError(`${at}: props can't use the reserved key "${key}"`);
344
- const issues = [];
345
- type.defaultRules?.forEach((rule, j) => {
346
- checkRule(rule, type.kind, ["defaultRules", j], registry, issues);
347
- });
348
- const [first] = issues;
349
- if (first) throw new TypeError(`${at}: ${formatPath(first.path)}: ${first.message}`);
1018
+ if (typeof type.kind === "string" && typeof type.itemKind !== "function") checkDefaultRules(name, type, {
1019
+ kind: type.kind,
1020
+ itemKind: type.kind === "array" ? type.itemKind : void 0
1021
+ }, registry);
350
1022
  }
351
1023
  }
352
1024
  function issue(code, path, message) {
@@ -388,16 +1060,21 @@ function createSchemaBuilder(options = {}) {
388
1060
  for (const field of fields) entries[field.name] = buildFieldSchema(field, messages);
389
1061
  return v.object(entries);
390
1062
  }
1063
+ /**
1064
+ * The registered rules that can be used on a field of `kind`, e.g. for a rule picker.
1065
+ */
1066
+ function rulesForKind(kind) {
1067
+ return Object.entries(registry.rules).filter(([, def]) => def.kinds.includes(kind));
1068
+ }
391
1069
  return {
392
1070
  build,
393
1071
  validate,
1072
+ rulesForKind,
394
1073
  kinds: registry.kinds,
395
1074
  fieldTypes: registry.fieldTypes,
396
1075
  rules: registry.rules
397
1076
  };
398
1077
  }
399
- // src/field-types/define-field-type.ts
400
- const defineFieldType = (def) => def;
401
- export { DefinitionError, RESERVED_FIELD_KEYS, RESERVED_RULE_KEYS, createSchemaBuilder, defaultFieldTypes, defaultKinds, defaultRules, defineFieldType, defineRule, formatPath };
1078
+ export { DefinitionError, RESERVED_FIELD_KEYS, RESERVED_RULE_KEYS, comparisonRules, contentRules, createSchemaBuilder, dateRules, defaultFieldTypes, defaultKinds, defaultRules, defineFieldType, defineRule, fileRules, formatPath, formatRules, lengthRules, normalizeRules, numberRules, textUnitRules };
402
1079
 
403
1080
  //# sourceMappingURL=index.js.map