@plasius/schema 1.0.17 → 1.1.0
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/.github/workflows/cd.yml +97 -47
- package/CHANGELOG.md +36 -11
- package/README.md +140 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/src/field.builder.ts +121 -1
- package/src/field.ts +139 -0
- package/src/schema.ts +78 -11
- package/src/validation/dateTime.ISO8601.ts +56 -5
- package/src/validation/languageCode.BCP47.ts +299 -0
- package/src/validation/version.SEMVER2.0.0.ts +3 -1
- package/tests/field.builder.test.ts +81 -0
- package/tests/fields.test.ts +213 -0
- package/tests/pii.test.ts +2 -2
- package/tests/schema.test.ts +26 -26
- package/tests/test-utils.ts +7 -7
- package/tests/validate.test.ts +6 -6
package/src/field.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { version } from "os";
|
|
1
2
|
import { FieldBuilder } from "./field.builder.js";
|
|
2
3
|
import { Infer } from "./infer.js";
|
|
3
4
|
import { SchemaShape } from "./types.js";
|
|
5
|
+
import {
|
|
6
|
+
validateCountryCode,
|
|
7
|
+
validateDateTimeISO,
|
|
8
|
+
validateEmail,
|
|
9
|
+
validatePhone,
|
|
10
|
+
validateRichText,
|
|
11
|
+
validateSafeText,
|
|
12
|
+
validateSemVer,
|
|
13
|
+
validateUrl,
|
|
14
|
+
validateUUID,
|
|
15
|
+
} from "./validation/index.js";
|
|
16
|
+
import { validateLanguage } from "./validation/languageCode.BCP47.js";
|
|
4
17
|
|
|
5
18
|
export const field = {
|
|
6
19
|
string: () => new FieldBuilder<string>("string"),
|
|
@@ -11,4 +24,130 @@ export const field = {
|
|
|
11
24
|
array: (itemType: FieldBuilder) => new FieldBuilder("array", { itemType }),
|
|
12
25
|
ref: <S extends SchemaShape>(refType: string) =>
|
|
13
26
|
new FieldBuilder<Infer<S>>("ref", { refType }),
|
|
27
|
+
email: () =>
|
|
28
|
+
new FieldBuilder<string>("string")
|
|
29
|
+
.validator(validateEmail)
|
|
30
|
+
.PID({
|
|
31
|
+
classification: "high",
|
|
32
|
+
action: "encrypt",
|
|
33
|
+
logHandling: "redact",
|
|
34
|
+
purpose: "an email address",
|
|
35
|
+
})
|
|
36
|
+
.description("An email address"),
|
|
37
|
+
phone: () =>
|
|
38
|
+
new FieldBuilder<string>("string")
|
|
39
|
+
.validator(validatePhone)
|
|
40
|
+
.PID({
|
|
41
|
+
classification: "high",
|
|
42
|
+
action: "encrypt",
|
|
43
|
+
logHandling: "redact",
|
|
44
|
+
purpose: "a phone number",
|
|
45
|
+
})
|
|
46
|
+
.description("A phone number"),
|
|
47
|
+
url: () =>
|
|
48
|
+
new FieldBuilder<string>("string")
|
|
49
|
+
.validator(validateUrl)
|
|
50
|
+
.PID({
|
|
51
|
+
classification: "low",
|
|
52
|
+
action: "hash",
|
|
53
|
+
logHandling: "pseudonym",
|
|
54
|
+
purpose: "a URL",
|
|
55
|
+
})
|
|
56
|
+
.description("A URL"),
|
|
57
|
+
uuid: () =>
|
|
58
|
+
new FieldBuilder<string>("string")
|
|
59
|
+
.PID({
|
|
60
|
+
classification: "low",
|
|
61
|
+
action: "hash",
|
|
62
|
+
logHandling: "pseudonym",
|
|
63
|
+
purpose: "a UUID",
|
|
64
|
+
})
|
|
65
|
+
.validator(validateUUID)
|
|
66
|
+
.description("A UUID"),
|
|
67
|
+
dateTimeISO: () =>
|
|
68
|
+
new FieldBuilder<string>("string")
|
|
69
|
+
.PID({
|
|
70
|
+
classification: "none",
|
|
71
|
+
action: "none",
|
|
72
|
+
logHandling: "plain",
|
|
73
|
+
purpose: "a date string",
|
|
74
|
+
})
|
|
75
|
+
.validator(validateDateTimeISO)
|
|
76
|
+
.description("A date string in ISO 8601 format"),
|
|
77
|
+
dateISO: () =>
|
|
78
|
+
new FieldBuilder<string>("string")
|
|
79
|
+
.PID({
|
|
80
|
+
classification: "none",
|
|
81
|
+
action: "none",
|
|
82
|
+
logHandling: "plain",
|
|
83
|
+
purpose: "a date string",
|
|
84
|
+
})
|
|
85
|
+
.validator((s) => validateDateTimeISO(s, { mode: "date" }))
|
|
86
|
+
.description("A date string in ISO 8601 format (date only)"),
|
|
87
|
+
timeISO: () =>
|
|
88
|
+
new FieldBuilder<string>("string")
|
|
89
|
+
.PID({
|
|
90
|
+
classification: "none",
|
|
91
|
+
action: "none",
|
|
92
|
+
logHandling: "plain",
|
|
93
|
+
purpose: "a time string",
|
|
94
|
+
})
|
|
95
|
+
.validator((s) => validateDateTimeISO(s, { mode: "time" }))
|
|
96
|
+
.description("A time string in ISO 8601 format (time only)"),
|
|
97
|
+
richText: () =>
|
|
98
|
+
new FieldBuilder<string>("string")
|
|
99
|
+
.PID({
|
|
100
|
+
classification: "low",
|
|
101
|
+
action: "clear",
|
|
102
|
+
logHandling: "omit",
|
|
103
|
+
purpose: "rich text content",
|
|
104
|
+
})
|
|
105
|
+
.validator(validateRichText)
|
|
106
|
+
.description("Rich text content, may include basic HTML formatting"),
|
|
107
|
+
generalText: () =>
|
|
108
|
+
new FieldBuilder<string>("string")
|
|
109
|
+
.PID({
|
|
110
|
+
classification: "none",
|
|
111
|
+
action: "none",
|
|
112
|
+
logHandling: "plain",
|
|
113
|
+
purpose: "Plain text content",
|
|
114
|
+
})
|
|
115
|
+
.validator(validateSafeText)
|
|
116
|
+
.description("Standard text content, no HTML allowed"),
|
|
117
|
+
latitude: () =>
|
|
118
|
+
new FieldBuilder<number>("number")
|
|
119
|
+
.PID({
|
|
120
|
+
classification: "low",
|
|
121
|
+
action: "clear",
|
|
122
|
+
logHandling: "omit",
|
|
123
|
+
purpose: "Latitude in decimal degrees, WGS 84 (ISO 6709)",
|
|
124
|
+
})
|
|
125
|
+
.min(-90)
|
|
126
|
+
.max(90)
|
|
127
|
+
.description("Latitude in decimal degrees, WGS 84 (ISO 6709)"),
|
|
128
|
+
longitude: () =>
|
|
129
|
+
new FieldBuilder<number>("number")
|
|
130
|
+
.PID({
|
|
131
|
+
classification: "low",
|
|
132
|
+
action: "clear",
|
|
133
|
+
logHandling: "omit",
|
|
134
|
+
purpose: "Longitude in decimal degrees, WGS 84 (ISO 6709)",
|
|
135
|
+
})
|
|
136
|
+
.min(-180)
|
|
137
|
+
.max(180)
|
|
138
|
+
.description("Longitude in decimal degrees, WGS 84 (ISO 6709)"),
|
|
139
|
+
version: () =>
|
|
140
|
+
new FieldBuilder<string>("string")
|
|
141
|
+
.validator(validateSemVer)
|
|
142
|
+
.description("A semantic version string, e.g. '1.0.0'"),
|
|
143
|
+
countryCode: () =>
|
|
144
|
+
new FieldBuilder<string>("string")
|
|
145
|
+
.validator(validateCountryCode)
|
|
146
|
+
.description("An ISO 3166 country code, e.g. 'US', 'GB', 'FR'"),
|
|
147
|
+
languageCode: () =>
|
|
148
|
+
new FieldBuilder<string>("string")
|
|
149
|
+
.validator(validateLanguage)
|
|
150
|
+
.description(
|
|
151
|
+
"An BCP 47 structured language code, primarily ISO 639-1 and optionally with ISO 3166-1 alpha-2 country code, e.g. 'en', 'en-US', 'fr', 'fr-FR'"
|
|
152
|
+
),
|
|
14
153
|
};
|
package/src/schema.ts
CHANGED
|
@@ -11,9 +11,22 @@ import {
|
|
|
11
11
|
scrubPiiForDelete as piiScrubPiiForDelete,
|
|
12
12
|
} from "./pii.js";
|
|
13
13
|
import { FieldBuilder } from "./field.builder.js";
|
|
14
|
+
import { validateSemVer } from "./validation/version.SEMVER2.0.0.js";
|
|
14
15
|
|
|
15
16
|
const globalSchemaRegistry = new Map<string, Schema<any>>();
|
|
16
17
|
|
|
18
|
+
function cmpSemver(a: string, b: string): number {
|
|
19
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
20
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
const ai = pa[i] ?? 0;
|
|
23
|
+
const bi = pb[i] ?? 0;
|
|
24
|
+
if (ai > bi) return 1;
|
|
25
|
+
if (ai < bi) return -1;
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
function validateEnum(
|
|
18
31
|
parentKey: string,
|
|
19
32
|
value: any,
|
|
@@ -469,7 +482,7 @@ export function createSchema<S extends SchemaShape>(
|
|
|
469
482
|
_shape: S,
|
|
470
483
|
entityType: string,
|
|
471
484
|
options: SchemaOptions = {
|
|
472
|
-
version: "1.0",
|
|
485
|
+
version: "1.0.0",
|
|
473
486
|
table: "",
|
|
474
487
|
schemaValidator: () => true,
|
|
475
488
|
piiEnforcement: "none",
|
|
@@ -477,10 +490,10 @@ export function createSchema<S extends SchemaShape>(
|
|
|
477
490
|
): Schema<S> {
|
|
478
491
|
const systemFields = {
|
|
479
492
|
type: field.string().immutable().system(),
|
|
480
|
-
version: field.string().immutable().system(),
|
|
493
|
+
version: field.string().immutable().system().validator(validateSemVer),
|
|
481
494
|
};
|
|
482
495
|
|
|
483
|
-
const version = options.version || "1.0";
|
|
496
|
+
const version = options.version || "1.0.0";
|
|
484
497
|
const store = options.table || "";
|
|
485
498
|
const schema: Schema<S> = {
|
|
486
499
|
// 🔗 Define the schema shape
|
|
@@ -541,18 +554,72 @@ export function createSchema<S extends SchemaShape>(
|
|
|
541
554
|
);
|
|
542
555
|
if (shortCircuit) continue;
|
|
543
556
|
|
|
544
|
-
//
|
|
545
|
-
const
|
|
546
|
-
|
|
557
|
+
// A local validator for this field that returns errors instead of pushing into the outer array
|
|
558
|
+
const validateField = (val: any): string[] => {
|
|
559
|
+
const localErrors: string[] = [];
|
|
560
|
+
const { invalid } = runCustomValidator(
|
|
561
|
+
"",
|
|
562
|
+
key,
|
|
563
|
+
val,
|
|
564
|
+
def,
|
|
565
|
+
localErrors
|
|
566
|
+
);
|
|
567
|
+
if (!invalid) {
|
|
568
|
+
validateByType("", key, val, def, localErrors);
|
|
569
|
+
}
|
|
570
|
+
return localErrors;
|
|
571
|
+
};
|
|
547
572
|
|
|
548
|
-
//
|
|
549
|
-
|
|
573
|
+
// First pass validation
|
|
574
|
+
let fieldValue = value;
|
|
575
|
+
let fieldErrors = validateField(fieldValue);
|
|
576
|
+
|
|
577
|
+
// Attempt upgrade if invalid and an upgrader exists and entity is older than schema
|
|
578
|
+
const entityFrom = String(working.version ?? "0.0.0");
|
|
579
|
+
const entityTo = String(version);
|
|
580
|
+
const fieldTo = String((def as any)._version ?? entityTo);
|
|
581
|
+
const hasUpgrader = typeof (def as any)._upgrade === "function";
|
|
582
|
+
|
|
583
|
+
if (
|
|
584
|
+
fieldErrors.length > 0 &&
|
|
585
|
+
hasUpgrader &&
|
|
586
|
+
cmpSemver(entityFrom, entityTo) < 0
|
|
587
|
+
) {
|
|
588
|
+
const up = (def as any)._upgrade as (
|
|
589
|
+
value: any,
|
|
590
|
+
ctx: {
|
|
591
|
+
entityFrom: string;
|
|
592
|
+
entityTo: string;
|
|
593
|
+
fieldTo: string;
|
|
594
|
+
fieldName: string;
|
|
595
|
+
}
|
|
596
|
+
) => { ok: boolean; value?: any; error?: string };
|
|
597
|
+
|
|
598
|
+
const res = up(fieldValue, {
|
|
599
|
+
entityFrom,
|
|
600
|
+
entityTo,
|
|
601
|
+
fieldTo,
|
|
602
|
+
fieldName: key,
|
|
603
|
+
});
|
|
604
|
+
if (res && res.ok) {
|
|
605
|
+
fieldValue = res.value;
|
|
606
|
+
fieldErrors = validateField(fieldValue);
|
|
607
|
+
} else {
|
|
608
|
+
fieldErrors.push(
|
|
609
|
+
res?.error ||
|
|
610
|
+
`Failed to upgrade field ${key} from v${entityFrom} to v${entityTo}`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
550
614
|
|
|
551
|
-
|
|
552
|
-
|
|
615
|
+
result[key] = fieldValue;
|
|
616
|
+
// Apply or record errors
|
|
617
|
+
if (fieldErrors.length !== 0) {
|
|
618
|
+
errors.push(...fieldErrors);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
553
621
|
}
|
|
554
622
|
|
|
555
|
-
|
|
556
623
|
if (errors.length === 0 && options.schemaValidator) {
|
|
557
624
|
const castValue = result as Infer<S>;
|
|
558
625
|
if (!options.schemaValidator(castValue)) {
|
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Validates whether a string is a properly formatted ISO 8601 datetime.
|
|
3
|
-
*
|
|
2
|
+
* Validates whether a string is a properly formatted ISO 8601 datetime, date, or time.
|
|
3
|
+
*
|
|
4
|
+
* @param value - The value to validate.
|
|
5
|
+
* @param options - Optional settings for validation mode.
|
|
6
|
+
* @param options.mode - The mode of validation:
|
|
7
|
+
* - "datetime" (default): Checks full ISO 8601 datetime and equality with toISOString().
|
|
8
|
+
* - "date": Validates that the string is in YYYY-MM-DD format and represents a valid date.
|
|
9
|
+
* - "time": Validates that the string matches a valid HH:MM:SS(.sss)?Z? ISO 8601 time pattern.
|
|
10
|
+
* @returns True if the value is valid according to the specified mode; otherwise, false.
|
|
4
11
|
*/
|
|
5
|
-
export const validateDateTimeISO = (
|
|
12
|
+
export const validateDateTimeISO = (
|
|
13
|
+
value: unknown,
|
|
14
|
+
options?: { mode?: "datetime" | "date" | "time" }
|
|
15
|
+
): boolean => {
|
|
16
|
+
const mode = options?.mode ?? "datetime";
|
|
17
|
+
|
|
6
18
|
if (typeof value !== "string") return false;
|
|
7
|
-
|
|
8
|
-
|
|
19
|
+
|
|
20
|
+
if (mode === "datetime") {
|
|
21
|
+
// Strict ISO 8601 date-time: YYYY-MM-DDTHH:mm:ss(.fraction)?(Z|±HH:MM)
|
|
22
|
+
const isoDateTimeRegex =
|
|
23
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
24
|
+
|
|
25
|
+
if (!isoDateTimeRegex.test(value)) return false;
|
|
26
|
+
|
|
27
|
+
const date = new Date(value);
|
|
28
|
+
if (Number.isNaN(date.getTime())) return false;
|
|
29
|
+
|
|
30
|
+
// We purposely do NOT require `value === date.toISOString()` because
|
|
31
|
+
// valid ISO8601 inputs may include offsets (e.g., "+01:00") or omit
|
|
32
|
+
// milliseconds, both of which produce a different canonical ISO string.
|
|
33
|
+
// The regex ensures strict shape; Date parsing ensures it is a real moment.
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (mode === "date") {
|
|
38
|
+
// YYYY-MM-DD format
|
|
39
|
+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
40
|
+
if (!dateRegex.test(value)) return false;
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
if (isNaN(date.getTime())) return false;
|
|
43
|
+
// Ensure the date parts match exactly (to avoid 2023-02-30 being accepted)
|
|
44
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
45
|
+
return (
|
|
46
|
+
date.getUTCFullYear() === year &&
|
|
47
|
+
date.getUTCMonth() + 1 === month &&
|
|
48
|
+
date.getUTCDate() === day
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (mode === "time") {
|
|
53
|
+
// HH:MM:SS(.sss)?Z?
|
|
54
|
+
// Hours: 00-23, Minutes: 00-59, Seconds: 00-59, optional fractional seconds, optional Z
|
|
55
|
+
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?Z?$/;
|
|
56
|
+
return timeRegex.test(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
9
60
|
};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ISO 639-1 language codes (two-letter).
|
|
5
|
+
* Tip: Keep this list as source of truth for supported languages in your app.
|
|
6
|
+
*/
|
|
7
|
+
export enum IsoLanguageCode {
|
|
8
|
+
Afar = "aa",
|
|
9
|
+
Abkhazian = "ab",
|
|
10
|
+
Afrikaans = "af",
|
|
11
|
+
Akan = "ak",
|
|
12
|
+
Albanian = "sq",
|
|
13
|
+
Amharic = "am",
|
|
14
|
+
Arabic = "ar",
|
|
15
|
+
Aragonese = "an",
|
|
16
|
+
Armenian = "hy",
|
|
17
|
+
Assamese = "as",
|
|
18
|
+
Avaric = "av",
|
|
19
|
+
Aymara = "ay",
|
|
20
|
+
Azerbaijani = "az",
|
|
21
|
+
Bashkir = "ba",
|
|
22
|
+
Bambara = "bm",
|
|
23
|
+
Basque = "eu",
|
|
24
|
+
Belarusian = "be",
|
|
25
|
+
Bengali = "bn",
|
|
26
|
+
Bislama = "bi",
|
|
27
|
+
Bosnian = "bs",
|
|
28
|
+
Breton = "br",
|
|
29
|
+
Bulgarian = "bg",
|
|
30
|
+
Burmese = "my",
|
|
31
|
+
Catalan = "ca",
|
|
32
|
+
Chamorro = "ch",
|
|
33
|
+
Chechen = "ce",
|
|
34
|
+
Chinese = "zh",
|
|
35
|
+
ChurchSlavic = "cu",
|
|
36
|
+
Chuvash = "cv",
|
|
37
|
+
Cornish = "kw",
|
|
38
|
+
Corsican = "co",
|
|
39
|
+
Cree = "cr",
|
|
40
|
+
Croatian = "hr",
|
|
41
|
+
Czech = "cs",
|
|
42
|
+
Danish = "da",
|
|
43
|
+
Divehi = "dv",
|
|
44
|
+
Dutch = "nl",
|
|
45
|
+
Dzongkha = "dz",
|
|
46
|
+
English = "en",
|
|
47
|
+
Esperanto = "eo",
|
|
48
|
+
Estonian = "et",
|
|
49
|
+
Ewe = "ee",
|
|
50
|
+
Faroese = "fo",
|
|
51
|
+
Fijian = "fj",
|
|
52
|
+
Finnish = "fi",
|
|
53
|
+
French = "fr",
|
|
54
|
+
WesternFrisian = "fy",
|
|
55
|
+
Fulah = "ff",
|
|
56
|
+
Gaelic = "gd",
|
|
57
|
+
Galician = "gl",
|
|
58
|
+
Ganda = "lg",
|
|
59
|
+
Georgian = "ka",
|
|
60
|
+
German = "de",
|
|
61
|
+
Greek = "el",
|
|
62
|
+
Kalaallisut = "kl",
|
|
63
|
+
Guarani = "gn",
|
|
64
|
+
Gujarati = "gu",
|
|
65
|
+
Haitian = "ht",
|
|
66
|
+
Hausa = "ha",
|
|
67
|
+
Hebrew = "he",
|
|
68
|
+
Herero = "hz",
|
|
69
|
+
Hindi = "hi",
|
|
70
|
+
HiriMotu = "ho",
|
|
71
|
+
Hungarian = "hu",
|
|
72
|
+
Icelandic = "is",
|
|
73
|
+
Ido = "io",
|
|
74
|
+
Igbo = "ig",
|
|
75
|
+
Indonesian = "id",
|
|
76
|
+
Interlingua = "ia",
|
|
77
|
+
Interlingue = "ie",
|
|
78
|
+
Inuktitut = "iu",
|
|
79
|
+
Inupiaq = "ik",
|
|
80
|
+
Irish = "ga",
|
|
81
|
+
Italian = "it",
|
|
82
|
+
Japanese = "ja",
|
|
83
|
+
Javanese = "jv",
|
|
84
|
+
Kannada = "kn",
|
|
85
|
+
Kanuri = "kr",
|
|
86
|
+
Kashmiri = "ks",
|
|
87
|
+
Kazakh = "kk",
|
|
88
|
+
CentralKhmer = "km",
|
|
89
|
+
Kikuyu = "ki",
|
|
90
|
+
Kinyarwanda = "rw",
|
|
91
|
+
Kyrgyz = "ky",
|
|
92
|
+
Komi = "kv",
|
|
93
|
+
Kongo = "kg",
|
|
94
|
+
Korean = "ko",
|
|
95
|
+
Kuanyama = "kj",
|
|
96
|
+
Kurdish = "ku",
|
|
97
|
+
Lao = "lo",
|
|
98
|
+
Latin = "la",
|
|
99
|
+
Latvian = "lv",
|
|
100
|
+
Limburgan = "li",
|
|
101
|
+
Lingala = "ln",
|
|
102
|
+
Lithuanian = "lt",
|
|
103
|
+
LubaKatanga = "lu",
|
|
104
|
+
Luxembourgish = "lb",
|
|
105
|
+
Macedonian = "mk",
|
|
106
|
+
Malagasy = "mg",
|
|
107
|
+
Malay = "ms",
|
|
108
|
+
Malayalam = "ml",
|
|
109
|
+
Maltese = "mt",
|
|
110
|
+
Manx = "gv",
|
|
111
|
+
Maori = "mi",
|
|
112
|
+
Marathi = "mr",
|
|
113
|
+
Marshallese = "mh",
|
|
114
|
+
Mongolian = "mn",
|
|
115
|
+
Nauru = "na",
|
|
116
|
+
Navajo = "nv",
|
|
117
|
+
NorthNdebele = "nd",
|
|
118
|
+
SouthNdebele = "nr",
|
|
119
|
+
Ndonga = "ng",
|
|
120
|
+
Nepali = "ne",
|
|
121
|
+
Norwegian = "no",
|
|
122
|
+
NorwegianBokmal = "nb",
|
|
123
|
+
NorwegianNynorsk = "nn",
|
|
124
|
+
SichuanYi = "ii",
|
|
125
|
+
Occitan = "oc",
|
|
126
|
+
Ojibwa = "oj",
|
|
127
|
+
Oriya = "or",
|
|
128
|
+
Oromo = "om",
|
|
129
|
+
Ossetian = "os",
|
|
130
|
+
Pali = "pi",
|
|
131
|
+
Pashto = "ps",
|
|
132
|
+
Persian = "fa",
|
|
133
|
+
Polish = "pl",
|
|
134
|
+
Portuguese = "pt",
|
|
135
|
+
Punjabi = "pa",
|
|
136
|
+
Quechua = "qu",
|
|
137
|
+
Romansh = "rm",
|
|
138
|
+
Romanian = "ro",
|
|
139
|
+
Rundi = "rn",
|
|
140
|
+
Russian = "ru",
|
|
141
|
+
Samoan = "sm",
|
|
142
|
+
Sango = "sg",
|
|
143
|
+
Sanskrit = "sa",
|
|
144
|
+
Sardinian = "sc",
|
|
145
|
+
Serbian = "sr",
|
|
146
|
+
Shona = "sn",
|
|
147
|
+
Sindhi = "sd",
|
|
148
|
+
Sinhala = "si",
|
|
149
|
+
Slovak = "sk",
|
|
150
|
+
Slovenian = "sl",
|
|
151
|
+
Somali = "so",
|
|
152
|
+
SouthernSotho = "st",
|
|
153
|
+
Spanish = "es",
|
|
154
|
+
Sundanese = "su",
|
|
155
|
+
Swahili = "sw",
|
|
156
|
+
Swati = "ss",
|
|
157
|
+
Swedish = "sv",
|
|
158
|
+
Tagalog = "tl",
|
|
159
|
+
Tahitian = "ty",
|
|
160
|
+
Tajik = "tg",
|
|
161
|
+
Tamil = "ta",
|
|
162
|
+
Tatar = "tt",
|
|
163
|
+
Telugu = "te",
|
|
164
|
+
Thai = "th",
|
|
165
|
+
Tibetan = "bo",
|
|
166
|
+
Tigrinya = "ti",
|
|
167
|
+
Tonga = "to",
|
|
168
|
+
Tsonga = "ts",
|
|
169
|
+
Tswana = "tn",
|
|
170
|
+
Turkish = "tr",
|
|
171
|
+
Turkmen = "tk",
|
|
172
|
+
Twi = "tw",
|
|
173
|
+
Uighur = "ug",
|
|
174
|
+
Ukrainian = "uk",
|
|
175
|
+
Urdu = "ur",
|
|
176
|
+
Uzbek = "uz",
|
|
177
|
+
Venda = "ve",
|
|
178
|
+
Vietnamese = "vi",
|
|
179
|
+
Volapuk = "vo",
|
|
180
|
+
Walloon = "wa",
|
|
181
|
+
Welsh = "cy",
|
|
182
|
+
Wolof = "wo",
|
|
183
|
+
Xhosa = "xh",
|
|
184
|
+
Yiddish = "yi",
|
|
185
|
+
Yoruba = "yo",
|
|
186
|
+
Zhuang = "za",
|
|
187
|
+
Zulu = "zu",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fast lookup set for enum values (lowercase 2-letter codes). */
|
|
191
|
+
const ISO_LANGUAGE_SET: ReadonlySet<string> = new Set<string>(
|
|
192
|
+
Object.values(IsoLanguageCode)
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
/** Type guard: primary language must be one of the enum values. */
|
|
196
|
+
export function isIsoLanguageCode(value: unknown): value is IsoLanguageCode {
|
|
197
|
+
return typeof value === "string" && ISO_LANGUAGE_SET.has(value.toLowerCase());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Region validator per BCP 47:
|
|
202
|
+
* - ISO 3166-1 alpha-2: 2 uppercase letters (e.g., GB, US)
|
|
203
|
+
* - UN M.49 numeric: 3 digits (e.g., 419 for Latin America)
|
|
204
|
+
*
|
|
205
|
+
* NOTE: This validates *shape* not membership against the 3166 list.
|
|
206
|
+
* If you want hard membership, we can add a Set of all alpha-2 regions.
|
|
207
|
+
*/
|
|
208
|
+
export function isRegionSubtag(value: string): boolean {
|
|
209
|
+
return /^[A-Z]{2}$/.test(value) || /^\d{3}$/.test(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Script subtag per ISO 15924: one capital + three lowercase (e.g., Latn, Cyrl, Hans). */
|
|
213
|
+
export function isScriptSubtag(value: string): boolean {
|
|
214
|
+
return /^[A-Z][a-z]{3}$/.test(value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Variant subtag per BCP 47: 5–8 alnum, or 4 starting with a digit. */
|
|
218
|
+
export function isVariantSubtag(value: string): boolean {
|
|
219
|
+
return /^([0-9][A-Za-z0-9]{3}|[A-Za-z0-9]{5,8})$/.test(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Extension sequence: singleton (alnum except 'x') + one or more 2–8 alnum subtags. */
|
|
223
|
+
export function isExtensionSingleton(value: string): boolean {
|
|
224
|
+
return /^[0-9A-WY-Za-wy-z]$/.test(value); // any alnum except 'x' (private-use)
|
|
225
|
+
}
|
|
226
|
+
export function isExtensionSubtag(value: string): boolean {
|
|
227
|
+
return /^[A-Za-z0-9]{2,8}$/.test(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Private-use subtag: 'x' then one or more 1–8 alnum subtags. */
|
|
231
|
+
export function isPrivateUseSingleton(value: string): boolean {
|
|
232
|
+
return value.toLowerCase() === "x";
|
|
233
|
+
}
|
|
234
|
+
export function isPrivateUseSubtag(value: string): boolean {
|
|
235
|
+
return /^[A-Za-z0-9]{1,8}$/.test(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Validates:
|
|
240
|
+
* - plain language: "en"
|
|
241
|
+
* - language + region: "en-GB"
|
|
242
|
+
* - language + script + region: "sr-Cyrl-RS"
|
|
243
|
+
* - language + variants: "sl-rozaj-biske", "de-CH-1996"
|
|
244
|
+
* - extensions: "en-GB-u-ca-gregory"
|
|
245
|
+
* - private-use: "en-x-klingon" or just "x-piglatin"
|
|
246
|
+
*
|
|
247
|
+
* Returns true only if the primary language is in IsoLanguageCode
|
|
248
|
+
* and the rest of the tag conforms to BCP 47 structure.
|
|
249
|
+
*/
|
|
250
|
+
export function validateLanguage(value: unknown): boolean {
|
|
251
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
252
|
+
|
|
253
|
+
const parts = value.split("-");
|
|
254
|
+
let i = 0;
|
|
255
|
+
|
|
256
|
+
// 1) primary language (must be enum member; we use lowercase for comparison)
|
|
257
|
+
const lang = parts[i];
|
|
258
|
+
if (!lang || !isIsoLanguageCode(lang)) return false;
|
|
259
|
+
i += 1;
|
|
260
|
+
|
|
261
|
+
// 2) optional script
|
|
262
|
+
if (i < parts.length && isScriptSubtag(parts[i] as string)) {
|
|
263
|
+
i += 1;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3) optional region
|
|
267
|
+
if (i < parts.length && isRegionSubtag((parts[i] as string).toUpperCase())) {
|
|
268
|
+
// region must be uppercase if alpha; we normalize for check only
|
|
269
|
+
i += 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 4) zero or more variants
|
|
273
|
+
while (i < parts.length && isVariantSubtag((parts[i] as string))) {
|
|
274
|
+
i += 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 5) zero or more extensions
|
|
278
|
+
// extension = singleton ; 2–8 ; ( ; 2–8 )*
|
|
279
|
+
while (i < parts.length && isExtensionSingleton((parts[i] as string))) {
|
|
280
|
+
i += 1;
|
|
281
|
+
// must have at least one following subtag of length 2–8
|
|
282
|
+
if (!(i < parts.length && isExtensionSubtag(parts[i]!))) return false;
|
|
283
|
+
while (i < parts.length && isExtensionSubtag(parts[i]!)) {
|
|
284
|
+
i += 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 6) optional private-use: 'x' 1*('-' (1*8alnum))
|
|
289
|
+
if (i < parts.length && isPrivateUseSingleton(parts[i]!)) {
|
|
290
|
+
i += 1;
|
|
291
|
+
if (!(i < parts.length && isPrivateUseSubtag(parts[i]!))) return false;
|
|
292
|
+
while (i < parts.length && isPrivateUseSubtag(parts[i]!)) {
|
|
293
|
+
i += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// no leftovers
|
|
298
|
+
return i === parts.length;
|
|
299
|
+
}
|
|
@@ -4,5 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export function validateSemVer(value: unknown): boolean {
|
|
6
6
|
if (typeof value !== "string") return false;
|
|
7
|
-
return /^(\d
|
|
7
|
+
return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/.test(
|
|
8
|
+
value
|
|
9
|
+
);
|
|
8
10
|
}
|