@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/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
- // 4) Custom validator
545
- const { invalid } = runCustomValidator("", key, value, def, errors);
546
- if (invalid) continue;
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
- // 5) Type-specific validation
549
- validateByType("", key, value, def, errors);
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
- // Assign value regardless; storage transforms happen elsewhere
552
- result[key] = value;
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
- * Ensures the string parses as a valid Date and matches the canonical toISOString() format.
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 = (value: unknown): boolean => {
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
- const date = new Date(value);
8
- return !isNaN(date.getTime()) && value === date.toISOString();
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+)\.(\d+)\.(\d+)$/.test(value);
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
  }