@c9up/rune 0.1.7 → 0.1.9

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.
Files changed (51) hide show
  1. package/dist/MessagesProvider.d.ts +5 -0
  2. package/dist/MessagesProvider.d.ts.map +1 -1
  3. package/dist/MessagesProvider.js +1 -1
  4. package/dist/MessagesProvider.js.map +1 -1
  5. package/dist/Schema.d.ts +831 -35
  6. package/dist/Schema.d.ts.map +1 -1
  7. package/dist/Schema.js +2342 -140
  8. package/dist/Schema.js.map +1 -1
  9. package/dist/date.d.ts +36 -0
  10. package/dist/date.d.ts.map +1 -0
  11. package/dist/date.js +275 -0
  12. package/dist/date.js.map +1 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +10 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/formats.d.ts +148 -0
  18. package/dist/formats.d.ts.map +1 -0
  19. package/dist/formats.js +671 -0
  20. package/dist/formats.js.map +1 -0
  21. package/dist/index.d.ts +150 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +180 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/magic.d.ts +30 -0
  26. package/dist/magic.d.ts.map +1 -0
  27. package/dist/magic.js +154 -0
  28. package/dist/magic.js.map +1 -0
  29. package/dist/native.d.ts +18 -6
  30. package/dist/native.d.ts.map +1 -1
  31. package/dist/native.js +34 -19
  32. package/dist/native.js.map +1 -1
  33. package/dist/types.d.ts +15 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +12 -0
  36. package/dist/types.js.map +1 -0
  37. package/index.darwin-arm64.node +0 -0
  38. package/index.darwin-x64.node +0 -0
  39. package/index.linux-arm64-gnu.node +0 -0
  40. package/index.linux-x64-gnu.node +0 -0
  41. package/index.win32-x64-msvc.node +0 -0
  42. package/package.json +9 -1
  43. package/src/MessagesProvider.ts +1 -1
  44. package/src/Schema.ts +3389 -177
  45. package/src/date.ts +320 -0
  46. package/src/errors.ts +11 -0
  47. package/src/formats.ts +776 -0
  48. package/src/index.ts +269 -0
  49. package/src/magic.ts +181 -0
  50. package/src/native.ts +36 -21
  51. package/src/types.ts +55 -0
