@oh-my-pi/omptype 17.2.6 → 17.2.7
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 +17 -2
- package/README.md +83 -14
- package/dist/js/ark.js +16 -0
- package/dist/js/compile.js +747 -0
- package/dist/js/errors.js +199 -0
- package/dist/js/index.js +12 -0
- package/dist/js/infer.js +2 -0
- package/dist/js/interp.js +476 -0
- package/dist/js/ir.js +930 -0
- package/dist/js/json-schema.js +273 -0
- package/dist/js/keywords.js +233 -0
- package/dist/js/type.js +1078 -0
- package/dist/js/typebox.js +346 -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 +51 -15
- package/dist/types/index.d.ts +3 -4
- package/dist/types/infer.d.ts +107 -30
- package/dist/types/interp.d.ts +5 -3
- package/dist/types/ir.d.ts +76 -7
- package/dist/types/json-schema.d.ts +6 -1
- package/dist/types/keywords.d.ts +7 -0
- package/dist/types/type.d.ts +238 -49
- package/dist/types/zod.d.ts +1 -1
- package/package.json +20 -8
- package/src/ark.ts +4 -14
- package/src/compile.ts +432 -54
- package/src/errors.ts +146 -34
- package/src/index.ts +3 -4
- package/src/infer.ts +284 -77
- package/src/interp.ts +153 -9
- package/src/ir.ts +636 -89
- package/src/json-schema.ts +109 -14
- package/src/keywords.ts +270 -0
- package/src/type.ts +1302 -147
- package/src/typebox.ts +1 -1
- package/src/zod.ts +40 -27
package/src/json-schema.ts
CHANGED
|
@@ -2,24 +2,69 @@ import type { IR, PropIR } from "./ir";
|
|
|
2
2
|
|
|
3
3
|
export interface JsonSchemaOptions {
|
|
4
4
|
description?: string;
|
|
5
|
+
target?: string;
|
|
6
|
+
dialect?: string;
|
|
7
|
+
fallback?: (context: { base: Record<string, unknown> }) => unknown;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
type JsonSchema = Record<string, unknown>;
|
|
8
11
|
|
|
9
|
-
/** Emit the
|
|
12
|
+
/** Emit the requested JSON Schema dialect represented by an IR tree. */
|
|
10
13
|
export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
|
|
11
|
-
|
|
14
|
+
let schema = emit(ir, options);
|
|
15
|
+
if (options?.target === "draft-07") schema = toDraft7(schema);
|
|
16
|
+
const dialect = options?.dialect ?? dialectFor(options?.target);
|
|
17
|
+
if (dialect !== undefined) schema.$schema = dialect;
|
|
12
18
|
if (options?.description !== undefined) schema.description = options.description;
|
|
13
19
|
return schema;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
function
|
|
22
|
+
function dialectFor(target: string | undefined): string | undefined {
|
|
23
|
+
if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
|
|
24
|
+
if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
|
|
25
|
+
return target?.startsWith("http://") || target?.startsWith("https://") ? target : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fallback(schema: JsonSchema, options?: JsonSchemaOptions): JsonSchema {
|
|
29
|
+
const replacement = options?.fallback?.({ base: schema });
|
|
30
|
+
if (replacement === true) return {};
|
|
31
|
+
if (replacement === false) return { not: {} };
|
|
32
|
+
return typeof replacement === "object" && replacement !== null ? (replacement as JsonSchema) : schema;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toDraft7(schema: JsonSchema): JsonSchema {
|
|
36
|
+
const converted: JsonSchema = {};
|
|
37
|
+
for (const key in schema) {
|
|
38
|
+
const value = schema[key];
|
|
39
|
+
if (key === "prefixItems" && Array.isArray(value)) {
|
|
40
|
+
converted.items = value.map(item =>
|
|
41
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
42
|
+
);
|
|
43
|
+
} else if (key === "items" && "prefixItems" in schema) {
|
|
44
|
+
converted.additionalItems =
|
|
45
|
+
typeof value === "object" && value !== null ? toDraft7(value as JsonSchema) : value;
|
|
46
|
+
} else if (Array.isArray(value)) {
|
|
47
|
+
converted[key] = value.map(item =>
|
|
48
|
+
typeof item === "object" && item !== null ? toDraft7(item as JsonSchema) : item,
|
|
49
|
+
);
|
|
50
|
+
} else if (typeof value === "object" && value !== null) {
|
|
51
|
+
converted[key] = toDraft7(value as JsonSchema);
|
|
52
|
+
} else {
|
|
53
|
+
converted[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return converted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function emit(ir: IR, options?: JsonSchemaOptions): JsonSchema {
|
|
17
60
|
let schema: JsonSchema;
|
|
18
61
|
switch (ir.k) {
|
|
19
62
|
case "unknown":
|
|
20
|
-
case "undefined":
|
|
21
63
|
schema = {};
|
|
22
64
|
break;
|
|
65
|
+
case "undefined":
|
|
66
|
+
schema = fallback({}, options);
|
|
67
|
+
break;
|
|
23
68
|
case "null":
|
|
24
69
|
schema = { type: "null" };
|
|
25
70
|
break;
|
|
@@ -29,6 +74,12 @@ function emit(ir: IR): JsonSchema {
|
|
|
29
74
|
case "bigint":
|
|
30
75
|
schema = { type: "integer" };
|
|
31
76
|
break;
|
|
77
|
+
case "symbol":
|
|
78
|
+
schema = fallback({}, options);
|
|
79
|
+
break;
|
|
80
|
+
case "never":
|
|
81
|
+
schema = { not: {} };
|
|
82
|
+
break;
|
|
32
83
|
case "anyobject":
|
|
33
84
|
schema = { type: "object" };
|
|
34
85
|
break;
|
|
@@ -42,18 +93,55 @@ function emit(ir: IR): JsonSchema {
|
|
|
42
93
|
schema = emitLiteral(ir.v);
|
|
43
94
|
break;
|
|
44
95
|
case "union":
|
|
45
|
-
schema = emitUnion(ir.members);
|
|
96
|
+
schema = emitUnion(ir.members, options);
|
|
97
|
+
break;
|
|
98
|
+
case "intersection":
|
|
99
|
+
schema = { allOf: ir.members.map(member => emit(member, options)) };
|
|
46
100
|
break;
|
|
47
101
|
case "array":
|
|
48
|
-
schema = { type: "array", items: emit(ir.el) };
|
|
102
|
+
schema = { type: "array", items: emit(ir.el, options) };
|
|
49
103
|
if (ir.min !== undefined) schema.minItems = ir.min;
|
|
50
104
|
if (ir.max !== undefined) schema.maxItems = ir.max;
|
|
51
105
|
break;
|
|
106
|
+
case "tuple": {
|
|
107
|
+
const prefixItems = ir.prefix.map(item => {
|
|
108
|
+
const itemSchema = emit(item.val, options);
|
|
109
|
+
if (item.hasDefault) {
|
|
110
|
+
itemSchema.default = item.defFactory && typeof item.def === "function" ? item.def() : item.def;
|
|
111
|
+
}
|
|
112
|
+
return itemSchema;
|
|
113
|
+
});
|
|
114
|
+
const required = ir.prefix.reduce(
|
|
115
|
+
(count, item) => count + (item.opt || item.hasDefault ? 0 : 1),
|
|
116
|
+
ir.postfix.length,
|
|
117
|
+
);
|
|
118
|
+
schema = { type: "array", prefixItems, minItems: required };
|
|
119
|
+
if (ir.variadic === undefined) {
|
|
120
|
+
schema.maxItems = ir.prefix.length + ir.postfix.length;
|
|
121
|
+
schema.items = false;
|
|
122
|
+
} else {
|
|
123
|
+
schema.items = emit(ir.variadic, options);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
52
127
|
case "object":
|
|
53
|
-
schema = emitObject(ir.props, ir.index, ir.extras);
|
|
128
|
+
schema = emitObject(ir.props, ir.index, ir.extras, options);
|
|
129
|
+
break;
|
|
130
|
+
case "instance":
|
|
131
|
+
schema = ir.ctor === Date ? { type: "string", format: "date-time" } : fallback({ type: "object" }, options);
|
|
132
|
+
break;
|
|
133
|
+
case "refine":
|
|
134
|
+
schema = emit(ir.base, options);
|
|
135
|
+
if (ir.json !== undefined) Object.assign(schema, ir.json);
|
|
136
|
+
break;
|
|
137
|
+
case "morph":
|
|
138
|
+
schema = fallback(emit(ir.out ?? ir.input, options), options);
|
|
139
|
+
break;
|
|
140
|
+
case "alias":
|
|
141
|
+
schema = emit(ir.resolve(), options);
|
|
54
142
|
break;
|
|
55
143
|
case "sub":
|
|
56
|
-
schema = emit(ir.schema.ir);
|
|
144
|
+
schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, options), options) : emit(ir.schema.ir, options);
|
|
57
145
|
if (ir.schema.description !== undefined) schema.description = ir.schema.description;
|
|
58
146
|
break;
|
|
59
147
|
}
|
|
@@ -73,10 +161,12 @@ function emitNumber(ir: Extract<IR, { k: "number" }>): JsonSchema {
|
|
|
73
161
|
const schema: JsonSchema = { type: ir.int ? "integer" : "number" };
|
|
74
162
|
if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min;
|
|
75
163
|
if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max;
|
|
164
|
+
if (ir.divisor !== undefined) schema.multipleOf = ir.divisor;
|
|
76
165
|
return schema;
|
|
77
166
|
}
|
|
78
167
|
|
|
79
168
|
function emitLiteral(value: unknown): JsonSchema {
|
|
169
|
+
if (value instanceof Date) return { type: "string", format: "date-time", const: value.toISOString() };
|
|
80
170
|
if (isJsonValue(value)) return { const: value };
|
|
81
171
|
switch (typeof value) {
|
|
82
172
|
case "string":
|
|
@@ -94,10 +184,10 @@ function emitLiteral(value: unknown): JsonSchema {
|
|
|
94
184
|
}
|
|
95
185
|
}
|
|
96
186
|
|
|
97
|
-
function emitUnion(members: IR[]): JsonSchema {
|
|
187
|
+
function emitUnion(members: IR[], options?: JsonSchemaOptions): JsonSchema {
|
|
98
188
|
const defined = members.filter(member => member.k !== "undefined");
|
|
99
189
|
if (defined.length === 0) return {};
|
|
100
|
-
if (defined.length === 1) return emit(defined[0]);
|
|
190
|
+
if (defined.length === 1) return emit(defined[0], options);
|
|
101
191
|
if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
|
|
102
192
|
const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
|
|
103
193
|
const schema: JsonSchema = { enum: values };
|
|
@@ -105,7 +195,7 @@ function emitUnion(members: IR[]): JsonSchema {
|
|
|
105
195
|
if (scalarType !== undefined) schema.type = scalarType;
|
|
106
196
|
return schema;
|
|
107
197
|
}
|
|
108
|
-
return { anyOf: defined.map(emit) };
|
|
198
|
+
return { anyOf: defined.map(member => emit(member, options)) };
|
|
109
199
|
}
|
|
110
200
|
|
|
111
201
|
function homogeneousScalarType(values: unknown[]): string | undefined {
|
|
@@ -129,14 +219,19 @@ function jsonScalarType(value: unknown): string | undefined {
|
|
|
129
219
|
}
|
|
130
220
|
}
|
|
131
221
|
|
|
132
|
-
function emitObject(
|
|
222
|
+
function emitObject(
|
|
223
|
+
props: PropIR[],
|
|
224
|
+
index: IR | undefined,
|
|
225
|
+
extras: "keep" | "reject" | "delete",
|
|
226
|
+
options?: JsonSchemaOptions,
|
|
227
|
+
): JsonSchema {
|
|
133
228
|
const properties: Record<string, unknown> = {};
|
|
134
229
|
const required: string[] = [];
|
|
135
230
|
// ArkType emits required properties first (each group in declaration
|
|
136
231
|
// order); downstream wire consumers rely on that stable ordering.
|
|
137
232
|
const ordered = [...props.filter(p => !p.opt && !p.hasDefault), ...props.filter(p => p.opt || p.hasDefault)];
|
|
138
233
|
for (const prop of ordered) {
|
|
139
|
-
const propertySchema = emit(prop.val);
|
|
234
|
+
const propertySchema = emit(prop.val, options);
|
|
140
235
|
if (prop.hasDefault) {
|
|
141
236
|
propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
|
|
142
237
|
}
|
|
@@ -145,7 +240,7 @@ function emitObject(props: PropIR[], index: IR | undefined, extras: "keep" | "re
|
|
|
145
240
|
}
|
|
146
241
|
const schema: JsonSchema = { type: "object", properties };
|
|
147
242
|
if (required.length > 0) schema.required = required;
|
|
148
|
-
if (index !== undefined) schema.additionalProperties = emit(index);
|
|
243
|
+
if (index !== undefined) schema.additionalProperties = emit(index, options);
|
|
149
244
|
else if (extras === "reject") schema.additionalProperties = false;
|
|
150
245
|
return schema;
|
|
151
246
|
}
|
package/src/keywords.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { IR, MorphContext } from "./ir";
|
|
2
|
+
|
|
3
|
+
const NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\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
|
+
const keywordFactories: Record<string, () => IR> = {
|
|
110
|
+
string: () => ({ k: "string" }),
|
|
111
|
+
"string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
|
|
112
|
+
"string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
|
|
113
|
+
"string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),
|
|
114
|
+
"string.base64": () => pattern(/^(?:[\d+/A-Za-z]{4})*(?:[\d+/A-Za-z]{2}==|[\d+/A-Za-z]{3}=)?$/, "base64-encoded"),
|
|
115
|
+
"string.base64.url": () =>
|
|
116
|
+
pattern(/^(?:[\w-]{4})*(?:[\w-]{2}(?:==|%3D%3D)?|[\w-]{3}(?:=|%3D)?)?$/, "base64url-encoded"),
|
|
117
|
+
"string.capitalize": () => morph({ k: "string" }, value => value.charAt(0).toUpperCase() + value.slice(1)),
|
|
118
|
+
"string.capitalize.preformatted": () => pattern(/^[A-Z].*$/, "capitalized"),
|
|
119
|
+
"string.creditCard": () => ({
|
|
120
|
+
k: "refine",
|
|
121
|
+
base: pattern(
|
|
122
|
+
/^(?:4\d{12}(?:\d{3,6})?|5[1-5]\d{14}|3[47]\d{13}|6(?:011|5\d\d)\d{12,15})$/,
|
|
123
|
+
"a credit card number",
|
|
124
|
+
),
|
|
125
|
+
pred: value => isLuhnValid(value as string),
|
|
126
|
+
expected: "a credit card number",
|
|
127
|
+
}),
|
|
128
|
+
"string.date": () => parsableDate(),
|
|
129
|
+
"string.date.parse": () =>
|
|
130
|
+
morph(parsableDate(), value => new Date(value), { k: "instance", ctor: Date, expected: "a Date" }),
|
|
131
|
+
"string.date.iso": () => pattern(ISO_DATE, "an ISO 8601 date", "date-time"),
|
|
132
|
+
"string.date.iso.parse": () =>
|
|
133
|
+
morph(pattern(ISO_DATE, "an ISO 8601 date", "date-time"), value => new Date(value), {
|
|
134
|
+
k: "instance",
|
|
135
|
+
ctor: Date,
|
|
136
|
+
expected: "a Date",
|
|
137
|
+
}),
|
|
138
|
+
"string.date.epoch": () => pattern(INTEGER, "an integer string representing a Unix timestamp"),
|
|
139
|
+
"string.date.epoch.parse": () =>
|
|
140
|
+
morph(pattern(INTEGER, "an integer string representing a Unix timestamp"), value => new Date(Number(value)), {
|
|
141
|
+
k: "instance",
|
|
142
|
+
ctor: Date,
|
|
143
|
+
expected: "a Date",
|
|
144
|
+
}),
|
|
145
|
+
"string.digits": () => pattern(/^\d*$/, "only digits 0-9"),
|
|
146
|
+
"string.email": () => pattern(EMAIL, "an email address", "email"),
|
|
147
|
+
"string.integer": () => pattern(INTEGER, "a well-formed integer string"),
|
|
148
|
+
"string.integer.parse": () =>
|
|
149
|
+
morph(
|
|
150
|
+
pattern(INTEGER, "a well-formed integer string"),
|
|
151
|
+
(value, context) => {
|
|
152
|
+
const parsed = Number.parseInt(value, 10);
|
|
153
|
+
return Number.isSafeInteger(parsed) ? parsed : context.error("a safe integer string");
|
|
154
|
+
},
|
|
155
|
+
{ k: "number", int: true },
|
|
156
|
+
),
|
|
157
|
+
"string.ip": () => ({
|
|
158
|
+
k: "union",
|
|
159
|
+
members: [pattern(IPV4, "an IPv4 address", "ipv4"), pattern(IPV6, "an IPv6 address", "ipv6")],
|
|
160
|
+
}),
|
|
161
|
+
"string.ip.v4": () => pattern(IPV4, "an IPv4 address", "ipv4"),
|
|
162
|
+
"string.ip.v6": () => pattern(IPV6, "an IPv6 address", "ipv6"),
|
|
163
|
+
"string.json": () => parsableJson(),
|
|
164
|
+
"string.json.parse": () =>
|
|
165
|
+
morph(parsableJson(), (value, context) => {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(value);
|
|
168
|
+
} catch {
|
|
169
|
+
return context.error("a JSON string");
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
"string.lower": () => morph({ k: "string" }, value => value.toLowerCase()),
|
|
173
|
+
"string.lower.preformatted": () => pattern(/^[a-z]*$/, "only lowercase letters"),
|
|
174
|
+
"string.normalize": () => normalize("NFC"),
|
|
175
|
+
"string.normalize.NFC": () => normalize("NFC"),
|
|
176
|
+
"string.normalize.NFC.preformatted": () => patternForNormalized("NFC"),
|
|
177
|
+
"string.normalize.NFD": () => normalize("NFD"),
|
|
178
|
+
"string.normalize.NFD.preformatted": () => patternForNormalized("NFD"),
|
|
179
|
+
"string.normalize.NFKC": () => normalize("NFKC"),
|
|
180
|
+
"string.normalize.NFKC.preformatted": () => patternForNormalized("NFKC"),
|
|
181
|
+
"string.normalize.NFKD": () => normalize("NFKD"),
|
|
182
|
+
"string.normalize.NFKD.preformatted": () => patternForNormalized("NFKD"),
|
|
183
|
+
"string.numeric": () => pattern(NUMERIC, "a well-formed numeric string"),
|
|
184
|
+
"string.numeric.parse": () =>
|
|
185
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
186
|
+
"string.regex": () => ({
|
|
187
|
+
k: "refine",
|
|
188
|
+
base: { k: "string" },
|
|
189
|
+
pred: value => {
|
|
190
|
+
try {
|
|
191
|
+
new RegExp(value as string);
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
expected: "a regex pattern",
|
|
198
|
+
json: { format: "regex" },
|
|
199
|
+
}),
|
|
200
|
+
"string.semver": () => pattern(SEMVER, "a semantic version"),
|
|
201
|
+
"string.trim": () => morph({ k: "string" }, value => value.trim()),
|
|
202
|
+
"string.trim.preformatted": () => pattern(/^\S.*\S$|^\S?$/, "trimmed"),
|
|
203
|
+
"string.upper": () => morph({ k: "string" }, value => value.toUpperCase()),
|
|
204
|
+
"string.upper.preformatted": () => pattern(/^[A-Z]*$/, "only uppercase letters"),
|
|
205
|
+
"string.url": () => ({ k: "string", url: true }),
|
|
206
|
+
"string.url.parse": () =>
|
|
207
|
+
morph({ k: "string", url: true }, value => new URL(value), { k: "instance", ctor: URL, expected: "a URL" }),
|
|
208
|
+
"string.uuid": () => pattern(UUID, "a UUID", "uuid"),
|
|
209
|
+
"string.uuid.v1": () => uuidVersion("1"),
|
|
210
|
+
"string.uuid.v2": () => uuidVersion("2"),
|
|
211
|
+
"string.uuid.v3": () => uuidVersion("3"),
|
|
212
|
+
"string.uuid.v4": () => uuidVersion("4"),
|
|
213
|
+
"string.uuid.v5": () => uuidVersion("5"),
|
|
214
|
+
"string.uuid.v6": () => uuidVersion("6"),
|
|
215
|
+
"string.uuid.v7": () => uuidVersion("7"),
|
|
216
|
+
"string.uuid.v8": () => uuidVersion("8"),
|
|
217
|
+
"parse.number": () =>
|
|
218
|
+
morph(pattern(NUMERIC, "a well-formed numeric string"), value => Number.parseFloat(value), { k: "number" }),
|
|
219
|
+
"parse.integer": () =>
|
|
220
|
+
morph(pattern(INTEGER, "a well-formed integer string"), value => Number.parseInt(value, 10), {
|
|
221
|
+
k: "number",
|
|
222
|
+
int: true,
|
|
223
|
+
}),
|
|
224
|
+
"parse.json": () => keywordFactories["string.json.parse"](),
|
|
225
|
+
"parse.date": () => keywordFactories["string.date.parse"](),
|
|
226
|
+
"parse.url": () => keywordFactories["string.url.parse"](),
|
|
227
|
+
"parse.boolean": () =>
|
|
228
|
+
morph(pattern(/^(?:true|false)$/, "a boolean string"), value => value === "true", { k: "boolean" }),
|
|
229
|
+
"parse.bigint": () => morph(pattern(INTEGER, "an integer string"), value => BigInt(value), { k: "bigint" }),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Lower a built-in keyword into fresh validation IR. */
|
|
233
|
+
export function keywordIR(name: string): IR | undefined {
|
|
234
|
+
return keywordFactories[name]?.();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Lower a regular expression into a string refinement. */
|
|
238
|
+
export function patternIR(regex: RegExp): IR {
|
|
239
|
+
return pattern(regex, `a string matching ${regex}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Lower an ArkType-style template literal into a string pattern. */
|
|
243
|
+
export function templateIR(source: string): IR {
|
|
244
|
+
let patternSource = "^";
|
|
245
|
+
let index = 0;
|
|
246
|
+
const placeholder = /\$\{(string|number|bigint|boolean)\}/g;
|
|
247
|
+
for (let match = placeholder.exec(source); match; match = placeholder.exec(source)) {
|
|
248
|
+
patternSource += escapeRegex(source.slice(index, match.index));
|
|
249
|
+
switch (match[1]) {
|
|
250
|
+
case "number":
|
|
251
|
+
patternSource += "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)";
|
|
252
|
+
break;
|
|
253
|
+
case "bigint":
|
|
254
|
+
patternSource += "[+-]?\\d+";
|
|
255
|
+
break;
|
|
256
|
+
case "boolean":
|
|
257
|
+
patternSource += "(?:true|false)";
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
patternSource += ".*";
|
|
261
|
+
}
|
|
262
|
+
index = match.index + match[0].length;
|
|
263
|
+
}
|
|
264
|
+
patternSource += `${escapeRegex(source.slice(index))}$`;
|
|
265
|
+
return pattern(new RegExp(patternSource), `a string matching \`${source}\``);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function escapeRegex(value: string): string {
|
|
269
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
270
|
+
}
|