@oh-my-pi/omptype 17.2.6 → 17.2.8
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/CHANGELOG.md +40 -2
- package/README.md +83 -14
- package/dist/js/ark.js +16 -0
- package/dist/js/compile.js +942 -0
- package/dist/js/errors.js +394 -0
- package/dist/js/from-json-schema.js +234 -0
- package/dist/js/index.js +13 -0
- package/dist/js/infer.js +2 -0
- package/dist/js/interp.js +858 -0
- package/dist/js/ir.js +1700 -0
- package/dist/js/json-schema.js +312 -0
- package/dist/js/keywords.js +340 -0
- package/dist/js/type.js +3062 -0
- package/dist/js/typebox.js +355 -0
- package/dist/js/zod.js +269 -0
- package/dist/types/ark.d.ts +3 -11
- package/dist/types/compile.d.ts +2 -0
- package/dist/types/errors.d.ts +53 -16
- package/dist/types/from-json-schema.d.ts +9 -0
- package/dist/types/index.d.ts +4 -4
- package/dist/types/infer.d.ts +147 -31
- package/dist/types/interp.d.ts +14 -5
- package/dist/types/ir.d.ts +129 -9
- package/dist/types/json-schema.d.ts +15 -1
- package/dist/types/keywords.d.ts +7 -0
- package/dist/types/type.d.ts +525 -60
- package/dist/types/typebox.d.ts +78 -50
- package/dist/types/zod.d.ts +1 -1
- package/package.json +24 -8
- package/src/ark.ts +4 -14
- package/src/compile.ts +887 -154
- package/src/errors.ts +347 -37
- package/src/from-json-schema.ts +231 -0
- package/src/index.ts +4 -4
- package/src/infer.ts +363 -81
- package/src/interp.ts +585 -102
- package/src/ir.ts +1444 -128
- package/src/json-schema.ts +162 -19
- package/src/keywords.ts +380 -0
- package/src/type.ts +3842 -198
- package/src/typebox.ts +142 -99
- package/src/zod.ts +41 -28
package/src/json-schema.ts
CHANGED
|
@@ -2,24 +2,92 @@ import type { IR, PropIR } from "./ir";
|
|
|
2
2
|
|
|
3
3
|
export interface JsonSchemaOptions {
|
|
4
4
|
description?: string;
|
|
5
|
+
target?: string;
|
|
6
|
+
dialect?: string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Which side of morphs and defaults to describe:
|
|
9
|
+
* - `'input'` — accepted payloads: morphs emit their input shape, defaulted
|
|
10
|
+
* properties are optional (with `default` annotations).
|
|
11
|
+
* - `'output'` — produced values: morphs emit their output shape, defaulted
|
|
12
|
+
* properties are required (always present after validation).
|
|
13
|
+
* Unset keeps the hybrid legacy behavior (morph output, defaults optional).
|
|
14
|
+
*/
|
|
15
|
+
io?: "input" | "output";
|
|
16
|
+
fallback?: (context: { base: Record<string, unknown> }) => unknown;
|
|
5
17
|
}
|
|
6
18
|
|
|
7
19
|
type JsonSchema = Record<string, unknown>;
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
interface EmitCtx {
|
|
22
|
+
options?: JsonSchemaOptions;
|
|
23
|
+
/** Emitted alias definitions, attached as `$defs` on the root schema. */
|
|
24
|
+
defs: Map<string, JsonSchema>;
|
|
25
|
+
/** Alias resolver identity → assigned `$defs` name (cycle-safe). */
|
|
26
|
+
refs: Map<() => IR, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Emit the requested JSON Schema dialect represented by an IR tree. */
|
|
10
30
|
export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
|
|
11
|
-
const
|
|
31
|
+
const ctx: EmitCtx = { options, defs: new Map(), refs: new Map() };
|
|
32
|
+
let schema = emit(ir, ctx);
|
|
33
|
+
if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs);
|
|
34
|
+
if (options?.target === "draft-07") schema = toDraft7(schema);
|
|
35
|
+
const dialect = options?.dialect === null ? undefined : (options?.dialect ?? dialectFor(options?.target));
|
|
36
|
+
if (dialect !== undefined) schema.$schema = dialect;
|
|
12
37
|
if (options?.description !== undefined) schema.description = options.description;
|
|
13
38
|
return schema;
|
|
14
39
|
}
|
|
15
40
|
|
|
16
|
-
function
|
|
41
|
+
function dialectFor(target: string | undefined): string | undefined {
|
|
42
|
+
if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
|
|
43
|
+
if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
|
|
44
|
+
return target !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema {
|
|
48
|
+
const replacement = ctx.options?.fallback?.({ base: schema });
|
|
49
|
+
if (replacement === true) return {};
|
|
50
|
+
if (replacement === false) return { not: {} };
|
|
51
|
+
return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toDraft7(schema: JsonSchema): JsonSchema {
|
|
55
|
+
const converted: JsonSchema = {};
|
|
56
|
+
for (const key in schema) {
|
|
57
|
+
const value = schema[key];
|
|
58
|
+
if (key === "$ref" && typeof value === "string") {
|
|
59
|
+
converted.$ref = value.replace("#/$defs/", "#/definitions/");
|
|
60
|
+
} else if (key === "$defs") {
|
|
61
|
+
converted.definitions = toDraft7(value as JsonSchema);
|
|
62
|
+
} else if (key === "prefixItems" && Array.isArray(value)) {
|
|
63
|
+
converted.items = value.map(item =>
|
|
64
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
65
|
+
);
|
|
66
|
+
} else if (key === "items" && "prefixItems" in schema) {
|
|
67
|
+
converted.additionalItems =
|
|
68
|
+
typeof value === "object" && value !== null ? toDraft7(value as JsonSchema) : value;
|
|
69
|
+
} else if (Array.isArray(value)) {
|
|
70
|
+
converted[key] = value.map(item =>
|
|
71
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
72
|
+
);
|
|
73
|
+
} else if (typeof value === "object" && value !== null) {
|
|
74
|
+
converted[key] = toDraft7(value as JsonSchema);
|
|
75
|
+
} else {
|
|
76
|
+
converted[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return converted;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emit(ir: IR, ctx: EmitCtx): JsonSchema {
|
|
17
83
|
let schema: JsonSchema;
|
|
18
84
|
switch (ir.k) {
|
|
19
85
|
case "unknown":
|
|
20
|
-
case "undefined":
|
|
21
86
|
schema = {};
|
|
22
87
|
break;
|
|
88
|
+
case "undefined":
|
|
89
|
+
schema = fallback({}, ctx);
|
|
90
|
+
break;
|
|
23
91
|
case "null":
|
|
24
92
|
schema = { type: "null" };
|
|
25
93
|
break;
|
|
@@ -29,6 +97,12 @@ function emit(ir: IR): JsonSchema {
|
|
|
29
97
|
case "bigint":
|
|
30
98
|
schema = { type: "integer" };
|
|
31
99
|
break;
|
|
100
|
+
case "symbol":
|
|
101
|
+
schema = fallback({}, ctx);
|
|
102
|
+
break;
|
|
103
|
+
case "never":
|
|
104
|
+
schema = { not: {} };
|
|
105
|
+
break;
|
|
32
106
|
case "anyobject":
|
|
33
107
|
schema = { type: "object" };
|
|
34
108
|
break;
|
|
@@ -42,22 +116,81 @@ function emit(ir: IR): JsonSchema {
|
|
|
42
116
|
schema = emitLiteral(ir.v);
|
|
43
117
|
break;
|
|
44
118
|
case "union":
|
|
45
|
-
schema = emitUnion(ir.members);
|
|
119
|
+
schema = emitUnion(ir.members, ctx);
|
|
120
|
+
break;
|
|
121
|
+
case "intersection":
|
|
122
|
+
schema = { allOf: ir.members.map(member => emit(member, ctx)) };
|
|
46
123
|
break;
|
|
47
124
|
case "array":
|
|
48
|
-
schema = { type: "array", items: emit(ir.el) };
|
|
125
|
+
schema = { type: "array", items: emit(ir.el, ctx) };
|
|
49
126
|
if (ir.min !== undefined) schema.minItems = ir.min;
|
|
50
127
|
if (ir.max !== undefined) schema.maxItems = ir.max;
|
|
51
128
|
break;
|
|
129
|
+
case "tuple": {
|
|
130
|
+
const prefixItems = ir.prefix.map(item => {
|
|
131
|
+
const itemSchema = emit(item.val, ctx);
|
|
132
|
+
if (item.hasDefault) {
|
|
133
|
+
itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def;
|
|
134
|
+
}
|
|
135
|
+
return itemSchema;
|
|
136
|
+
});
|
|
137
|
+
const required = ir.prefix.reduce(
|
|
138
|
+
(count, item) => count + (item.opt || item.hasDefault ? 0 : 1),
|
|
139
|
+
ir.postfix.length,
|
|
140
|
+
);
|
|
141
|
+
schema = { type: "array", prefixItems, minItems: required };
|
|
142
|
+
if (ir.variadic === undefined) {
|
|
143
|
+
schema.items = false;
|
|
144
|
+
} else {
|
|
145
|
+
schema.items = emit(ir.variadic, ctx);
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
52
149
|
case "object":
|
|
53
|
-
schema = emitObject(ir.props, ir.index, ir.extras);
|
|
150
|
+
schema = emitObject(ir.props, ir.index, ir.extras, ctx);
|
|
54
151
|
break;
|
|
152
|
+
case "instance":
|
|
153
|
+
schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, ctx);
|
|
154
|
+
break;
|
|
155
|
+
case "refine":
|
|
156
|
+
schema = emit(ir.base, ctx);
|
|
157
|
+
if (ir.json !== undefined) Object.assign(schema, ir.json);
|
|
158
|
+
break;
|
|
159
|
+
case "morph":
|
|
160
|
+
schema = ctx.options?.io === "input" ? emit(ir.input, ctx) : fallback(emit(ir.out ?? ir.input, ctx), ctx);
|
|
161
|
+
break;
|
|
162
|
+
case "alias": {
|
|
163
|
+
const known = ctx.refs.get(ir.resolve);
|
|
164
|
+
if (known !== undefined) {
|
|
165
|
+
schema = { $ref: `#/$defs/${known}` };
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
// Register before lowering so cyclic aliases resolve to the same $ref
|
|
169
|
+
// instead of recursing forever.
|
|
170
|
+
let name = ir.name.replace(/[^\w.-]/g, "_");
|
|
171
|
+
while ([...ctx.refs.values()].includes(name)) name = `${name}_`;
|
|
172
|
+
ctx.refs.set(ir.resolve, name);
|
|
173
|
+
ctx.defs.set(name, emit(ir.resolve(), ctx));
|
|
174
|
+
schema = { $ref: `#/$defs/${name}` };
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
55
177
|
case "sub":
|
|
56
|
-
|
|
57
|
-
|
|
178
|
+
if (ctx.options?.io === "output" && ir.schema.opaqueOutput) {
|
|
179
|
+
schema = {};
|
|
180
|
+
} else if (ctx.options?.io === "output" && ir.schema.stepOut !== undefined) {
|
|
181
|
+
schema = emit(ir.schema.stepOut, ctx);
|
|
182
|
+
} else if (ctx.options?.io === "input") {
|
|
183
|
+
schema = emit(ir.schema.ir, ctx);
|
|
184
|
+
} else {
|
|
185
|
+
schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx);
|
|
186
|
+
}
|
|
187
|
+
if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc;
|
|
188
|
+
else if (ir.schema.description !== undefined && ir.descAuto !== true) {
|
|
189
|
+
schema.description = ir.schema.description;
|
|
190
|
+
}
|
|
58
191
|
break;
|
|
59
192
|
}
|
|
60
|
-
if (ir.desc !== undefined) schema.description = ir.desc;
|
|
193
|
+
if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc;
|
|
61
194
|
return schema;
|
|
62
195
|
}
|
|
63
196
|
|
|
@@ -73,10 +206,12 @@ function emitNumber(ir: Extract<IR, { k: "number" }>): JsonSchema {
|
|
|
73
206
|
const schema: JsonSchema = { type: ir.int ? "integer" : "number" };
|
|
74
207
|
if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min;
|
|
75
208
|
if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max;
|
|
209
|
+
if (ir.divisor !== undefined) schema.multipleOf = ir.divisor;
|
|
76
210
|
return schema;
|
|
77
211
|
}
|
|
78
212
|
|
|
79
213
|
function emitLiteral(value: unknown): JsonSchema {
|
|
214
|
+
if (value instanceof Date) return { type: "string", format: "date-time", const: value.toISOString() };
|
|
80
215
|
if (isJsonValue(value)) return { const: value };
|
|
81
216
|
switch (typeof value) {
|
|
82
217
|
case "string":
|
|
@@ -94,10 +229,10 @@ function emitLiteral(value: unknown): JsonSchema {
|
|
|
94
229
|
}
|
|
95
230
|
}
|
|
96
231
|
|
|
97
|
-
function emitUnion(members: IR[]): JsonSchema {
|
|
232
|
+
function emitUnion(members: IR[], ctx: EmitCtx): JsonSchema {
|
|
98
233
|
const defined = members.filter(member => member.k !== "undefined");
|
|
99
234
|
if (defined.length === 0) return {};
|
|
100
|
-
if (defined.length === 1) return emit(defined[0]);
|
|
235
|
+
if (defined.length === 1) return emit(defined[0], ctx);
|
|
101
236
|
if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
|
|
102
237
|
const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
|
|
103
238
|
const schema: JsonSchema = { enum: values };
|
|
@@ -105,7 +240,7 @@ function emitUnion(members: IR[]): JsonSchema {
|
|
|
105
240
|
if (scalarType !== undefined) schema.type = scalarType;
|
|
106
241
|
return schema;
|
|
107
242
|
}
|
|
108
|
-
return { anyOf: defined.map(emit) };
|
|
243
|
+
return { anyOf: defined.map(member => emit(member, ctx)) };
|
|
109
244
|
}
|
|
110
245
|
|
|
111
246
|
function homogeneousScalarType(values: unknown[]): string | undefined {
|
|
@@ -129,23 +264,31 @@ function jsonScalarType(value: unknown): string | undefined {
|
|
|
129
264
|
}
|
|
130
265
|
}
|
|
131
266
|
|
|
132
|
-
function emitObject(
|
|
267
|
+
function emitObject(
|
|
268
|
+
props: PropIR[],
|
|
269
|
+
index: IR | undefined,
|
|
270
|
+
extras: "keep" | "reject" | "delete",
|
|
271
|
+
ctx: EmitCtx,
|
|
272
|
+
): JsonSchema {
|
|
133
273
|
const properties: Record<string, unknown> = {};
|
|
134
274
|
const required: string[] = [];
|
|
275
|
+
const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault);
|
|
135
276
|
// ArkType emits required properties first (each group in declaration
|
|
136
277
|
// order); downstream wire consumers rely on that stable ordering.
|
|
137
|
-
const ordered = [...props.filter(
|
|
278
|
+
const ordered = [...props.filter(filled), ...props.filter(prop => !filled(prop))];
|
|
138
279
|
for (const prop of ordered) {
|
|
139
|
-
|
|
280
|
+
if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string");
|
|
281
|
+
const key = String(prop.key);
|
|
282
|
+
const propertySchema = emit(prop.val, ctx);
|
|
140
283
|
if (prop.hasDefault) {
|
|
141
284
|
propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
|
|
142
285
|
}
|
|
143
|
-
properties[
|
|
144
|
-
if (
|
|
286
|
+
properties[key] = propertySchema;
|
|
287
|
+
if (filled(prop)) required.push(key);
|
|
145
288
|
}
|
|
146
289
|
const schema: JsonSchema = { type: "object", properties };
|
|
147
290
|
if (required.length > 0) schema.required = required;
|
|
148
|
-
if (index !== undefined) schema.additionalProperties = emit(index);
|
|
291
|
+
if (index !== undefined) schema.additionalProperties = emit(index, ctx);
|
|
149
292
|
else if (extras === "reject") schema.additionalProperties = false;
|
|
150
293
|
return schema;
|
|
151
294
|
}
|
package/src/keywords.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { IR, MorphContext } from "./ir";
|
|
2
|
+
|
|
3
|
+
const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\d+?))$/;
|
|
4
|
+
const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/;
|
|
5
|
+
const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/;
|
|
6
|
+
const SEMVER =
|
|
7
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*))?(?:\+([\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*))?$/;
|
|
8
|
+
const UUID = /^[\da-f]{8}-[\da-f]{4}-[1-8][\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
|
|
9
|
+
const IPV4_SEGMENT = "(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
|
|
10
|
+
const IPV4_ADDRESS = `(?:${IPV4_SEGMENT}[.]){3}${IPV4_SEGMENT}`;
|
|
11
|
+
const IPV4 = new RegExp(`^${IPV4_ADDRESS}$`);
|
|
12
|
+
const IPV6_SEGMENT = "(?:[0-9a-fA-F]{1,4})";
|
|
13
|
+
const IPV6 = new RegExp(
|
|
14
|
+
"^(" +
|
|
15
|
+
`(?:${IPV6_SEGMENT}:){7}(?:${IPV6_SEGMENT}|:)|` +
|
|
16
|
+
`(?:${IPV6_SEGMENT}:){6}(?:${IPV4_ADDRESS}|:${IPV6_SEGMENT}|:)|` +
|
|
17
|
+
`(?:${IPV6_SEGMENT}:){5}(?::${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,2}|:)|` +
|
|
18
|
+
`(?:${IPV6_SEGMENT}:){4}(?:(:${IPV6_SEGMENT}){0,1}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,3}|:)|` +
|
|
19
|
+
`(?:${IPV6_SEGMENT}:){3}(?:(:${IPV6_SEGMENT}){0,2}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,4}|:)|` +
|
|
20
|
+
`(?:${IPV6_SEGMENT}:){2}(?:(:${IPV6_SEGMENT}){0,3}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,5}|:)|` +
|
|
21
|
+
`(?:${IPV6_SEGMENT}:){1}(?:(:${IPV6_SEGMENT}){0,4}:${IPV4_ADDRESS}|(:${IPV6_SEGMENT}){1,6}|:)|` +
|
|
22
|
+
`(?::((?::${IPV6_SEGMENT}){0,5}:${IPV4_ADDRESS}|(?::${IPV6_SEGMENT}){1,7}|:))` +
|
|
23
|
+
")(?:%[\\d.A-Za-z]{1,})?$",
|
|
24
|
+
);
|
|
25
|
+
const ISO_DATE =
|
|
26
|
+
/^([+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-3])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))(T((([01]\d|2[0-3])((:?)[0-5]\d)?|24:?00)([,.]\d+(?!:))?)?(\17[0-5]\d([,.]\d+)?)?([Zz]|([+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
|
|
27
|
+
|
|
28
|
+
function pattern(regex: RegExp, expected: string, format?: string): IR {
|
|
29
|
+
return {
|
|
30
|
+
k: "refine",
|
|
31
|
+
base: { k: "string" },
|
|
32
|
+
pred: value => {
|
|
33
|
+
regex.lastIndex = 0;
|
|
34
|
+
return regex.test(value as string);
|
|
35
|
+
},
|
|
36
|
+
expected,
|
|
37
|
+
json: format === undefined ? { pattern: regex.source } : { pattern: regex.source, format },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function morph(input: IR, fn: (value: string, context: MorphContext) => unknown, out?: IR): IR {
|
|
42
|
+
return { k: "morph", input, fn: (value, context) => fn(value as string, context), out };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsableJson(): IR {
|
|
46
|
+
return {
|
|
47
|
+
k: "refine",
|
|
48
|
+
base: { k: "string" },
|
|
49
|
+
pred: value => {
|
|
50
|
+
try {
|
|
51
|
+
JSON.parse(value as string);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
expected: "a JSON string",
|
|
58
|
+
json: { contentMediaType: "application/json" },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parsableDate(expected = "a parsable date"): IR {
|
|
63
|
+
return {
|
|
64
|
+
k: "refine",
|
|
65
|
+
base: { k: "string" },
|
|
66
|
+
pred: value => !Number.isNaN(new Date(value as string).valueOf()),
|
|
67
|
+
expected,
|
|
68
|
+
json: { format: "date-time" },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalize(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
|
|
73
|
+
return morph({ k: "string" }, value => value.normalize(form), patternForNormalized(form));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function patternForNormalized(form: "NFC" | "NFD" | "NFKC" | "NFKD"): IR {
|
|
77
|
+
return {
|
|
78
|
+
k: "refine",
|
|
79
|
+
base: { k: "string" },
|
|
80
|
+
pred: value => (value as string).normalize(form) === value,
|
|
81
|
+
expected: `${form}-normalized unicode`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function uuidVersion(version: string): IR {
|
|
86
|
+
return pattern(
|
|
87
|
+
new RegExp(`^[\\da-f]{8}-[\\da-f]{4}-${version}[\\da-f]{3}-[89ab][\\da-f]{3}-[\\da-f]{12}$`, "i"),
|
|
88
|
+
`a UUIDv${version}`,
|
|
89
|
+
"uuid",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isLuhnValid(input: string): boolean {
|
|
94
|
+
const value = input.replace(/[ -]+/g, "");
|
|
95
|
+
let sum = 0;
|
|
96
|
+
let double = false;
|
|
97
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
98
|
+
let digit = Number(value[index]);
|
|
99
|
+
if (double) {
|
|
100
|
+
digit *= 2;
|
|
101
|
+
if (digit > 9) digit -= 9;
|
|
102
|
+
}
|
|
103
|
+
sum += digit;
|
|
104
|
+
double = !double;
|
|
105
|
+
}
|
|
106
|
+
return value.length > 0 && sum % 10 === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function finiteNumber(expected = "a number"): IR {
|
|
110
|
+
return {
|
|
111
|
+
k: "refine",
|
|
112
|
+
base: { k: "unknown" },
|
|
113
|
+
pred: value => typeof value === "number" && !Number.isNaN(value),
|
|
114
|
+
expected,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function jsonObject(): IR {
|
|
119
|
+
let value: IR;
|
|
120
|
+
const resolveValue = (): IR => value;
|
|
121
|
+
const object: IR = {
|
|
122
|
+
k: "object",
|
|
123
|
+
props: [],
|
|
124
|
+
index: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
|
125
|
+
extras: "keep",
|
|
126
|
+
};
|
|
127
|
+
const array: IR = {
|
|
128
|
+
k: "array",
|
|
129
|
+
el: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
|
130
|
+
desc: "an object",
|
|
131
|
+
};
|
|
132
|
+
value = {
|
|
133
|
+
k: "union",
|
|
134
|
+
members: [
|
|
135
|
+
object,
|
|
136
|
+
array,
|
|
137
|
+
{ k: "number" },
|
|
138
|
+
{ k: "string" },
|
|
139
|
+
{ k: "lit", v: false },
|
|
140
|
+
{ k: "null" },
|
|
141
|
+
{ k: "lit", v: true },
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
return object;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function instance(ctor: new (...args: never[]) => object, name: string): IR {
|
|
148
|
+
return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keywordFactories: Record<string, () => IR> = {
|
|
152
|
+
string: () => ({ k: "string" }),
|
|
153
|
+
Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }),
|
|
154
|
+
"unknown.any": () => ({ k: "unknown" }),
|
|
155
|
+
Array: () => ({ k: "array", el: { k: "unknown" } }),
|
|
156
|
+
Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"),
|
|
157
|
+
RegExp: () => instance(RegExp, "RegExp"),
|
|
158
|
+
File: () => instance(File, "File"),
|
|
159
|
+
Error: () => instance(Error, "Error"),
|
|
160
|
+
Set: () => instance(Set, "Set"),
|
|
161
|
+
Map: () => instance(Map, "Map"),
|
|
162
|
+
WeakSet: () => instance(WeakSet, "WeakSet"),
|
|
163
|
+
WeakMap: () => instance(WeakMap, "WeakMap"),
|
|
164
|
+
Promise: () => instance(Promise, "Promise"),
|
|
165
|
+
FormData: () => instance(FormData, "FormData"),
|
|
166
|
+
"FormData.parse": () => ({
|
|
167
|
+
k: "morph",
|
|
168
|
+
input: instance(FormData, "FormData"),
|
|
169
|
+
fn: value => {
|
|
170
|
+
const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {};
|
|
171
|
+
for (const [key, entry] of (value as FormData).entries()) {
|
|
172
|
+
const current = out[key];
|
|
173
|
+
out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry];
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
},
|
|
177
|
+
out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" },
|
|
178
|
+
}),
|
|
179
|
+
"object.json": jsonObject,
|
|
180
|
+
"object.json.stringify": () => ({
|
|
181
|
+
k: "morph",
|
|
182
|
+
input: jsonObject(),
|
|
183
|
+
fn: value => JSON.stringify(value),
|
|
184
|
+
out: { k: "string" },
|
|
185
|
+
}),
|
|
186
|
+
"number.epoch": () => ({
|
|
187
|
+
k: "refine",
|
|
188
|
+
base: {
|
|
189
|
+
k: "refine",
|
|
190
|
+
base: {
|
|
191
|
+
k: "refine",
|
|
192
|
+
base: finiteNumber("a number representing a Unix timestamp"),
|
|
193
|
+
pred: value => Number.isInteger(value),
|
|
194
|
+
expected: "an integer representing a Unix timestamp",
|
|
195
|
+
},
|
|
196
|
+
pred: value => (value as number) >= -8_640_000_000_000_000,
|
|
197
|
+
expected: "a Unix timestamp after -8640000000000000",
|
|
198
|
+
},
|
|
199
|
+
pred: value => (value as number) <= 8_640_000_000_000_000,
|
|
200
|
+
expected: "a Unix timestamp before 8640000000000000",
|
|
201
|
+
}),
|
|
202
|
+
"number.safe": () => ({
|
|
203
|
+
k: "refine",
|
|
204
|
+
base: {
|
|
205
|
+
k: "refine",
|
|
206
|
+
base: finiteNumber(),
|
|
207
|
+
pred: value => (value as number) >= Number.MIN_SAFE_INTEGER,
|
|
208
|
+
expected: `at least ${Number.MIN_SAFE_INTEGER}`,
|
|
209
|
+
},
|
|
210
|
+
pred: value => (value as number) <= Number.MAX_SAFE_INTEGER,
|
|
211
|
+
expected: `at most ${Number.MAX_SAFE_INTEGER}`,
|
|
212
|
+
}),
|
|
213
|
+
"number.NaN": () => ({
|
|
214
|
+
k: "refine",
|
|
215
|
+
base: { k: "unknown" },
|
|
216
|
+
pred: Number.isNaN,
|
|
217
|
+
expected: "NaN",
|
|
218
|
+
}),
|
|
219
|
+
"number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }),
|
|
220
|
+
"number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }),
|
|
221
|
+
"string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
|
|
222
|
+
"string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
|
|
223
|
+
"string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),
|
|
224
|
+
"string.base64": () => pattern(/^(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{2}==|[\d+/A-Za-z]{3}=)?$/, "base64-encoded"),
|
|
225
|
+
"string.base64.url": () =>
|
|
226
|
+
pattern(/^(?:[\w-]{4})*(?:[\w-]{2}(?:==|%3D%3D)?|[\w-]{3}(?:=|%3D)?)?$/, "base64url-encoded"),
|
|
227
|
+
"string.capitalize": () => morph({ k: "string" }, value => value.charAt(0).toUpperCase() + value.slice(1)),
|
|
228
|
+
"string.capitalize.preformatted": () => pattern(/^[A-Z].*$/, "capitalized"),
|
|
229
|
+
"string.creditCard": () => ({
|
|
230
|
+
k: "refine",
|
|
231
|
+
base: pattern(
|
|
232
|
+
/^(?:4\d{12}(?:\d{3,6})?|5[1-5]\d{14}|3[47]\d{13}|6(?:011|5\d\d)\d{12,15})$/,
|
|
233
|
+
"a credit card number",
|
|
234
|
+
),
|
|
235
|
+
pred: value => isLuhnValid(value as string),
|
|
236
|
+
expected: "a credit card number",
|
|
237
|
+
}),
|
|
238
|
+
"string.date": () => parsableDate(),
|
|
239
|
+
"string.date.parse": () =>
|
|
240
|
+
morph(parsableDate(), value => new Date(value), { k: "instance", ctor: Date, expected: "a Date" }),
|
|
241
|
+
"string.date.iso": () => pattern(ISO_DATE, "an ISO 8601 date", "date-time"),
|
|
242
|
+
"string.date.iso.parse": () =>
|
|
243
|
+
morph(pattern(ISO_DATE, "an ISO 8601 date", "date-time"), value => new Date(value), {
|
|
244
|
+
k: "instance",
|
|
245
|
+
ctor: Date,
|
|
246
|
+
expected: "a Date",
|
|
247
|
+
}),
|
|
248
|
+
"string.date.epoch": () => pattern(INTEGER, "an integer string representing a Unix timestamp"),
|
|
249
|
+
"string.date.epoch.parse": () =>
|
|
250
|
+
morph(pattern(INTEGER, "an integer string representing a Unix timestamp"), value => new Date(Number(value)), {
|
|
251
|
+
k: "instance",
|
|
252
|
+
ctor: Date,
|
|
253
|
+
expected: "a Date",
|
|
254
|
+
}),
|
|
255
|
+
"string.digits": () => pattern(/^\d*$/, "only digits 0-9"),
|
|
256
|
+
"string.email": () => pattern(EMAIL, "an email address", "email"),
|
|
257
|
+
"string.integer": () => pattern(INTEGER, "a well-formed integer string"),
|
|
258
|
+
"string.integer.parse": () =>
|
|
259
|
+
morph(
|
|
260
|
+
pattern(INTEGER, "a well-formed integer string"),
|
|
261
|
+
(value, context) => {
|
|
262
|
+
const parsed = Number.parseInt(value, 10);
|
|
263
|
+
return Number.isSafeInteger(parsed) ? parsed : context.error("a safe integer string");
|
|
264
|
+
},
|
|
265
|
+
{ k: "number", int: true },
|
|
266
|
+
),
|
|
267
|
+
"string.ip": () => ({
|
|
268
|
+
k: "union",
|
|
269
|
+
members: [pattern(IPV4, "an IPv4 address", "ipv4"), pattern(IPV6, "an IPv6 address", "ipv6")],
|
|
270
|
+
}),
|
|
271
|
+
"string.ip.v4": () => pattern(IPV4, "an IPv4 address", "ipv4"),
|
|
272
|
+
"string.ip.v6": () => pattern(IPV6, "an IPv6 address", "ipv6"),
|
|
273
|
+
"string.json": () => parsableJson(),
|
|
274
|
+
"string.json.parse": () =>
|
|
275
|
+
morph(parsableJson(), (value, context) => {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(value);
|
|
278
|
+
} catch {
|
|
279
|
+
return context.error("a JSON string");
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
"string.lower": () => morph({ k: "string" }, value => value.toLowerCase()),
|
|
283
|
+
"string.lower.preformatted": () => pattern(/^[a-z]*$/, "only lowercase letters"),
|
|
284
|
+
"string.normalize": () => normalize("NFC"),
|
|
285
|
+
"string.normalize.NFC": () => normalize("NFC"),
|
|
286
|
+
"string.normalize.NFC.preformatted": () => patternForNormalized("NFC"),
|
|
287
|
+
"string.normalize.NFD": () => normalize("NFD"),
|
|
288
|
+
"string.normalize.NFD.preformatted": () => patternForNormalized("NFD"),
|
|
289
|
+
"string.normalize.NFKC": () => normalize("NFKC"),
|
|
290
|
+
"string.normalize.NFKC.preformatted": () => patternForNormalized("NFKC"),
|
|
291
|
+
"string.normalize.NFKD": () => normalize("NFKD"),
|
|
292
|
+
"string.normalize.NFKD.preformatted": () => patternForNormalized("NFKD"),
|
|
293
|
+
"string.numeric": () => pattern(NUMERIC, "a well-formed numeric string"),
|
|
294
|
+
"string.numeric.parse": () =>
|
|
295
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
296
|
+
"string.regex": () => ({
|
|
297
|
+
k: "refine",
|
|
298
|
+
base: { k: "string" },
|
|
299
|
+
pred: value => {
|
|
300
|
+
try {
|
|
301
|
+
new RegExp(value as string);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
expected: "a regex pattern",
|
|
308
|
+
json: { format: "regex" },
|
|
309
|
+
}),
|
|
310
|
+
"string.semver": () => pattern(SEMVER, "a semantic version"),
|
|
311
|
+
"string.trim": () => morph({ k: "string" }, value => value.trim()),
|
|
312
|
+
"string.trim.preformatted": () => pattern(/^\S.*\S$|^\S?$/, "trimmed"),
|
|
313
|
+
"string.upper": () => morph({ k: "string" }, value => value.toUpperCase()),
|
|
314
|
+
"string.upper.preformatted": () => pattern(/^[A-Z]*$/, "only uppercase letters"),
|
|
315
|
+
"string.url": () => ({ k: "string", url: true }),
|
|
316
|
+
"string.url.parse": () =>
|
|
317
|
+
morph({ k: "string", url: true }, value => new URL(value), { k: "instance", ctor: URL, expected: "a URL" }),
|
|
318
|
+
"string.uuid": () => pattern(UUID, "a UUID", "uuid"),
|
|
319
|
+
"string.uuid.v1": () => uuidVersion("1"),
|
|
320
|
+
"string.uuid.v2": () => uuidVersion("2"),
|
|
321
|
+
"string.uuid.v3": () => uuidVersion("3"),
|
|
322
|
+
"string.uuid.v4": () => uuidVersion("4"),
|
|
323
|
+
"string.uuid.v5": () => uuidVersion("5"),
|
|
324
|
+
"string.uuid.v6": () => uuidVersion("6"),
|
|
325
|
+
"string.uuid.v7": () => uuidVersion("7"),
|
|
326
|
+
"string.uuid.v8": () => uuidVersion("8"),
|
|
327
|
+
"parse.number": () =>
|
|
328
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
329
|
+
"parse.integer": () =>
|
|
330
|
+
morph(pattern(INTEGER, "a well-formed integer string"), value => Number.parseInt(value, 10), {
|
|
331
|
+
k: "number",
|
|
332
|
+
int: true,
|
|
333
|
+
}),
|
|
334
|
+
"parse.json": () => keywordFactories["string.json.parse"](),
|
|
335
|
+
"parse.date": () => keywordFactories["string.date.parse"](),
|
|
336
|
+
"parse.url": () => keywordFactories["string.url.parse"](),
|
|
337
|
+
"parse.boolean": () =>
|
|
338
|
+
morph(pattern(/^(?:true|false)$/, "a boolean string"), value => value === "true", { k: "boolean" }),
|
|
339
|
+
"parse.bigint": () => morph(pattern(INTEGER, "an integer string"), value => BigInt(value), { k: "bigint" }),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
/** Lower a built-in keyword into fresh validation IR. */
|
|
343
|
+
export function keywordIR(name: string): IR | undefined {
|
|
344
|
+
return keywordFactories[name]?.();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Lower a regular expression into a string refinement. */
|
|
348
|
+
export function patternIR(regex: RegExp): IR {
|
|
349
|
+
return pattern(regex, `a string matching ${regex}`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Lower an ArkType-style template literal into a string pattern. */
|
|
353
|
+
export function templateIR(source: string): IR {
|
|
354
|
+
let patternSource = "^";
|
|
355
|
+
let index = 0;
|
|
356
|
+
const placeholder = /\$\{(string|number|bigint|boolean)\}/g;
|
|
357
|
+
for (let match = placeholder.exec(source); match; match = placeholder.exec(source)) {
|
|
358
|
+
patternSource += escapeRegex(source.slice(index, match.index));
|
|
359
|
+
switch (match[1]) {
|
|
360
|
+
case "number":
|
|
361
|
+
patternSource += "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
|
|
362
|
+
break;
|
|
363
|
+
case "bigint":
|
|
364
|
+
patternSource += "[+-]?\\d+";
|
|
365
|
+
break;
|
|
366
|
+
case "boolean":
|
|
367
|
+
patternSource += "(?:true|false)";
|
|
368
|
+
break;
|
|
369
|
+
default:
|
|
370
|
+
patternSource += ".*";
|
|
371
|
+
}
|
|
372
|
+
index = match.index + match[0].length;
|
|
373
|
+
}
|
|
374
|
+
patternSource += `${escapeRegex(source.slice(index))}$`;
|
|
375
|
+
return pattern(new RegExp(patternSource), `a string matching \`${source}\``);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function escapeRegex(value: string): string {
|
|
379
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
380
|
+
}
|