package/src/formats.ts ADDED
@@ -0,0 +1,776 @@
1
+ /**
2
+ * Format validators backing the VineJS string/number/array rules.
3
+ *
4
+ * VineJS delegates these to `validator.js`. rune has zero runtime dependencies,
5
+ * so each check is implemented here. Where VineJS ships a per-locale table we
6
+ * cannot reasonably reproduce in full (mobile numbers, postal codes, passports),
7
+ * rune supports a named subset and **fails closed** on an unknown locale rather
8
+ * than waving the value through — an unchecked value that reports "valid" is the
9
+ * failure mode this package exists to prevent.
10
+ */
11
+
12
+ const HEX_RE = /^#?(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
13
+ const ULID_RE = /^[0-7][0-9ABCDEFGHJKMNPQRSTVWXYZ]{25}$/i;
14
+ const JWT_RE = /^[\w-]+\.[\w-]+\.[\w-]*$/;
15
+ const IPV4_RE =
16
+ /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
17
+ const E164_RE = /^\+?[1-9]\d{6,14}$/;
18
+
19
+ /** Code-point scan rather than a control-character regex, which lints as suspicious. */
20
+ export const isAscii = (v: string): boolean =>
21
+ [...v].every((c) => (c.codePointAt(0) ?? 0) <= 0x7f);
22
+ export const isHexCode = (v: string): boolean => HEX_RE.test(v);
23
+ export const isUlid = (v: string): boolean => ULID_RE.test(v);
24
+ export const isJwt = (v: string): boolean => JWT_RE.test(v);
25
+
26
+ /** IPv6, including the `::` compressed form and IPv4-mapped tails. */
27
+ function isIpV6(v: string): boolean {
28
+ if (!v.includes(":")) return false;
29
+ const halves = v.split("::");
30
+ if (halves.length > 2) return false;
31
+ const expand = (part: string): string[] =>
32
+ part === "" ? [] : part.split(":");
33
+ const head = expand(halves[0]);
34
+ const tail = halves.length === 2 ? expand(halves[1]) : [];
35
+ const groups = [...head, ...tail];
36
+ // A trailing IPv4 literal occupies two groups.
37
+ const last = groups.at(-1);
38
+ const ipv4Tail = last?.includes(".") ?? false;
39
+ if (ipv4Tail && last !== undefined && !IPV4_RE.test(last)) return false;
40
+ const count = groups.length + (ipv4Tail ? 1 : 0);
41
+ if (halves.length === 1 ? count !== 8 : count >= 8) return false;
42
+ return groups
43
+ .slice(0, ipv4Tail ? -1 : undefined)
44
+ .every((g) => /^[0-9a-f]{1,4}$/i.test(g));
45
+ }
46
+
47
+ export function isIpAddress(v: string, version?: 4 | 6): boolean {
48
+ if (version === 4) return IPV4_RE.test(v);
49
+ if (version === 6) return isIpV6(v);
50
+ return IPV4_RE.test(v) || isIpV6(v);
51
+ }
52
+
53
+ /** Luhn checksum — the digits-only part of card validation. */
54
+ export function isCreditCard(v: string): boolean {
55
+ const digits = v.replace(/[ -]/g, "");
56
+ if (!/^\d{12,19}$/.test(digits)) return false;
57
+ let sum = 0;
58
+ let double = false;
59
+ for (let i = digits.length - 1; i >= 0; i--) {
60
+ let d = digits.charCodeAt(i) - 48;
61
+ if (double) {
62
+ d *= 2;
63
+ if (d > 9) d -= 9;
64
+ }
65
+ sum += d;
66
+ double = !double;
67
+ }
68
+ return sum % 10 === 0;
69
+ }
70
+
71
+ /** IBAN mod-97 check (ISO 13616), computed digit by digit to avoid BigInt. */
72
+ export function isIban(v: string): boolean {
73
+ const s = v.replace(/\s/g, "").toUpperCase();
74
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(s)) return false;
75
+ const rearranged = s.slice(4) + s.slice(0, 4);
76
+ let remainder = 0;
77
+ for (const ch of rearranged) {
78
+ const chunk = /\d/.test(ch) ? ch : String(ch.charCodeAt(0) - 55); // A→10 … Z→35
79
+ for (const digit of chunk) {
80
+ remainder = (remainder * 10 + (digit.charCodeAt(0) - 48)) % 97;
81
+ }
82
+ }
83
+ return remainder === 1;
84
+ }
85
+
86
+ /** `"lat,lng"` within the valid ranges. */
87
+ export function isCoordinates(v: string): boolean {
88
+ const parts = v.split(",");
89
+ if (parts.length !== 2) return false;
90
+ const lat = Number(parts[0].trim());
91
+ const lng = Number(parts[1].trim());
92
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false;
93
+ return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
94
+ }
95
+
96
+ /**
97
+ * Postal-code patterns. A named subset of VineJS's table — extend it here rather
98
+ * than at the call site, and see the module note on unknown locales.
99
+ */
100
+ const POSTAL_CODES: Record<string, RegExp> = {
101
+ AD: /^AD\d{3}$/i,
102
+ AT: /^\d{4}$/,
103
+ AU: /^\d{4}$/,
104
+ BE: /^\d{4}$/,
105
+ BG: /^\d{4}$/,
106
+ BR: /^\d{5}-?\d{3}$/,
107
+ CA: /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/i,
108
+ CH: /^\d{4}$/,
109
+ CN: /^\d{6}$/,
110
+ CZ: /^\d{3} ?\d{2}$/,
111
+ DE: /^\d{5}$/,
112
+ DK: /^\d{4}$/,
113
+ EE: /^\d{5}$/,
114
+ ES: /^\d{5}$/,
115
+ FI: /^\d{5}$/,
116
+ FR: /^\d{5}$/,
117
+ GB: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i,
118
+ GR: /^\d{3} ?\d{2}$/,
119
+ HR: /^\d{5}$/,
120
+ HU: /^\d{4}$/,
121
+ IE: /^[A-Z]\d[\dW] ?[A-Z\d]{4}$/i,
122
+ IL: /^\d{5}(?:\d{2})?$/,
123
+ IN: /^\d{6}$/,
124
+ IS: /^\d{3}$/,
125
+ IT: /^\d{5}$/,
126
+ JP: /^\d{3}-?\d{4}$/,
127
+ KR: /^\d{5}$/,
128
+ LI: /^\d{4}$/,
129
+ LT: /^(?:LT-)?\d{5}$/i,
130
+ LU: /^\d{4}$/,
131
+ LV: /^(?:LV-)?\d{4}$/i,
132
+ MC: /^980\d{2}$/,
133
+ MT: /^[A-Z]{3} ?\d{4}$/i,
134
+ MX: /^\d{5}$/,
135
+ NL: /^\d{4} ?[A-Z]{2}$/i,
136
+ NO: /^\d{4}$/,
137
+ NZ: /^\d{4}$/,
138
+ PL: /^\d{2}-?\d{3}$/,
139
+ PT: /^\d{4}-?\d{3}$/,
140
+ RO: /^\d{6}$/,
141
+ RU: /^\d{6}$/,
142
+ SE: /^\d{3} ?\d{2}$/,
143
+ SI: /^(?:SI-)?\d{4}$/i,
144
+ SK: /^\d{3} ?\d{2}$/,
145
+ TR: /^\d{5}$/,
146
+ UA: /^\d{5}$/,
147
+ US: /^\d{5}(?:-\d{4})?$/,
148
+ ZA: /^\d{4}$/,
149
+ };
150
+
151
+ /** Country codes rune can check postal codes for. */
152
+ export const SUPPORTED_POSTAL_CODES = Object.keys(POSTAL_CODES);
153
+
154
+ export function isPostalCode(v: string, countryCode: string): boolean | null {
155
+ const re = POSTAL_CODES[countryCode.toUpperCase()];
156
+ return re === undefined ? null : re.test(v.trim());
157
+ }
158
+
159
+ /**
160
+ * Mobile numbers in E.164 form — the locale-less check. Per-locale plans live
161
+ * in `MOBILE_LOCALES`; see {@link isMobileForLocale}.
162
+ */
163
+ export const isMobile = (v: string): boolean =>
164
+ E164_RE.test(v.replace(/[ .-]/g, ""));
165
+
166
+ /** HTML-escape the five characters that break out of markup (VineJS `escape`). */
167
+ export function escapeHtml(v: string): string {
168
+ return v
169
+ .replace(/&/g, "&amp;")
170
+ .replace(/</g, "&lt;")
171
+ .replace(/>/g, "&gt;")
172
+ .replace(/"/g, "&quot;")
173
+ .replace(/'/g, "&#x27;");
174
+ }
175
+
176
+ /** Options accepted by `normalizeEmail` (subset of VineJS's). */
177
+ export interface NormalizeEmailOptions {
178
+ /** validator.js spelling — takes precedence over the camelCase alias. */
179
+ all_lowercase?: boolean;
180
+ /** validator.js spelling. */
181
+ gmail_remove_dots?: boolean;
182
+ /** validator.js spelling. */
183
+ gmail_remove_subaddress?: boolean;
184
+ /** Lowercase the whole address. Defaults to `true`, like VineJS. */
185
+ allLowercase?: boolean;
186
+ /** Strip dots from a Gmail local part (`a.b@gmail.com` → `ab@gmail.com`). */
187
+ gmailRemoveDots?: boolean;
188
+ /** Drop a `+tag` suffix from the local part. */
189
+ gmailRemoveSubaddress?: boolean;
190
+ }
191
+
192
+ const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
193
+
194
+ export function normalizeEmail(
195
+ value: string,
196
+ options: NormalizeEmailOptions = {},
197
+ ): string {
198
+ const at = value.lastIndexOf("@");
199
+ if (at < 1) return value;
200
+ let local = value.slice(0, at);
201
+ let domain = value.slice(at + 1);
202
+ // The domain is case-insensitive per RFC 1035, so it is always lowercased.
203
+ domain = domain.toLowerCase();
204
+ const allLowercase = options.all_lowercase ?? options.allLowercase;
205
+ const removeSubaddress =
206
+ options.gmail_remove_subaddress ?? options.gmailRemoveSubaddress;
207
+ const removeDots = options.gmail_remove_dots ?? options.gmailRemoveDots;
208
+ if (allLowercase !== false) local = local.toLowerCase();
209
+ if (GMAIL_DOMAINS.has(domain)) {
210
+ if (removeSubaddress) local = local.split("+")[0];
211
+ if (removeDots) local = local.replace(/\./g, "");
212
+ } else if (removeSubaddress) {
213
+ local = local.split("+")[0];
214
+ }
215
+ return `${local}@${domain}`;
216
+ }
217
+
218
+ /** Options accepted by `normalizeUrl` (subset of VineJS's). */
219
+ export interface NormalizeUrlOptions {
220
+ /** Remove a leading `www.` from the host. */
221
+ stripWWW?: boolean;
222
+ /** Force this protocol (e.g. `"https"`). */
223
+ forceProtocol?: string;
224
+ /** Rewrite `http:` to `https:` (normalize-url `forceHttps`). */
225
+ forceHttps?: boolean;
226
+ /** Drop the trailing slash of an empty path. */
227
+ stripTrailingSlash?: boolean;
228
+ /** Drop the trailing slash of ANY path (normalize-url `removeTrailingSlash`). */
229
+ removeTrailingSlash?: boolean;
230
+ /** Drop the `#fragment`. */
231
+ stripHash?: boolean;
232
+ /** Drop the scheme entirely, leaving `example.com/path`. */
233
+ stripProtocol?: boolean;
234
+ /** Drop `user:pass@`. */
235
+ stripAuthentication?: boolean;
236
+ /** Drop `:80` / `:443` when they match the scheme's default. */
237
+ removeExplicitPort?: boolean;
238
+ /** Sort the query parameters by name, for a stable comparison key. */
239
+ sortQueryParameters?: boolean;
240
+ /** Query parameters to remove — names, or patterns matched against names. */
241
+ removeQueryParameters?: ReadonlyArray<string | RegExp>;
242
+ /** Drop `index.html` / `index.php`-style directory indexes. */
243
+ removeDirectoryIndex?: boolean;
244
+ }
245
+
246
+ export function normalizeUrl(
247
+ value: string,
248
+ options: NormalizeUrlOptions = {},
249
+ ): string {
250
+ let url: URL;
251
+ try {
252
+ url = new URL(value);
253
+ } catch {
254
+ // Not parseable: hand it back untouched and let `url()` report the failure.
255
+ return value;
256
+ }
257
+ if (options.forceProtocol) url.protocol = `${options.forceProtocol}:`;
258
+ if (options.forceHttps && url.protocol === "http:") url.protocol = "https:";
259
+ if (options.stripWWW) url.hostname = url.hostname.replace(/^www\./, "");
260
+ if (options.stripHash) url.hash = "";
261
+ if (options.stripAuthentication) {
262
+ url.username = "";
263
+ url.password = "";
264
+ }
265
+ if (
266
+ options.removeExplicitPort &&
267
+ ((url.protocol === "http:" && url.port === "80") ||
268
+ (url.protocol === "https:" && url.port === "443"))
269
+ ) {
270
+ url.port = "";
271
+ }
272
+ if (options.removeDirectoryIndex) {
273
+ url.pathname = url.pathname.replace(/\/index\.(?:html?|php|asp)$/i, "/");
274
+ }
275
+ for (const parameter of options.removeQueryParameters ?? []) {
276
+ for (const name of [...url.searchParams.keys()]) {
277
+ const matches =
278
+ typeof parameter === "string"
279
+ ? name === parameter
280
+ : parameter.test(name);
281
+ if (matches) url.searchParams.delete(name);
282
+ }
283
+ }
284
+ if (options.sortQueryParameters) url.searchParams.sort();
285
+
286
+ let out = url.toString();
287
+ if (options.removeTrailingSlash) {
288
+ // Any path, not just the root one.
289
+ out = out.replace(/\/(?=(?:\?|#|$))/, "");
290
+ } else if (options.stripTrailingSlash && url.pathname === "/") {
291
+ out = out.replace(/\/(?=(?:\?|#|$))/, "");
292
+ }
293
+ if (options.stripProtocol) out = out.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
294
+ return out;
295
+ }
296
+
297
+ /** `dash-case`, `snake_case` and spaced words to `camelCase`. */
298
+ export function toCamelCase(v: string): string {
299
+ return v
300
+ .trim()
301
+ .replace(/[-_\s]+(.)?/g, (_, c: string | undefined) =>
302
+ c ? c.toUpperCase() : "",
303
+ )
304
+ .replace(/^(.)/, (c) => c.toLowerCase());
305
+ }
306
+
307
+ /**
308
+ * Passport numbers. Named subset, same fail-closed contract as
309
+ * {@link isPostalCode}: an unknown country returns `null`.
310
+ */
311
+ const PASSPORTS: Record<string, RegExp> = {
312
+ AT: /^[A-Z]\d{7}$/i,
313
+ AU: /^[A-Z]\d{7}$/i,
314
+ BE: /^[A-Z]{2}\d{6}$/i,
315
+ CA: /^[A-Z]{2}\d{6}$/i,
316
+ CH: /^[A-Z]\d{7}$/i,
317
+ CZ: /^\d{8}$/,
318
+ DE: /^[CFGHJKLMNPRTVWXYZ0-9]{9}$/i,
319
+ DK: /^\d{9}$/,
320
+ ES: /^[A-Z]{3}\d{6}$/i,
321
+ FI: /^[A-Z]{2}\d{7}$/i,
322
+ FR: /^\d{2}[A-Z]{2}\d{5}$/i,
323
+ GB: /^\d{9}$/,
324
+ GR: /^[A-Z]{2}\d{7}$/i,
325
+ HU: /^[A-Z]{2}\d{6}$/i,
326
+ IE: /^[A-Z0-9]{2}\d{7}$/i,
327
+ IN: /^[A-Z]\d{7}$/i,
328
+ IT: /^[A-Z0-9]{2}\d{7}$/i,
329
+ JP: /^[A-Z]{2}\d{7}$/i,
330
+ KR: /^[MS]\d{8}$/i,
331
+ NL: /^[A-Z]{2}\d{6}[A-Z0-9]$/i,
332
+ NO: /^\d{8}$/,
333
+ PL: /^[A-Z]{2}\d{7}$/i,
334
+ PT: /^[A-Z]\d{6}$/i,
335
+ RO: /^\d{8,9}$/,
336
+ RU: /^\d{9}$/,
337
+ SE: /^\d{8}$/,
338
+ TR: /^[A-Z]\d{8}$/i,
339
+ UA: /^[A-Z]{2}\d{6}$/i,
340
+ US: /^\d{9}$/,
341
+ ZA: /^[TAMD]\d{8}$/i,
342
+ };
343
+
344
+ export const SUPPORTED_PASSPORTS = Object.keys(PASSPORTS);
345
+
346
+ export function isPassport(v: string, countryCode: string): boolean | null {
347
+ const re = PASSPORTS[countryCode.toUpperCase()];
348
+ return re === undefined ? null : re.test(v.trim());
349
+ }
350
+
351
+ /** Options accepted by `alpha()` / `alphaNumeric()` (VineJS spelling). */
352
+ export interface AlphaOptions {
353
+ allowSpaces?: boolean;
354
+ allowUnderscores?: boolean;
355
+ allowDashes?: boolean;
356
+ }
357
+
358
+ /** Build the character class for `alpha`/`alphaNumeric` from its options. */
359
+ export function alphaPattern(base: string, options: AlphaOptions = {}): RegExp {
360
+ let extra = "";
361
+ if (options.allowSpaces) extra += " ";
362
+ if (options.allowUnderscores) extra += "_";
363
+ if (options.allowDashes) extra += "\\-";
364
+ return new RegExp(`^[${base}${extra}]+$`);
365
+ }
366
+
367
+ /** Options accepted by `url()` — the validator.js names VineJS forwards. */
368
+ export interface UrlOptions {
369
+ /** validator.js spelling — takes precedence over the camelCase alias. */
370
+ require_protocol?: boolean;
371
+ /** validator.js spelling. */
372
+ require_tld?: boolean;
373
+ /** validator.js spelling. */
374
+ allow_underscores?: boolean;
375
+ /** Require an explicit scheme. Defaults to `true`, like validator.js. */
376
+ requireProtocol?: boolean;
377
+ /** Allowed schemes, without the colon. Defaults to http/https. */
378
+ protocols?: string[];
379
+ /** Require a dotted host (rejects `http://localhost`). Defaults to `true`. */
380
+ requireTld?: boolean;
381
+ /** Allow `_` in the host. */
382
+ allowUnderscores?: boolean;
383
+ }
384
+
385
+ export function isUrlWithOptions(
386
+ value: string,
387
+ options: UrlOptions = {},
388
+ ): boolean {
389
+ // VineJS forwards validator.js options verbatim, so a transcribed Adonis
390
+ // validator arrives in snake_case. Both spellings are honoured, snake_case
391
+ // first, so neither form is silently ignored.
392
+ const requireProtocol =
393
+ (options.require_protocol ?? options.requireProtocol) !== false;
394
+ const protocols = options.protocols ?? ["http", "https"];
395
+ const candidate =
396
+ requireProtocol || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
397
+ ? value
398
+ : `https://${value}`;
399
+ let url: URL;
400
+ try {
401
+ url = new URL(candidate);
402
+ } catch {
403
+ return false;
404
+ }
405
+ if (requireProtocol && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
406
+ if (!protocols.includes(url.protocol.replace(/:$/, ""))) return false;
407
+ if (url.hostname.length === 0) return false;
408
+ const allowUnderscores =
409
+ options.allow_underscores ?? options.allowUnderscores ?? false;
410
+ if (!allowUnderscores && url.hostname.includes("_")) return false;
411
+ const requireTld = (options.require_tld ?? options.requireTld) !== false;
412
+ if (requireTld && !url.hostname.includes(".")) return false;
413
+ return true;
414
+ }
415
+
416
+ /**
417
+ * Mobile numbering plans. Named subset of VineJS's `locale` table, same
418
+ * fail-closed contract as the postal codes: an unknown locale returns `null`.
419
+ */
420
+ const MOBILE_LOCALES: Record<string, RegExp> = {
421
+ "fr-CH": /^(?:\+41|0)7[5-9]\d{7}$/,
422
+ "de-CH": /^(?:\+41|0)7[5-9]\d{7}$/,
423
+ "it-CH": /^(?:\+41|0)7[5-9]\d{7}$/,
424
+ "fr-FR": /^(?:\+33|0)[67]\d{8}$/,
425
+ "fr-BE": /^(?:\+32|0)4[5-9]\d{7}$/,
426
+ "nl-BE": /^(?:\+32|0)4[5-9]\d{7}$/,
427
+ "en-US": /^(?:\+1)?[2-9]\d{9}$/,
428
+ "en-CA": /^(?:\+1)?[2-9]\d{9}$/,
429
+ "en-GB": /^(?:\+44|0)7\d{9}$/,
430
+ "en-IE": /^(?:\+353|0)8[35-9]\d{7}$/,
431
+ "en-AU": /^(?:\+61|0)4\d{8}$/,
432
+ "en-NZ": /^(?:\+64|0)2\d{7,9}$/,
433
+ "en-IN": /^(?:\+91|0)?[6-9]\d{9}$/,
434
+ "de-DE": /^(?:\+49|0)1[5-7]\d{8,9}$/,
435
+ "de-AT": /^(?:\+43|0)6[4-9]\d{7,10}$/,
436
+ "it-IT": /^(?:\+39)?3\d{8,9}$/,
437
+ "es-ES": /^(?:\+34)?[679]\d{8}$/,
438
+ "pt-PT": /^(?:\+351)?9[1236]\d{7}$/,
439
+ "pt-BR": /^(?:\+55)?(?:\d{2})?9?\d{8}$/,
440
+ "nl-NL": /^(?:\+31|0)6\d{8}$/,
441
+ "da-DK": /^(?:\+45)?\d{8}$/,
442
+ "sv-SE": /^(?:\+46|0)7[02369]\d{7}$/,
443
+ "nb-NO": /^(?:\+47)?[49]\d{7}$/,
444
+ "fi-FI": /^(?:\+358|0)4\d{5,10}$/,
445
+ "pl-PL": /^(?:\+48)?\d{9}$/,
446
+ "cs-CZ": /^(?:\+420)?[6-7]\d{8}$/,
447
+ "sk-SK": /^(?:\+421)?9\d{8}$/,
448
+ "hu-HU": /^(?:\+36|06)(?:20|30|31|50|70)\d{7}$/,
449
+ "ro-RO": /^(?:\+40|0)7\d{8}$/,
450
+ "el-GR": /^(?:\+30|0)6[89]\d{8}$/,
451
+ "tr-TR": /^(?:\+90|0)5\d{9}$/,
452
+ "ru-RU": /^(?:\+7|8)9\d{9}$/,
453
+ "uk-UA": /^(?:\+380|0)\d{9}$/,
454
+ "ja-JP": /^(?:\+81|0)[7-9]0\d{8}$/,
455
+ "ko-KR": /^(?:\+82|0)1[0-9]\d{7,8}$/,
456
+ "zh-CN": /^(?:\+86|0)?1[3-9]\d{9}$/,
457
+ "zh-TW": /^(?:\+886|0)9\d{8}$/,
458
+ "ar-AE": /^(?:\+971|0)5[0245678]\d{7}$/,
459
+ "ar-SA": /^(?:\+966|0)5\d{8}$/,
460
+ "he-IL": /^(?:\+972|0)5[0-9]\d{7}$/,
461
+ "en-ZA": /^(?:\+27|0)[6-8]\d{8}$/,
462
+ };
463
+
464
+ export const SUPPORTED_MOBILE_LOCALES = Object.keys(MOBILE_LOCALES);
465
+
466
+ export function isMobileForLocale(v: string, locale: string): boolean | null {
467
+ const re = MOBILE_LOCALES[locale];
468
+ if (re === undefined) return null;
469
+ return re.test(v.replace(/[ .-]/g, ""));
470
+ }
471
+
472
+ /**
473
+ * Options accepted by `email()` — the validator.js names VineJS forwards.
474
+ * Implemented here rather than delegated: rune carries no runtime dependency,
475
+ * so every check it claims to do, it does itself.
476
+ */
477
+ export interface EmailOptions {
478
+ /** Accept `Name <a@b.io>`. Off by default. */
479
+ allow_display_name?: boolean;
480
+ /** Require a dotted domain. On by default. */
481
+ require_tld?: boolean;
482
+ /** Accept `user@[192.168.0.1]` / `user@[IPv6:…]`. Off by default. */
483
+ allow_ip_domain?: boolean;
484
+ /** Skip the RFC length caps (64 local / 254 total). Off by default. */
485
+ ignore_max_length?: boolean;
486
+ /** Characters refused anywhere in the local part. */
487
+ blacklisted_chars?: string;
488
+ /** Apply Gmail's own extra restrictions when the domain is Gmail. */
489
+ domain_specific_validation?: boolean;
490
+ }
491
+
492
+ /** Hard bound on anything handed to the email parser (RFC 5322 line limit). */
493
+ const MAX_EMAIL_INPUT = 998;
494
+
495
+ /** Unquoted local part: dot-separated atoms of RFC 5322 atext. */
496
+ const ATEXT = "[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+";
497
+ const DOT_ATOM_RE = new RegExp(`^${ATEXT}(?:\\.${ATEXT})*$`);
498
+ /** Quoted local part: `"anything but bare quote/backslash, or escaped"`. */
499
+ const QUOTED_LOCAL_RE = /^"(?:[^"\\]|\\.)*"$/;
500
+ /**
501
+ * Split `Display Name <address@host>` into its address.
502
+ *
503
+ * Parsed rather than matched: the obvious pattern —
504
+ * `^\s*(?:"..."|[^<>@]*?)\s*<(.+)>\s*$` — lets `\s*` and `[^<>@]*?` both
505
+ * claim a space, so an input of N spaces has N ways to be split and the engine
506
+ * tries them all. Measured at O(n³): 8 KB of spaces blocked the event loop for
507
+ * 67 seconds, which turns any route validating an email into a denial of
508
+ * service. This walk is linear and answers the same question.
509
+ *
510
+ * Returns the address, or null when the input is not in display-name form.
511
+ */
512
+ function displayNameAddress(input: string): string | null {
513
+ const trimmed = input.trim();
514
+ if (!trimmed.endsWith(">")) return null;
515
+
516
+ let open: number;
517
+ if (trimmed.startsWith('"')) {
518
+ // A quoted display name may contain anything, `<` included, so the
519
+ // address opens at the first `<` AFTER the closing quote.
520
+ const closingQuote = closingQuoteIndex(trimmed);
521
+ if (closingQuote === -1) return null;
522
+ open = trimmed.indexOf("<", closingQuote + 1);
523
+ if (open === -1) return null;
524
+ if (trimmed.slice(closingQuote + 1, open).trim() !== "") return null;
525
+ } else {
526
+ open = trimmed.indexOf("<");
527
+ if (open === -1) return null;
528
+ // An unquoted display name carries none of `<`, `>` or `@` — the same
529
+ // restriction the pattern expressed.
530
+ if (/[<>@]/.test(trimmed.slice(0, open))) return null;
531
+ }
532
+
533
+ const address = trimmed.slice(open + 1, -1);
534
+ return address.length > 0 ? address : null;
535
+ }
536
+
537
+ /** Index of the quote closing the one at position 0, or -1. */
538
+ function closingQuoteIndex(input: string): number {
539
+ for (let i = 1; i < input.length; i++) {
540
+ if (input[i] === "\\") {
541
+ i++;
542
+ continue;
543
+ }
544
+ if (input[i] === '"') return i;
545
+ }
546
+ return -1;
547
+ }
548
+
549
+ /** A single DNS label: alphanumerics and inner hyphens, 1..63 chars. */
550
+ function isDnsLabel(label: string): boolean {
551
+ if (label.length === 0 || label.length > 63) return false;
552
+ if (label.startsWith("-") || label.endsWith("-")) return false;
553
+ return /^[a-zA-Z0-9-]+$/.test(label);
554
+ }
555
+
556
+ /** Bracketed IP domain literal — `[192.168.0.1]` or `[IPv6:::1]`. */
557
+ function isIpDomainLiteral(domain: string): boolean {
558
+ if (!domain.startsWith("[") || !domain.endsWith("]")) return false;
559
+ const inner = domain.slice(1, -1);
560
+ if (inner.toLowerCase().startsWith("ipv6:")) {
561
+ return isIpAddress(inner.slice(5), 6);
562
+ }
563
+ return isIpAddress(inner, 4);
564
+ }
565
+
566
+ /**
567
+ * Validate an email address.
568
+ *
569
+ * Deliberately structural rather than one giant regex: the length caps, the
570
+ * quoted local part and the IP-literal domain are separate rules in RFC 5321,
571
+ * and a single pattern that tries to express all of them is the classic source
572
+ * of both false accepts and false rejects.
573
+ */
574
+ export function isEmail(value: string, options: EmailOptions = {}): boolean {
575
+ let candidate = value;
576
+
577
+ // Bound the input BEFORE any parsing. A caller may opt out of the 254-char
578
+ // address cap, but never out of a bound: an unbounded string reaching the
579
+ // parser is how a validator becomes an outage. RFC 5322 caps a whole line
580
+ // at 998 octets, so a display-name form has no business being longer.
581
+ if (value.length > MAX_EMAIL_INPUT) return false;
582
+
583
+ if (options.allow_display_name) {
584
+ const address = displayNameAddress(candidate);
585
+ if (address !== null) candidate = address;
586
+ } else if (/[<>]/.test(candidate)) {
587
+ return false;
588
+ }
589
+
590
+ if (!options.ignore_max_length && candidate.length > 254) return false;
591
+
592
+ const at = candidate.lastIndexOf("@");
593
+ if (at < 1 || at === candidate.length - 1) return false;
594
+ const local = candidate.slice(0, at);
595
+ const domain = candidate.slice(at + 1);
596
+
597
+ if (options.blacklisted_chars) {
598
+ for (const char of options.blacklisted_chars) {
599
+ if (local.includes(char)) return false;
600
+ }
601
+ }
602
+
603
+ const quoted = QUOTED_LOCAL_RE.test(local);
604
+ if (!quoted && !DOT_ATOM_RE.test(local)) return false;
605
+ if (!options.ignore_max_length && local.length > 64) return false;
606
+
607
+ if (domain.startsWith("[")) {
608
+ return options.allow_ip_domain === true && isIpDomainLiteral(domain);
609
+ }
610
+
611
+ const labels = domain.split(".");
612
+ if (options.require_tld !== false) {
613
+ if (labels.length < 2) return false;
614
+ // A TLD is alphabetic and at least two characters.
615
+ const tld = labels[labels.length - 1];
616
+ if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false;
617
+ }
618
+ if (!labels.every(isDnsLabel)) return false;
619
+
620
+ if (
621
+ options.domain_specific_validation &&
622
+ GMAIL_DOMAINS.has(domain.toLowerCase())
623
+ ) {
624
+ // Gmail: 6..30 chars, letters/digits/dots only, no leading/trailing dot,
625
+ // no doubled dot — and dots are ignored for the length check.
626
+ const username = local.split("+")[0];
627
+ if (!/^[a-zA-Z0-9.]+$/.test(username)) return false;
628
+ if (username.startsWith(".") || username.endsWith(".")) return false;
629
+ if (username.includes("..")) return false;
630
+ const bare = username.replace(/\./g, "");
631
+ if (bare.length < 6 || bare.length > 30) return false;
632
+ }
633
+
634
+ return true;
635
+ }
636
+
637
+ /** Options accepted by `vat()` (VineJS 4.2 `vatRule`). */
638
+ export interface VatOptions {
639
+ countryCode: string | string[];
640
+ }
641
+
642
+ /**
643
+ * VAT number patterns, per country. `check` runs the country's own checksum
644
+ * when there is a short, well-defined one; countries without a `check` are
645
+ * validated on FORMAT only, and that is stated rather than implied.
646
+ */
647
+ const VAT_RULES: Record<
648
+ string,
649
+ { pattern: RegExp; check?: (digits: string) => boolean }
650
+ > = {
651
+ // mod-97 on the 9 leading digits (the two check digits are the last two).
652
+ BE: {
653
+ pattern: /^BE0?\d{9}$/i,
654
+ check: (d) => mod97(d.slice(0, 8)) === Number(d.slice(8, 10)),
655
+ },
656
+ FR: { pattern: /^FR[0-9A-Z]{2}\d{9}$/i },
657
+ DE: { pattern: /^DE\d{9}$/i, check: (d) => germanChecksum(d) },
658
+ NL: { pattern: /^NL\d{9}B\d{2}$/i, check: (d) => dutchChecksum(d) },
659
+ IT: { pattern: /^IT\d{11}$/i, check: (d) => luhnLike(d) },
660
+ ES: { pattern: /^ES[0-9A-Z]\d{7}[0-9A-Z]$/i },
661
+ PT: { pattern: /^PT\d{9}$/i, check: (d) => mod11(d) },
662
+ LU: {
663
+ pattern: /^LU\d{8}$/i,
664
+ check: (d) => Number(d.slice(0, 6)) % 89 === Number(d.slice(6, 8)),
665
+ },
666
+ AT: { pattern: /^ATU\d{8}$/i },
667
+ DK: { pattern: /^DK\d{8}$/i },
668
+ FI: { pattern: /^FI\d{8}$/i },
669
+ SE: { pattern: /^SE\d{12}$/i },
670
+ IE: { pattern: /^IE(?:\d{7}[A-W]{1,2}|\d[A-Z+*]\d{5}[A-W])$/i },
671
+ PL: { pattern: /^PL\d{10}$/i },
672
+ CZ: { pattern: /^CZ\d{8,10}$/i },
673
+ SK: { pattern: /^SK\d{10}$/i },
674
+ GR: { pattern: /^(?:EL|GR)\d{9}$/i },
675
+ HU: { pattern: /^HU\d{8}$/i },
676
+ RO: { pattern: /^RO\d{2,10}$/i },
677
+ BG: { pattern: /^BG\d{9,10}$/i },
678
+ HR: { pattern: /^HR\d{11}$/i },
679
+ SI: { pattern: /^SI\d{8}$/i },
680
+ EE: { pattern: /^EE\d{9}$/i },
681
+ LV: { pattern: /^LV\d{11}$/i },
682
+ LT: { pattern: /^LT(?:\d{9}|\d{12})$/i },
683
+ MT: { pattern: /^MT\d{8}$/i },
684
+ CY: { pattern: /^CY\d{8}[A-Z]$/i },
685
+ GB: { pattern: /^GB(?:\d{9}|\d{12}|GD\d{3}|HA\d{3})$/i },
686
+ CH: {
687
+ pattern: /^CHE\d{9}(?:TVA|MWST|IVA)?$/i,
688
+ check: (d) => swissUidChecksum(d),
689
+ },
690
+ };
691
+
692
+ /** Countries `vat()` can check. */
693
+ export const SUPPORTED_VAT_COUNTRIES = Object.keys(VAT_RULES);
694
+
695
+ /** Plain mod-97 over a digit string. */
696
+ function mod97(digits: string): number {
697
+ let remainder = 0;
698
+ for (const digit of digits) {
699
+ remainder = (remainder * 10 + (digit.charCodeAt(0) - 48)) % 97;
700
+ }
701
+ return 97 - remainder;
702
+ }
703
+
704
+ /** ISO 7064 mod-11 used by the Portuguese NIF. */
705
+ function mod11(digits: string): boolean {
706
+ let sum = 0;
707
+ for (let i = 0; i < 8; i++) {
708
+ sum += (digits.charCodeAt(i) - 48) * (9 - i);
709
+ }
710
+ const check = 11 - (sum % 11);
711
+ const expected = check >= 10 ? 0 : check;
712
+ return expected === digits.charCodeAt(8) - 48;
713
+ }
714
+
715
+ /** German USt-IdNr. checksum (the "11-test" defined by the Bundeszentralamt). */
716
+ function germanChecksum(digits: string): boolean {
717
+ let product = 10;
718
+ for (let i = 0; i < 8; i++) {
719
+ const digit = digits.charCodeAt(i) - 48;
720
+ let sum = (digit + product) % 10;
721
+ if (sum === 0) sum = 10;
722
+ product = (2 * sum) % 11;
723
+ }
724
+ const check = 11 - product;
725
+ return (check === 10 ? 0 : check) === digits.charCodeAt(8) - 48;
726
+ }
727
+
728
+ /** Dutch BTW checksum: weighted 9..2 mod 11 over the first 8 digits. */
729
+ function dutchChecksum(digits: string): boolean {
730
+ let sum = 0;
731
+ for (let i = 0; i < 8; i++) {
732
+ sum += (digits.charCodeAt(i) - 48) * (9 - i);
733
+ }
734
+ return sum % 11 === digits.charCodeAt(8) - 48;
735
+ }
736
+
737
+ /** Italian partita IVA: Luhn over 11 digits. */
738
+ function luhnLike(digits: string): boolean {
739
+ let sum = 0;
740
+ for (let i = 0; i < 11; i++) {
741
+ let digit = digits.charCodeAt(i) - 48;
742
+ if (i % 2 === 1) {
743
+ digit *= 2;
744
+ if (digit > 9) digit -= 9;
745
+ }
746
+ sum += digit;
747
+ }
748
+ return sum % 10 === 0;
749
+ }
750
+
751
+ /** Swiss UID (CHE): weights 5,4,3,2,7,6,5,4 mod 11. */
752
+ function swissUidChecksum(digits: string): boolean {
753
+ const weights = [5, 4, 3, 2, 7, 6, 5, 4];
754
+ let sum = 0;
755
+ for (let i = 0; i < 8; i++) {
756
+ sum += (digits.charCodeAt(i) - 48) * weights[i];
757
+ }
758
+ const remainder = sum % 11;
759
+ if (remainder === 10) return false;
760
+ const check = remainder === 0 ? 0 : 11 - remainder;
761
+ return check === digits.charCodeAt(8) - 48;
762
+ }
763
+
764
+ /**
765
+ * Validate a VAT number for one country. Returns `null` when the country has no
766
+ * rule, so the caller can fail LOUDLY instead of accepting the value.
767
+ */
768
+ export function isVat(value: string, countryCode: string): boolean | null {
769
+ const rule = VAT_RULES[countryCode.toUpperCase()];
770
+ if (rule === undefined) return null;
771
+ const normalized = value.replace(/[\s.-]/g, "").toUpperCase();
772
+ if (!rule.pattern.test(normalized)) return false;
773
+ if (!rule.check) return true;
774
+ const digits = normalized.replace(/[^0-9]/g, "");
775
+ return rule.check(digits);
776
+ }