@json-schema-engine/formats 0.0.1

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 ADDED
@@ -0,0 +1,799 @@
1
+ // @json-schema-engine/formats: format implementations for the format-assertion vocabulary
2
+ // and the assertFormats configuration (M7). Implemented from the defining
3
+ // RFCs and the JSON Schema specs only (DESIGN.md D15) — no format/IDNA
4
+ // library is consulted.
5
+ //
6
+ // Every definition is type-scoped through FormatDefinition.types (default
7
+ // ["string"]): the KEYWORD treats instances outside a format's types as
8
+ // vacuously valid, per spec. The shape deliberately admits non-string
9
+ // formats (the OpenAPI format registry's number-scoped entries are a
10
+ // planned future table).
11
+ import { isValidALabel } from "./idna.js";
12
+ import { idnEmail, idnHostname } from "./idn.js";
13
+ export { idnEmail, idnHostname } from "./idn.js";
14
+ // EXEMPLAR (trivial tier): a fixed grammar — one anchored regex, no
15
+ // structural interplay. RFC 4122 §3: 8-4-4-4-12 hex digits, any version.
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ export const uuid = {
18
+ test: (value) => typeof value === "string" && UUID_RE.test(value),
19
+ };
20
+ // EXEMPLAR (parser tier): grammar with structural rules a single regex
21
+ // obscures. RFC 4291 §2.2: up to eight 16-bit hex fields; `::` compresses
22
+ // exactly one run (and must compress at least one field when 8 are already
23
+ // present); an embedded dotted-quad IPv4 tail counts as two fields.
24
+ const H16 = /^[0-9a-f]{1,4}$/i;
25
+ const IPV4_TAIL = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}$/;
26
+ function isIpv6(value) {
27
+ // A zone index or other non [0-9a-f:.] characters are out of grammar.
28
+ if (!/^[0-9a-f:.]+$/i.test(value))
29
+ return false;
30
+ const compressions = value.split("::").length - 1;
31
+ if (compressions > 1)
32
+ return false;
33
+ let fields = 0;
34
+ const sides = value.split("::");
35
+ for (let sideIndex = 0; sideIndex < sides.length; sideIndex++) {
36
+ const side = sides[sideIndex];
37
+ if (side === "")
38
+ continue; // "::" edge (leading/trailing)
39
+ const parts = side.split(":");
40
+ for (let i = 0; i < parts.length; i++) {
41
+ const part = parts[i];
42
+ if (part === "")
43
+ return false; // ":::", leading/trailing single ":"
44
+ const isLast = sideIndex === sides.length - 1 && i === parts.length - 1;
45
+ if (isLast && part.includes(".")) {
46
+ if (!IPV4_TAIL.test(part))
47
+ return false;
48
+ fields += 2;
49
+ continue;
50
+ }
51
+ if (!H16.test(part))
52
+ return false;
53
+ fields++;
54
+ }
55
+ }
56
+ if (compressions === 1)
57
+ return fields < 8; // "::" must stand for ≥1 field
58
+ return fields === 8;
59
+ }
60
+ export const ipv6 = {
61
+ test: (value) => typeof value === "string" && isIpv6(value),
62
+ };
63
+ // RFC 3339 §5.6 full-date = date-fullyear "-" date-month "-" date-mday.
64
+ // The grammar caps date-mday at 01-31 syntactically, but real month/leap-year
65
+ // lengths are a semantic check the suite exercises (e.g. 2021-02-29 invalid,
66
+ // 2020-02-29 valid) — the ABNF alone under-constrains this format.
67
+ const FULL_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
68
+ function isLeapYear(year) {
69
+ // Gregorian leap rule: divisible by 4, except centuries, except
70
+ // centuries divisible by 400 (RFC 3339 appendix C examples: 0100 and
71
+ // 2100 are not leap; 0400 is).
72
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
73
+ }
74
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
75
+ function isFullDate(value) {
76
+ const m = FULL_DATE_RE.exec(value);
77
+ if (m === null)
78
+ return false;
79
+ const year = Number(m[1]);
80
+ const month = Number(m[2]);
81
+ const day = Number(m[3]);
82
+ if (month < 1 || month > 12)
83
+ return false;
84
+ const maxDay = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
85
+ return day >= 1 && day <= maxDay;
86
+ }
87
+ export const date = {
88
+ test: (value) => typeof value === "string" && isFullDate(value),
89
+ };
90
+ // RFC 3339 appendix A duration ABNF:
91
+ // dur-second = 1*DIGIT "S"
92
+ // dur-minute = 1*DIGIT "M" [dur-second]
93
+ // dur-hour = 1*DIGIT "H" [dur-minute]
94
+ // dur-time = "T" (dur-hour / dur-minute / dur-second)
95
+ // dur-day = 1*DIGIT "D"
96
+ // dur-month = 1*DIGIT "M" [dur-day]
97
+ // dur-year = 1*DIGIT "Y" [dur-month]
98
+ // dur-week = 1*DIGIT "W"
99
+ // dur-date = (dur-day / dur-month / dur-year) [dur-time]
100
+ // duration = "P" (dur-date / dur-time / dur-week)
101
+ // The date/time components are each optional but strictly ordered
102
+ // (year-month-day, then hour-minute-second) when present, and the
103
+ // grammar's optional-suffix nesting means at least one numeric component
104
+ // must actually appear after "P" (or after "T"): "P" and "PT" alone don't
105
+ // reduce to any of the three duration alternatives. Weeks are a separate
106
+ // top-level alternative and cannot combine with the other units.
107
+ // Each alternative's trailing unit is truly optional only by nesting
108
+ // inside the *preceding* unit's suffix (dur-year's [dur-month] contains
109
+ // dur-month's own [dur-day]) — so "D" without a preceding "M" is valid
110
+ // only when there's no "Y" either (dur-day standing alone), never as
111
+ // "Y...D" skipping "M". Mirroring that nesting directly (rather than three
112
+ // independently-optional groups) is what makes P1Y2D correctly rejected.
113
+ const DUR_DATE = /^\d+D$|^\d+M(\d+D)?$|^\d+Y(\d+M(\d+D)?)?$/;
114
+ const DUR_TIME = /^\d+S$|^\d+M(\d+S)?$|^\d+H(\d+M(\d+S)?)?$/;
115
+ function isDuration(value) {
116
+ if (!value.startsWith("P"))
117
+ return false;
118
+ const body = value.slice(1);
119
+ if (body === "")
120
+ return false;
121
+ if (/^\d+W$/.test(body))
122
+ return true; // dur-week, no combining
123
+ const tIndex = body.indexOf("T");
124
+ const datePart = tIndex === -1 ? body : body.slice(0, tIndex);
125
+ const timePart = tIndex === -1 ? "" : body.slice(tIndex + 1);
126
+ if (tIndex !== -1 && timePart === "")
127
+ return false; // "PT" with nothing after
128
+ if (datePart === "" && timePart === "")
129
+ return false; // "P" alone
130
+ if (datePart !== "" && !DUR_DATE.test(datePart))
131
+ return false;
132
+ if (timePart !== "" && !DUR_TIME.test(timePart))
133
+ return false;
134
+ return true;
135
+ }
136
+ export const duration = {
137
+ test: (value) => typeof value === "string" && isDuration(value),
138
+ };
139
+ // RFC 1123 §2.1 relaxes RFC 952's "must start with a letter" to allow
140
+ // leading digits, but the label/hyphen/length rules are unchanged: each
141
+ // label is 1-63 alphanumerics-or-hyphens, no leading/trailing hyphen, and
142
+ // the assembled name is at most 253 octets (excluding a trailing root dot,
143
+ // which this format doesn't accept anyway per the suite).
144
+ const HOSTNAME_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
145
+ function isHostname(value) {
146
+ if (value.length === 0 || value.length > 253)
147
+ return false;
148
+ const labels = value.split(".");
149
+ return labels.every((label) => {
150
+ if (!HOSTNAME_LABEL.test(label))
151
+ return false;
152
+ return /^xn--/i.test(label) ? isValidALabel(label) : true;
153
+ });
154
+ }
155
+ export const hostname = {
156
+ test: (value) => typeof value === "string" && isHostname(value),
157
+ };
158
+ // RFC 2673 §3.2 dotted-quad: decbyte "." decbyte "." decbyte "." decbyte
159
+ // where decbyte is 1*3DIGIT syntactically, but JSON Schema's `ipv4` format
160
+ // (per the suite) also forbids leading zeros — "087" reads as octal in
161
+ // some parsers, so each octet must be "0" or a non-zero digit run.
162
+ const IPV4_OCTET = "(0|[1-9][0-9]?|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
163
+ const IPV4_RE = new RegExp(`^${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}$`);
164
+ export const ipv4 = {
165
+ test: (value) => typeof value === "string" && IPV4_RE.test(value),
166
+ };
167
+ // RFC 6901 §3: a JSON Pointer is either empty or a sequence of "/"-prefixed
168
+ // reference tokens; "~" is only valid as part of the "~0" (~) or "~1" (/)
169
+ // escape sequences.
170
+ const JSON_POINTER_RE = /^(\/([^~/]|~[01])*)*$/;
171
+ export const jsonPointer = {
172
+ test: (value) => typeof value === "string" && JSON_POINTER_RE.test(value),
173
+ };
174
+ // RFC 6901 relative-json-pointer draft: a non-negative integer (no leading
175
+ // zeros, since a leading zero could only be the single digit "0" itself)
176
+ // followed by either a json-pointer or the "#" index/key marker.
177
+ const RELATIVE_JSON_POINTER_RE = /^(0|[1-9][0-9]*)(#|(\/([^~/]|~[01])*)*)$/;
178
+ export const relativeJsonPointer = {
179
+ test: (value) => typeof value === "string" && RELATIVE_JSON_POINTER_RE.test(value),
180
+ };
181
+ // The `regex` format asserts ECMA-262 pattern validity, and the runtime
182
+ // itself is an ECMA-262 engine — `new RegExp` succeeding/throwing IS the
183
+ // authoritative check, not a stand-in for one. Plain (non-`u`) mode is
184
+ // sufficient here: the suite's only invalid case is unclosed parens
185
+ // (rejected in any mode), so there is no case forcing the `u`-mode retry
186
+ // core's `schemaRegExp` uses for `\p{...}` property escapes.
187
+ function isRegex(value) {
188
+ try {
189
+ new RegExp(value);
190
+ return true;
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
196
+ export const regex = {
197
+ test: (value) => typeof value === "string" && isRegex(value),
198
+ };
199
+ // RFC 3986 §3 URI grammar, generalized for §2 percent-encoding and the
200
+ // unreserved/sub-delims/pchar character classes. Implemented as a small
201
+ // hand-rolled parser (rather than one grammar-sized regex) because the
202
+ // production nesting (authority host-forms, path-forms keyed on whether a
203
+ // scheme/authority preceded them) is easier to get right — and to keep
204
+ // correct under later IRI generalization (D-block below) — as explicit
205
+ // steps than as regex alternation.
206
+ //
207
+ // pct-encoded = "%" HEXDIG HEXDIG
208
+ const PCT_ENCODED = /^%[0-9a-fA-F]{2}/;
209
+ // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
210
+ const UNRESERVED = /[A-Za-z0-9\-._~]/;
211
+ // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
212
+ const SUB_DELIMS = /[!$&'()*+,;=]/;
213
+ /**
214
+ * Consumes the longest prefix of `s` matching the repeated class
215
+ * "unreserved / pct-encoded / sub-delims / extra" (a generalization
216
+ * covering pchar when extra is the two characters colon and at-sign,
217
+ * userinfo when extra is just colon, and reg-name/query/fragment
218
+ * variants) plus — via `extraChar`, an additional single-codepoint
219
+ * predicate — the IRI ucschar/iprivate extension. Returns the number of
220
+ * consumed UTF-16 code units, or -1 on hitting a character/escape that
221
+ * doesn't fit.
222
+ */
223
+ function consumePctEncodedClass(s, extra, extraChar) {
224
+ let i = 0;
225
+ while (i < s.length) {
226
+ const rest = s.slice(i);
227
+ const pct = PCT_ENCODED.exec(rest);
228
+ if (pct !== null) {
229
+ i += 3;
230
+ continue;
231
+ }
232
+ const ch = s[i];
233
+ if (UNRESERVED.test(ch) || SUB_DELIMS.test(ch) || extra.includes(ch)) {
234
+ i += 1;
235
+ continue;
236
+ }
237
+ if (extraChar !== undefined) {
238
+ const cp = s.codePointAt(i);
239
+ if (extraChar(cp)) {
240
+ i += cp > 0xffff ? 2 : 1;
241
+ continue;
242
+ }
243
+ }
244
+ return i === 0 ? -1 : i;
245
+ }
246
+ return i;
247
+ }
248
+ /** True if the whole string is consumed by {@link consumePctEncodedClass}. */
249
+ function isWholeClass(s, extra, extraChar) {
250
+ if (s.length === 0)
251
+ return true;
252
+ const n = consumePctEncodedClass(s, extra, extraChar);
253
+ return n === s.length;
254
+ }
255
+ // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
256
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
257
+ /**
258
+ * Splits into scheme/authority/path/query/fragment without validating
259
+ * component contents (RFC 3986 Appendix B's parsing regex, restated as
260
+ * explicit scanning). Returns undefined only for the impossible case of
261
+ * a "://" with no scheme delimiter reachable — in practice this always
262
+ * succeeds structurally; content validation happens in the caller.
263
+ */
264
+ function splitUriReference(value) {
265
+ let rest = value;
266
+ let fragment;
267
+ const hashIndex = rest.indexOf("#");
268
+ if (hashIndex !== -1) {
269
+ fragment = rest.slice(hashIndex + 1);
270
+ rest = rest.slice(0, hashIndex);
271
+ }
272
+ let query;
273
+ const qIndex = rest.indexOf("?");
274
+ if (qIndex !== -1) {
275
+ query = rest.slice(qIndex + 1);
276
+ rest = rest.slice(0, qIndex);
277
+ }
278
+ // A scheme is present only when a ":" appears before any "/", "?", or
279
+ // "#" and what precedes it matches the scheme grammar (this also keeps
280
+ // "a:b" — a scheme — distinct from a relative path segment containing
281
+ // ":", which RFC 3986 §3.3 forbids in the first segment of a
282
+ // scheme-less relative-ref).
283
+ let scheme;
284
+ const colonIndex = rest.indexOf(":");
285
+ if (colonIndex > 0) {
286
+ const candidate = rest.slice(0, colonIndex);
287
+ if (SCHEME_RE.test(candidate)) {
288
+ scheme = candidate;
289
+ rest = rest.slice(colonIndex + 1);
290
+ }
291
+ }
292
+ let authority;
293
+ if (rest.startsWith("//")) {
294
+ rest = rest.slice(2);
295
+ const slashIndex = rest.search(/[/]/);
296
+ if (slashIndex === -1) {
297
+ authority = rest;
298
+ rest = "";
299
+ }
300
+ else {
301
+ authority = rest.slice(0, slashIndex);
302
+ rest = rest.slice(slashIndex);
303
+ }
304
+ }
305
+ return { scheme, authority, path: rest, query, fragment };
306
+ }
307
+ /**
308
+ * IPv4address for the host production (RFC 3986 §3.2.2), distinct from
309
+ * the standalone `ipv4` format: a URI host that fails this still parses
310
+ * as a valid `reg-name` (unreserved/pct-encoded/sub-delims), which is why
311
+ * "http://999.999.999.999/" and "http://087.10.0.1/" are structurally
312
+ * valid URIs per the suite even though neither is a valid `ipv4`.
313
+ */
314
+ const URI_IPV4_RE = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}$/;
315
+ /**
316
+ * IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) — an
317
+ * escape hatch for host address formats beyond IPv4/IPv6 that the suite
318
+ * doesn't exercise but the grammar admits.
319
+ */
320
+ const IPVFUTURE_RE = /^[vV][0-9a-fA-F]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+$/;
321
+ function isValidHost(host, regNameExtraChar) {
322
+ if (host.startsWith("[") && host.endsWith("]")) {
323
+ const inner = host.slice(1, -1);
324
+ return isIpv6(inner) || IPVFUTURE_RE.test(inner);
325
+ }
326
+ // host = IP-literal / IPv4address / reg-name — IPv4address is tried
327
+ // first only in the sense that reg-name is the universal fallback;
328
+ // either way an IPv4-shaped reg-name is accepted (see URI_IPV4_RE doc).
329
+ if (URI_IPV4_RE.test(host))
330
+ return true;
331
+ return isWholeClass(host, "", regNameExtraChar);
332
+ }
333
+ function isValidAuthority(authority, extraChar) {
334
+ let rest = authority;
335
+ const at = rest.lastIndexOf("@");
336
+ if (at !== -1) {
337
+ const userinfo = rest.slice(0, at);
338
+ if (!isWholeClass(userinfo, ":", extraChar))
339
+ return false;
340
+ rest = rest.slice(at + 1);
341
+ }
342
+ // host may be an IP-literal ("[...]"), which can itself contain ":" —
343
+ // so the port separator must be sought outside any bracketed literal.
344
+ let host = rest;
345
+ let port;
346
+ if (rest.startsWith("[")) {
347
+ const close = rest.indexOf("]");
348
+ if (close === -1)
349
+ return false;
350
+ host = rest.slice(0, close + 1);
351
+ const afterBracket = rest.slice(close + 1);
352
+ if (afterBracket.startsWith(":"))
353
+ port = afterBracket.slice(1);
354
+ else if (afterBracket !== "")
355
+ return false;
356
+ }
357
+ else {
358
+ const colon = rest.indexOf(":");
359
+ if (colon !== -1) {
360
+ host = rest.slice(0, colon);
361
+ port = rest.slice(colon + 1);
362
+ }
363
+ }
364
+ if (port !== undefined && !/^[0-9]*$/.test(port))
365
+ return false;
366
+ return isValidHost(host, extraChar);
367
+ }
368
+ /**
369
+ * Validates the parsed components of a URI/URI-reference (or, via
370
+ * `extraChar`, an IRI/IRI-reference). `requireScheme` distinguishes `uri`
371
+ * (must be absolute, RFC 3986 §4.3) from `uri-reference` (§4.1, scheme
372
+ * optional).
373
+ */
374
+ function isValidUriReference(value, requireScheme, extraChar) {
375
+ const { scheme, authority, path, query, fragment } = splitUriReference(value);
376
+ if (requireScheme && scheme === undefined)
377
+ return false;
378
+ if (authority !== undefined && !isValidAuthority(authority, extraChar)) {
379
+ return false;
380
+ }
381
+ // path-noscheme's first segment forbids ":" (already enforced by
382
+ // splitUriReference treating a pre-"/" colon as a scheme delimiter
383
+ // whenever it matches scheme grammar); pchar covers the rest.
384
+ if (!isWholeClass(path, "/:@", extraChar))
385
+ return false;
386
+ if (query !== undefined && !isWholeClass(query, "/:@?", extraChar)) {
387
+ return false;
388
+ }
389
+ if (fragment !== undefined && !isWholeClass(fragment, "/:@?", extraChar)) {
390
+ return false;
391
+ }
392
+ return true;
393
+ }
394
+ export const uri = {
395
+ test: (value) => typeof value === "string" && isValidUriReference(value, true),
396
+ };
397
+ export const uriReference = {
398
+ test: (value) => typeof value === "string" && isValidUriReference(value, false),
399
+ };
400
+ // RFC 6570 §2 URI Template grammar:
401
+ // URI-Template = *( literal / expression )
402
+ // expression = "{" [ operator ] variable-list "}"
403
+ // operator = op-level2 / op-level3 / op-reserve
404
+ // op-level2 = "+" / "#"
405
+ // op-level3 = "." / "/" / ";" / "?" / "&"
406
+ // op-reserve = "=" / "," / "!" / "@" / "|"
407
+ // variable-list = varspec *( "," varspec )
408
+ // varspec = varname [ modifier-level4 ]
409
+ // varname = varchar *( ["."] varchar )
410
+ // varchar = ALPHA / DIGIT / "_" / pct-encoded
411
+ // modifier-level4 = prefix / explode
412
+ // prefix = ":" max-length ; max-length = %x31-39 0*3DIGIT
413
+ // explode = "*"
414
+ // `literal` is any character except operator/expression delimiters,
415
+ // backslash, quotes, and control/space; unlike plain URI references it
416
+ // admits raw ucschar/iprivate (this format doesn't distinguish an ASCII
417
+ // vs. internationalized tier the way uri/iri do).
418
+ const VARCHAR = /[A-Za-z0-9_]/;
419
+ function isValidVarname(varname) {
420
+ if (varname === "")
421
+ return false;
422
+ const parts = varname.split(".");
423
+ // "." only ever separates two varchar runs — a leading/trailing/doubled
424
+ // "." would produce an empty part here.
425
+ return parts.every((part) => {
426
+ if (part === "")
427
+ return false;
428
+ let i = 0;
429
+ while (i < part.length) {
430
+ if (VARCHAR.test(part[i])) {
431
+ i += 1;
432
+ continue;
433
+ }
434
+ const pct = PCT_ENCODED.exec(part.slice(i));
435
+ if (pct === null)
436
+ return false;
437
+ i += 3;
438
+ }
439
+ return true;
440
+ });
441
+ }
442
+ function isValidVarspec(varspec) {
443
+ const prefixMatch = /^(.*):([1-9][0-9]{0,3})$/.exec(varspec);
444
+ if (prefixMatch !== null)
445
+ return isValidVarname(prefixMatch[1]);
446
+ if (varspec.endsWith("*"))
447
+ return isValidVarname(varspec.slice(0, -1));
448
+ return isValidVarname(varspec);
449
+ }
450
+ const OPERATORS = new Set([
451
+ "+",
452
+ "#",
453
+ ".",
454
+ "/",
455
+ ";",
456
+ "?",
457
+ "&",
458
+ "=",
459
+ ",",
460
+ "!",
461
+ "@",
462
+ "|",
463
+ ]);
464
+ function isValidExpression(inner) {
465
+ if (inner === "")
466
+ return false;
467
+ const body = OPERATORS.has(inner[0]) ? inner.slice(1) : inner;
468
+ if (body === "")
469
+ return false;
470
+ return body.split(",").every(isValidVarspec);
471
+ }
472
+ // literal excludes: SP DQUOTE "'" "%" (unless a valid pct-encoded) "<" ">"
473
+ // "\" "^" "`" "{" "|" "}" and C0/DEL control characters (RFC 6570 §2.1's
474
+ // ucschar/iprivate carve-outs are additive on top of this base set — the
475
+ // IRI-facing `extraChar` predicate below covers those, same shared-helper
476
+ // shape as the URI/IRI generalization above).
477
+ // The control-character range is spelled with explicit hex escapes (rather
478
+ // than source control bytes) so the pattern stays legible in an editor.
479
+ // eslint-disable-next-line no-control-regex -- RFC 6570's excluded set is C0/DEL by definition
480
+ const LITERAL_EXCLUDED = /["'%<>\\^`{|}\x00-\x1f\x7f ]/;
481
+ function isValidLiteralRun(s, extraChar) {
482
+ let i = 0;
483
+ while (i < s.length) {
484
+ const ch = s[i];
485
+ if (ch === "%") {
486
+ const pct = PCT_ENCODED.exec(s.slice(i));
487
+ if (pct === null)
488
+ return false;
489
+ i += 3;
490
+ continue;
491
+ }
492
+ if (!LITERAL_EXCLUDED.test(ch)) {
493
+ i += 1;
494
+ continue;
495
+ }
496
+ if (extraChar !== undefined) {
497
+ const cp = s.codePointAt(i);
498
+ if (extraChar(cp)) {
499
+ i += cp > 0xffff ? 2 : 1;
500
+ continue;
501
+ }
502
+ }
503
+ return false;
504
+ }
505
+ return true;
506
+ }
507
+ function isUriTemplate(value, extraChar) {
508
+ let i = 0;
509
+ while (i < value.length) {
510
+ const open = value.indexOf("{", i);
511
+ if (open === -1)
512
+ return isValidLiteralRun(value.slice(i), extraChar);
513
+ if (!isValidLiteralRun(value.slice(i, open), extraChar))
514
+ return false;
515
+ const close = value.indexOf("}", open);
516
+ if (close === -1)
517
+ return false; // unclosed brace
518
+ if (!isValidExpression(value.slice(open + 1, close)))
519
+ return false;
520
+ i = close + 1;
521
+ }
522
+ return true;
523
+ }
524
+ export const uriTemplate = {
525
+ test: (value) => typeof value === "string" && isUriTemplate(value),
526
+ };
527
+ // RFC 5321 §4.1.2 Mailbox grammar:
528
+ // Mailbox = Local-part "@" ( Domain / address-literal )
529
+ // Local-part = Dot-string / Quoted-string
530
+ // Dot-string = Atom *("." Atom)
531
+ // Atom = 1*atext
532
+ // atext = ALPHA / DIGIT / "!" / "#" / "$" / "%" / "&" / "'" / "*"
533
+ // / "+" / "-" / "/" / "=" / "?" / "^" / "_" / "`" / "{"
534
+ // / "|" / "}" / "~"
535
+ // Quoted-string = DQUOTE *QcontentSMTP DQUOTE
536
+ // QcontentSMTP = qtextSMTP / quoted-pairSMTP
537
+ // qtextSMTP = %d32-33 / %d35-91 / %d93-126 ; printable US-ASCII, no
538
+ // ; unescaped '"' or '\'
539
+ // quoted-pairSMTP = %d92 %d32-126
540
+ // Local-part's Dot-string disallows leading/trailing/doubled "." because
541
+ // each Atom is 1*atext with no embedded ".": the dots are pure separators
542
+ // between non-empty atoms, so an empty atom (adjacent dots or a dot at
543
+ // either end) is a structural rejection, not a special case.
544
+ const ATEXT = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+$/;
545
+ function isDotString(localPart) {
546
+ if (localPart === "")
547
+ return false;
548
+ return localPart.split(".").every((atom) => ATEXT.test(atom));
549
+ }
550
+ function isQuotedString(localPart) {
551
+ if (localPart.length < 2)
552
+ return false;
553
+ if (!localPart.startsWith('"') || !localPart.endsWith('"')) {
554
+ return false;
555
+ }
556
+ const inner = localPart.slice(1, -1);
557
+ let i = 0;
558
+ while (i < inner.length) {
559
+ const code = inner.codePointAt(i);
560
+ if (code === 0x5c) {
561
+ // quoted-pairSMTP: "\" followed by any printable ASCII (32-126).
562
+ const next = inner.codePointAt(i + 1);
563
+ if (next === undefined || next < 32 || next > 126)
564
+ return false;
565
+ i += 2;
566
+ continue;
567
+ }
568
+ // qtextSMTP: printable ASCII except '"' (34) and '\' (92).
569
+ if (code < 32 || code > 126 || code === 0x22)
570
+ return false;
571
+ i += 1;
572
+ }
573
+ return true;
574
+ }
575
+ /**
576
+ * RFC 5321 §4.1.3 address-literal contents (without the surrounding
577
+ * "[" "]", already stripped by the caller): IPv4-address-literal is the
578
+ * plain `ipv4` grammar; IPv6-address-literal is "IPv6:" plus the `ipv6`
579
+ * grammar. General-address-literal (other tags) isn't exercised by the
580
+ * suite and is intentionally not accepted here.
581
+ */
582
+ function isAddressLiteral(inner) {
583
+ if (inner.startsWith("IPv6:"))
584
+ return isIpv6(inner.slice(5));
585
+ return IPV4_RE.test(inner);
586
+ }
587
+ function isValidEmailDomain(domain) {
588
+ if (domain.startsWith("[") && domain.endsWith("]")) {
589
+ return isAddressLiteral(domain.slice(1, -1));
590
+ }
591
+ return isHostname(domain);
592
+ }
593
+ function isEmail(value) {
594
+ // The local part ends at the last unquoted "@"; a quoted local part can
595
+ // itself contain "@" (RFC 5321's qtextSMTP admits it), so splitting on
596
+ // the first "@" would wrongly truncate `"joe@bloggs"@example.com`.
597
+ let localPart;
598
+ let domain;
599
+ if (value.startsWith('"')) {
600
+ // Find the closing quote, respecting quoted-pairSMTP escapes, then
601
+ // the domain is whatever follows "@" immediately after it.
602
+ let i = 1;
603
+ while (i < value.length && value[i] !== '"') {
604
+ i += value[i] === "\\" ? 2 : 1;
605
+ }
606
+ if (i >= value.length || value[i + 1] !== "@")
607
+ return false;
608
+ localPart = value.slice(0, i + 1);
609
+ domain = value.slice(i + 2);
610
+ }
611
+ else {
612
+ const at = value.lastIndexOf("@");
613
+ if (at === -1)
614
+ return false;
615
+ localPart = value.slice(0, at);
616
+ domain = value.slice(at + 1);
617
+ }
618
+ if (domain === "")
619
+ return false;
620
+ const validLocal = localPart.startsWith('"')
621
+ ? isQuotedString(localPart)
622
+ : isDotString(localPart);
623
+ return validLocal && isValidEmailDomain(domain);
624
+ }
625
+ export const email = {
626
+ test: (value) => typeof value === "string" && isEmail(value),
627
+ };
628
+ // RFC 3987 §2.2 IRI grammar: RFC 3986's URI grammar with `ucschar` and
629
+ // `iprivate` admitted alongside ASCII wherever `unreserved` is admitted in
630
+ // URI (host reg-name, userinfo, pchar-derived path/query/fragment) — query
631
+ // additionally admits `iprivate`. `ucschar` is (in RFC 3987's block-listed
632
+ // form) most of the non-ASCII Unicode range excluding surrogates,
633
+ // noncharacters, and a handful of reserved blocks; JS Unicode regex
634
+ // classes make the surrogate/noncharacter exclusions the practical way to
635
+ // state it without hand-copying RFC 3987's forty-odd range triples.
636
+ const IPRIVATE_RANGES = [
637
+ [0xe000, 0xf8ff],
638
+ [0xf0000, 0xffffd],
639
+ [0x100000, 0x10fffd],
640
+ ];
641
+ function isIprivate(cp) {
642
+ return IPRIVATE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
643
+ }
644
+ // ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF / %x10000-1FFFD / ...
645
+ // (one contiguous supplementary-plane range per plane through 10). Encoded
646
+ // as explicit ranges (rather than trying to express "surrogates and
647
+ // noncharacters excluded" as a single predicate) so it matches the RFC
648
+ // text directly and stays auditable against it.
649
+ const UCSCHAR_BMP_RANGES = [
650
+ [0xa0, 0xd7ff],
651
+ [0xf900, 0xfdcf],
652
+ [0xfdf0, 0xfffd],
653
+ ];
654
+ function isUcschar(cp) {
655
+ if (UCSCHAR_BMP_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) {
656
+ return true;
657
+ }
658
+ if (cp < 0x10000)
659
+ return false;
660
+ // Supplementary planes 1-14 (0x10000-0xDFFFD), each plane's last two
661
+ // code points (...FFFE/...FFFF) are noncharacters and excluded, mirroring
662
+ // the BMP exclusion of FFFE/FFFF via the 0xFFEF upper bound above.
663
+ const plane = cp >>> 16;
664
+ if (plane < 1 || plane > 14)
665
+ return false;
666
+ const inPlane = cp & 0xffff;
667
+ return inPlane <= 0xfffd;
668
+ }
669
+ const isIriExtraChar = (cp) => isUcschar(cp) || isIprivate(cp);
670
+ function isValidIriReference(value, requireScheme) {
671
+ return isValidUriReference(value, requireScheme, isIriExtraChar);
672
+ }
673
+ export const iri = {
674
+ test: (value) => typeof value === "string" && isValidIriReference(value, true),
675
+ };
676
+ export const iriReference = {
677
+ test: (value) => typeof value === "string" && isValidIriReference(value, false),
678
+ };
679
+ // RFC 3339 §5.6:
680
+ // full-date = date-fullyear "-" date-month "-" date-mday
681
+ // full-time = partial-time time-offset
682
+ // partial-time = time-hour ":" time-minute ":" time-second [time-secfrac]
683
+ // time-offset = "Z" / time-numoffset
684
+ // time-numoffset = ("+" / "-") time-hour ":" time-minute
685
+ // date-time = full-date "T" full-time
686
+ // time-second's grammar allows "60" for a positive leap second, but RFC
687
+ // 3339 §5.7 restricts *actual* leap seconds to the UTC instant 23:59:60 —
688
+ // so a local time with a ":60" second is only valid when the offset shift
689
+ // back to UTC lands exactly on 23:59. "T"/"Z" are case-insensitive per
690
+ // §5.6's ABNF note.
691
+ const PARTIAL_TIME_RE = /^([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$/;
692
+ function isValidTimeOffset(hour, minute, offset) {
693
+ if (offset === "Z" || offset === "z") {
694
+ return { ok: true, isLeapCandidate: true, utcMinutes: hour * 60 + minute };
695
+ }
696
+ const sign = offset.startsWith("+") ? 1 : -1;
697
+ const offHour = Number(offset.slice(1, 3));
698
+ const offMinute = Number(offset.slice(4, 6));
699
+ if (offHour > 23 || offMinute > 59) {
700
+ return { ok: false, isLeapCandidate: false, utcMinutes: 0 };
701
+ }
702
+ const localMinutes = hour * 60 + minute;
703
+ const utcMinutes = (((localMinutes - sign * (offHour * 60 + offMinute)) % 1440) + 1440) % 1440;
704
+ return { ok: true, isLeapCandidate: true, utcMinutes };
705
+ }
706
+ function isValidPartialTime(value) {
707
+ const m = PARTIAL_TIME_RE.exec(value);
708
+ if (m === null)
709
+ return false;
710
+ const hour = Number(m[1]);
711
+ const minute = Number(m[2]);
712
+ const second = Number(m[3]);
713
+ const offset = m[5];
714
+ if (hour > 23 || minute > 59)
715
+ return false;
716
+ const { ok, utcMinutes } = isValidTimeOffset(hour, minute, offset);
717
+ if (!ok)
718
+ return false;
719
+ if (second < 60)
720
+ return true;
721
+ if (second > 60)
722
+ return false;
723
+ // A ":60" second is only real at the UTC instant 23:59:60 — i.e. the
724
+ // local time, shifted to UTC through its offset, must equal 23:59.
725
+ return utcMinutes === 23 * 60 + 59;
726
+ }
727
+ export const time = {
728
+ test: (value) => typeof value === "string" && isValidPartialTime(value),
729
+ };
730
+ function isValidDateTime(value) {
731
+ if (value.length < 20)
732
+ return false;
733
+ const tIndex = 10;
734
+ if (value[tIndex] !== "T" && value[tIndex] !== "t")
735
+ return false;
736
+ const datePart = value.slice(0, tIndex);
737
+ const timePart = value.slice(tIndex + 1);
738
+ return isFullDate(datePart) && isValidPartialTime(timePart);
739
+ }
740
+ export const dateTime = {
741
+ test: (value) => typeof value === "string" && isValidDateTime(value),
742
+ };
743
+ /**
744
+ * The draft 2020-12 format table. 2019-09 shares it; draft-07/06 subsets
745
+ * are derived below. Entries land format-by-format during M7 — each with
746
+ * its optional-suite file green before the next begins.
747
+ */
748
+ export const FORMATS_2020_12 = {
749
+ uuid,
750
+ ipv6,
751
+ date,
752
+ duration,
753
+ hostname,
754
+ ipv4,
755
+ "json-pointer": jsonPointer,
756
+ "relative-json-pointer": relativeJsonPointer,
757
+ regex,
758
+ uri,
759
+ "uri-reference": uriReference,
760
+ "uri-template": uriTemplate,
761
+ email,
762
+ iri,
763
+ "iri-reference": iriReference,
764
+ "date-time": dateTime,
765
+ time,
766
+ "idn-hostname": idnHostname,
767
+ "idn-email": idnEmail,
768
+ };
769
+ /** 2019-09 shares 2020-12's format list. */
770
+ export const FORMATS_2019_09 = FORMATS_2020_12;
771
+ // draft-07 lacks uuid/duration; draft-06 additionally lacks iri/idn
772
+ // formats. Subset tables are derived once the relevant entries exist.
773
+ const subset = (table, omit) => Object.fromEntries(Object.entries(table).filter(([name]) => !omit.includes(name)));
774
+ /** draft-07 format table (no uuid, no duration). */
775
+ export const FORMATS_DRAFT_07 = subset(FORMATS_2020_12, [
776
+ "uuid",
777
+ "duration",
778
+ ]);
779
+ /** draft-06 format table (draft-07's minus iri/iri-reference/idn-*). */
780
+ export const FORMATS_DRAFT_06 = subset(FORMATS_DRAFT_07, [
781
+ "iri",
782
+ "iri-reference",
783
+ "idn-email",
784
+ "idn-hostname",
785
+ ]);
786
+ /**
787
+ * draft-04 format table (for the draft-04 dialect package): only date-time,
788
+ * email, hostname, ipv4, ipv6, and uri are defined by the draft-04 spec.
789
+ */
790
+ export const FORMATS_DRAFT_04 = subset(FORMATS_DRAFT_06, [
791
+ "date",
792
+ "time",
793
+ "json-pointer",
794
+ "relative-json-pointer",
795
+ "regex",
796
+ "uri-reference",
797
+ "uri-template",
798
+ ]);
799
+ //# sourceMappingURL=index.js.map