@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.
@@ -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 draft-2020-12 JSON Schema represented by an IR tree. */
12
+ /** Emit the requested JSON Schema dialect represented by an IR tree. */
10
13
  export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
11
- const schema = emit(ir);
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 emit(ir: IR): JsonSchema {
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(props: PropIR[], index: IR | undefined, extras: "keep" | "reject" | "delete"): JsonSchema {
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
  }
@@ -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
+ }