@structured-id/ui-core 4.1.0 → 4.3.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.
@@ -3,7 +3,7 @@
3
3
  ref="inputRef"
4
4
  v-model="model"
5
5
  :label="fieldLabel"
6
- :rules="[requiredRule, typeRule]"
6
+ :rules="[typeRule]"
7
7
  :error="!!fieldError"
8
8
  :error-message="fieldError ?? undefined"
9
9
  outlined
@@ -18,7 +18,7 @@
18
18
  <template #prepend>
19
19
  <q-icon :name="iconName" :color="iconColor" size="24px" />
20
20
  </template>
21
- <template v-if="model && detectedType !== 'unknown'" #append>
21
+ <template v-if="showChip" #append>
22
22
  <q-chip
23
23
  dense
24
24
  :color="chipColor"
@@ -35,11 +35,12 @@
35
35
  <script setup lang="ts">
36
36
  import { ref, computed, watch } from "vue";
37
37
  import { isValidPhoneNumber } from "libphonenumber-js";
38
- import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
38
+ import { checkEmail, emailReady, initEmail } from "../utils/email";
39
39
 
40
40
  import {
41
41
  ALL_PRINCIPAL_TYPES,
42
42
  DEFAULT_PRINCIPAL_INPUT_TEXTS,
43
+ principalRecognizable,
43
44
  type AcceptedPrincipalType,
44
45
  type PrincipalInputTexts,
45
46
  type PrincipalType,
@@ -56,15 +57,12 @@ interface Props {
56
57
  */
57
58
  types?: AcceptedPrincipalType[];
58
59
  minUsernameLength?: number;
59
- required?: boolean;
60
60
  /** The label; without one the field names the accepted types. */
61
61
  label?: string;
62
62
  /** A refusal the server bound to this field, shown on it until edited. */
63
63
  fieldError?: string | null;
64
64
  /** Replacements for the field's texts. */
65
65
  texts?: Partial<PrincipalInputTexts>;
66
- /** What an empty required field says. */
67
- requiredText?: string;
68
66
  /**
69
67
  * Validate once the field is left, then on every change (true), or on
70
68
  * every change from the first keystroke (false). Default true: a field
@@ -76,13 +74,11 @@ interface Props {
76
74
  const props = withDefaults(defineProps<Props>(), {
77
75
  modelValue: "",
78
76
  types: () => [...ALL_PRINCIPAL_TYPES],
79
- // Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
77
+ // Code points of the NFC display form; the server's minimum decides.
80
78
  minUsernameLength: 3,
81
- required: true,
82
79
  label: undefined,
83
80
  fieldError: null,
84
81
  texts: () => ({}),
85
- requiredText: REQUIRED_FIELD_TEXT,
86
82
  lazyRules: true,
87
83
  });
88
84
 
@@ -96,13 +92,26 @@ const accepted = computed(() =>
96
92
  ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
97
93
  );
98
94
 
99
- const fieldLabel = computed(
100
- () => props.label ?? text.value.label(accepted.value),
101
- );
95
+ /**
96
+ * A field of one type is named by it; a field of several is a login field
97
+ * until its input is recognized as one of the accepted types.
98
+ */
99
+ const fieldLabel = computed(() => {
100
+ // An empty label is the form's own choice; only an absent one is derived.
101
+ if (props.label != null) return props.label;
102
+ const [only, ...others] = accepted.value;
103
+ if (only && others.length === 0) return text.value.label([only]);
104
+ const type = detectedType.value;
105
+ return type !== "unknown" && accepted.value.includes(type)
106
+ ? text.value.label([type])
107
+ : text.value.login;
108
+ });
102
109
 
103
110
  const emit = defineEmits<{
104
111
  "update:modelValue": [value: string];
105
112
  "update:principalType": [type: PrincipalType];
113
+ /** Whether the value can be submitted: a recognized, accepted, well-formed principal. */
114
+ "update:valid": [valid: boolean];
106
115
  }>();
107
116
 
108
117
  const inputRef = ref<{ focus: () => void } | null>(null);
@@ -114,10 +123,13 @@ const model = computed({
114
123
 
115
124
  const USERNAME_MAX_LENGTH = 32;
116
125
 
126
+ /** What a username may hold: letters of any script, marks, digits, `_`. */
127
+ const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
128
+
117
129
  function detectType(value: string): PrincipalType {
118
- if (!value) return "unknown";
130
+ // One or two characters are nobody yet: no type is read from them.
131
+ if (!principalRecognizable(value)) return "unknown";
119
132
  const trimmed = value.trim();
120
- if (!trimmed) return "unknown";
121
133
 
122
134
  // Phone: starts with +
123
135
  if (trimmed.startsWith("+")) return "phone";
@@ -125,12 +137,9 @@ function detectType(value: string): PrincipalType {
125
137
  // Email: contains @ (checked after phone to exclude edge cases)
126
138
  if (trimmed.includes("@")) return "email";
127
139
 
128
- // Username by its shape at any length, so that a short one is told the
129
- // minimum instead of being taken for no type at all. Uppercase is accepted
130
- // (the server derives the key); length and underscores are the rule's.
131
- if (/^[a-zA-Z0-9_]+$/.test(trimmed)) return "username";
132
-
133
- return "unknown";
140
+ // Anything else is a username attempt, whatever it holds, so that what is
141
+ // wrong with it (spaces, a dot, a symbol, its length) can be said.
142
+ return "username";
134
143
  }
135
144
 
136
145
  const detectedType = computed(() => detectType(model.value));
@@ -146,26 +155,37 @@ function onInput(value: string | number | null) {
146
155
 
147
156
  // --- Validation rules ---
148
157
 
149
- const requiredRule = (val: string) =>
150
- !props.required || !!val || props.requiredText;
151
-
158
+ /**
159
+ * Until a type is recognized nothing is wrong yet, an empty field included:
160
+ * the form keeps its button disabled instead (see principalRecognizable).
161
+ */
152
162
  const typeRule = (val: string): string | true => {
153
- if (!val) return true; // required rule handles empty
154
163
  const type = detectType(val);
155
- if (type === "unknown" || !accepted.value.includes(type)) {
164
+ if (type === "unknown") return true;
165
+ if (!accepted.value.includes(type)) {
156
166
  return text.value.enterAccepted(accepted.value);
157
167
  }
158
168
 
159
169
  switch (type) {
160
- case "email":
161
- return isValidEmail(val.trim()) || text.value.invalidEmail;
170
+ case "email": {
171
+ // Read so the rule re-runs once the parser has loaded.
172
+ void emailReady.value;
173
+ const check = checkEmail(val.trim());
174
+ // Nothing is wrong yet while the parser loads; `valid` stays false.
175
+ return check !== "invalid" || text.value.invalidEmail;
176
+ }
162
177
  case "phone":
163
178
  return isValidPhoneNumber(val) || text.value.invalidPhone;
164
179
  case "username": {
165
- const name = val.trim();
166
- if (name.length < props.minUsernameLength)
180
+ const name = val;
181
+ if (/\s/u.test(name)) return text.value.usernameSpaces;
182
+ if (!USERNAME_SHAPE.test(name)) return text.value.usernameCharacters;
183
+ // Counted in code points of the NFC form, as the server counts the
184
+ // display; the value itself is sent unchanged.
185
+ const length = [...name.normalize("NFC")].length;
186
+ if (length < props.minUsernameLength)
167
187
  return text.value.usernameTooShort(props.minUsernameLength);
168
- if (name.length > USERNAME_MAX_LENGTH)
188
+ if (length > USERNAME_MAX_LENGTH)
169
189
  return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
170
190
  if (name.startsWith("_") || name.endsWith("_"))
171
191
  return text.value.usernameUnderscoreEdge;
@@ -175,10 +195,63 @@ const typeRule = (val: string): string | true => {
175
195
  }
176
196
  };
177
197
 
198
+ /**
199
+ * A form keeps its submit disabled until this holds, silently: the error a
200
+ * rule names appears only once the field is left, the button never waits for
201
+ * it. A refusal the server bound to the field holds until the value changes.
202
+ */
203
+ const valid = computed(
204
+ () =>
205
+ detectedType.value !== "unknown" &&
206
+ typeRule(model.value) === true &&
207
+ // An email is not submittable before the parser has judged it.
208
+ (detectedType.value !== "email" || emailReady.value) &&
209
+ !props.fieldError,
210
+ );
211
+
212
+ // The application starts the parser at boot (initEmail with its namespace);
213
+ // a field that accepts email starts it when nobody did, also once its
214
+ // accepted types come to include email.
215
+ watch(
216
+ () => accepted.value.includes("email"),
217
+ (takesEmail) => {
218
+ if (takesEmail && !emailReady.value) {
219
+ initEmail().catch((error: unknown) => {
220
+ console.error("email address parser failed to load", error);
221
+ });
222
+ }
223
+ },
224
+ { immediate: true },
225
+ );
226
+
227
+ watch(valid, (v) => emit("update:valid", v), { immediate: true });
228
+
178
229
  // --- Dynamic icon & type ---
179
230
 
231
+ /** The recognized type when the field accepts it; refused types look neutral. */
232
+ const shownType = computed<PrincipalType>(() => {
233
+ const type = detectedType.value;
234
+ return type !== "unknown" && accepted.value.includes(type) ? type : "unknown";
235
+ });
236
+
237
+ /**
238
+ * The chip names the type only when the label does not: a derived label
239
+ * already says "Email" or "Username".
240
+ */
241
+ const showChip = computed(
242
+ () => props.label != null && shownType.value !== "unknown",
243
+ );
244
+
245
+ /** A field of one type shows that type's icon from the start, greyed until recognized. */
246
+ const iconType = computed<PrincipalType>(() => {
247
+ const [only, ...others] = accepted.value;
248
+ return shownType.value === "unknown" && only && others.length === 0
249
+ ? only
250
+ : shownType.value;
251
+ });
252
+
180
253
  const iconName = computed(() => {
181
- switch (detectedType.value) {
254
+ switch (iconType.value) {
182
255
  case "email":
183
256
  return "sym_o_mail";
184
257
  case "phone":
@@ -191,7 +264,7 @@ const iconName = computed(() => {
191
264
  });
192
265
 
193
266
  const iconColor = computed(() =>
194
- detectedType.value === "unknown" ? "grey-6" : "primary",
267
+ shownType.value === "unknown" ? "grey-6" : "primary",
195
268
  );
196
269
 
197
270
  // inputmode controls mobile keyboard WITHOUT triggering browser autofill.
@@ -211,13 +284,11 @@ const inputMode = computed(() => {
211
284
  });
212
285
 
213
286
  const chipLabel = computed(() =>
214
- detectedType.value === "unknown"
215
- ? ""
216
- : text.value.typeChip(detectedType.value),
287
+ shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
217
288
  );
218
289
 
219
290
  const chipColor = computed(() => {
220
- switch (detectedType.value) {
291
+ switch (shownType.value) {
221
292
  case "email":
222
293
  return "deep-orange";
223
294
  case "phone":
@@ -235,5 +306,5 @@ function focus() {
235
306
  inputRef.value?.focus();
236
307
  }
237
308
 
238
- defineExpose({ focus, detectedType });
309
+ defineExpose({ focus, detectedType, valid });
239
310
  </script>
@@ -13,6 +13,8 @@ export type {
13
13
  export {
14
14
  ALL_PRINCIPAL_TYPES,
15
15
  DEFAULT_PRINCIPAL_INPUT_TEXTS,
16
+ MIN_PRINCIPAL_LENGTH,
17
+ principalRecognizable,
16
18
  } from "./principalInputTexts";
17
19
  export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
18
20
  export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
@@ -12,10 +12,27 @@ export const ALL_PRINCIPAL_TYPES: readonly AcceptedPrincipalType[] = [
12
12
  "username",
13
13
  ];
14
14
 
15
+ /**
16
+ * Fewer characters than this are nobody yet: no type is recognized from them
17
+ * and no form can submit them. Counted in code points of the NFC form,
18
+ * surrounding whitespace aside.
19
+ */
20
+ export const MIN_PRINCIPAL_LENGTH = 3;
21
+
22
+ /** Whether `value` is long enough to be anybody. */
23
+ export function principalRecognizable(value: string): boolean {
24
+ return [...value.trim().normalize("NFC")].length >= MIN_PRINCIPAL_LENGTH;
25
+ }
26
+
15
27
  /** What the field says; each text can be replaced (translated) by the form. */
16
28
  export interface PrincipalInputTexts {
17
- /** The label for the accepted types, when the form gives none. */
29
+ /**
30
+ * The label naming one type: the field's only accepted type, or the type
31
+ * the input of a several-type field was recognized as.
32
+ */
18
33
  label: (types: AcceptedPrincipalType[]) => string;
34
+ /** The label of a field of several types before its input shows which. */
35
+ login: string;
19
36
  /** A value of a type the field does not accept, or of no type at all. */
20
37
  enterAccepted: (types: AcceptedPrincipalType[]) => string;
21
38
  /** The chip naming the type the value was recognized as. */
@@ -28,6 +45,10 @@ export interface PrincipalInputTexts {
28
45
  usernameTooLong: (max: number) => string;
29
46
  usernameUnderscoreEdge: string;
30
47
  usernameDoubleUnderscore: string;
48
+ /** A username with whitespace in or around it. */
49
+ usernameSpaces: string;
50
+ /** A username with a character other than a letter, digit or `_`. */
51
+ usernameCharacters: string;
31
52
  }
32
53
 
33
54
  /** `a`, `a or b`, `a, b, or c`. */
@@ -54,6 +75,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
54
75
  const text = list(types.map((t) => LABEL_WORDS[t]));
55
76
  return text.charAt(0).toUpperCase() + text.slice(1);
56
77
  },
78
+ login: "Login",
57
79
  enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
58
80
  typeChip: (type) => LABEL_WORDS[type],
59
81
  loginSubtitle: (types) =>
@@ -65,4 +87,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
65
87
  usernameUnderscoreEdge: "A username cannot start or end with an underscore",
66
88
  usernameDoubleUnderscore:
67
89
  "A username cannot contain two underscores in a row",
90
+ usernameSpaces: "A username cannot contain spaces",
91
+ usernameCharacters:
92
+ "A username can contain only letters, digits and underscores",
68
93
  };
@@ -0,0 +1,107 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import {
3
+ CORPORATE_BASELINE,
4
+ GLOBAL_PERSONAL,
5
+ LOCAL,
6
+ checkEmail,
7
+ emailPolicy,
8
+ initEmail,
9
+ isValidEmail,
10
+ } from "./email";
11
+ import { TEST_PSL } from "./email.testing";
12
+
13
+ // The admission the server applies (sid_authn::email): the same parser, the
14
+ // same configuration per namespace, so the field refuses what the server
15
+ // would refuse and accepts what it would accept.
16
+
17
+ describe("before the parser is loaded", () => {
18
+ // Nothing is judged without the parser: a hand-written guess would accept
19
+ // what the server refuses.
20
+ it("says pending and never valid", () => {
21
+ expect(checkEmail("user@example.com", LOCAL)).toBe("pending");
22
+ expect(isValidEmail("user@example.com", LOCAL)).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("with the parser", () => {
27
+ beforeAll(async () => {
28
+ await initEmail({ psl: TEST_PSL });
29
+ });
30
+
31
+ it("accepts an ordinary mailbox in every namespace", () => {
32
+ for (const policy of [GLOBAL_PERSONAL, CORPORATE_BASELINE, LOCAL]) {
33
+ expect(checkEmail("a.b+c@example.com", policy)).toBe("valid");
34
+ }
35
+ });
36
+
37
+ it("refuses what is never an address", () => {
38
+ for (const input of [
39
+ "",
40
+ "noat",
41
+ "@no-local.com",
42
+ "user@",
43
+ "spaces in@email.com",
44
+ "user@exa mple.com",
45
+ ]) {
46
+ expect(checkEmail(input, LOCAL), input).toBe("invalid");
47
+ }
48
+ });
49
+
50
+ // RFC 5321 §4.1.2: a quoted local part is a mailbox; comments and display
51
+ // names are header syntax and are not.
52
+ it("accepts a quoted local part, refuses header syntax", () => {
53
+ expect(checkEmail('"a b"@example.com', LOCAL)).toBe("valid");
54
+ expect(checkEmail('"a@b"@example.com', LOCAL)).toBe("valid");
55
+ expect(checkEmail("a(comment)@example.com", LOCAL)).toBe("invalid");
56
+ expect(checkEmail("Name <a@example.com>", LOCAL)).toBe("invalid");
57
+ });
58
+
59
+ it("refuses a local part left empty by the equality rules", () => {
60
+ expect(checkEmail("+tag@example.com", LOCAL)).toBe("invalid");
61
+ });
62
+
63
+ describe("the global Personal namespace", () => {
64
+ it("refuses non-ASCII in either part", () => {
65
+ expect(checkEmail("алиса@example.com", GLOBAL_PERSONAL)).toBe("invalid");
66
+ expect(checkEmail("user@münchen.de", GLOBAL_PERSONAL)).toBe("invalid");
67
+ });
68
+
69
+ it("refuses an A-label domain", () => {
70
+ expect(checkEmail("user@xn--mnchen-3ya.de", GLOBAL_PERSONAL)).toBe(
71
+ "invalid",
72
+ );
73
+ });
74
+
75
+ it("requires a public suffix", () => {
76
+ expect(checkEmail("admin@printer", GLOBAL_PERSONAL)).toBe("invalid");
77
+ expect(checkEmail("user@example.notatld", GLOBAL_PERSONAL)).toBe(
78
+ "invalid",
79
+ );
80
+ });
81
+ });
82
+
83
+ describe("an installation's own accounts", () => {
84
+ it("admits internal and internationalized mail domains", () => {
85
+ expect(checkEmail("admin@printer", LOCAL)).toBe("valid");
86
+ expect(checkEmail("user@münchen.de", LOCAL)).toBe("valid");
87
+ expect(checkEmail("алиса@example.com", LOCAL)).toBe("valid");
88
+ });
89
+ });
90
+
91
+ it("corporate baseline admits an internal domain, ASCII only", () => {
92
+ expect(checkEmail("admin@printer", CORPORATE_BASELINE)).toBe("valid");
93
+ expect(checkEmail("user@münchen.de", CORPORATE_BASELINE)).toBe("invalid");
94
+ });
95
+ });
96
+
97
+ describe("the application's namespace", () => {
98
+ it("is LOCAL unless the application names its own", async () => {
99
+ await initEmail();
100
+ expect(emailPolicy()).toBe(LOCAL);
101
+ await initEmail({ policy: GLOBAL_PERSONAL });
102
+ expect(emailPolicy()).toBe(GLOBAL_PERSONAL);
103
+ expect(isValidEmail("admin@printer")).toBe(false);
104
+ await initEmail({ policy: LOCAL });
105
+ expect(isValidEmail("admin@printer")).toBe(true);
106
+ });
107
+ });
@@ -0,0 +1,19 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import type { PslSource } from "./email";
4
+
5
+ /**
6
+ * The trie the application would serve, read from the installed package, for
7
+ * tests running without the application's host. Not cached, so a test never
8
+ * reads another run's copy.
9
+ */
10
+ export const TEST_PSL: PslSource = {
11
+ url: "psl.bin",
12
+ cache: false,
13
+ fetch: (async () => {
14
+ const path = createRequire(import.meta.url).resolve(
15
+ "@structured-world/structured-public-domains/psl.bin",
16
+ );
17
+ return new Response(await readFile(path));
18
+ }) as typeof fetch,
19
+ };
@@ -0,0 +1,165 @@
1
+ // An ambient declaration cannot be imported, and a consumer compiling this
2
+ // source sees it only through this reference.
3
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
4
+ /// <reference path="../assets.d.ts" />
5
+ import { readonly, ref, shallowRef, type Ref } from "vue";
6
+
7
+ /**
8
+ * Which mailboxes an email namespace admits, as the server's policy for it
9
+ * (sid_authn::email) does. Equality and the resolution key stay the
10
+ * server's: the field only refuses what the namespace never admits.
11
+ */
12
+ export interface EmailPolicy {
13
+ /** Accept non-ASCII characters in the local part (RFC 6531). */
14
+ readonly unicodeLocal: boolean;
15
+ /** Accept internationalized domains, in Unicode or A-label spelling. */
16
+ readonly idnDomain: boolean;
17
+ /** Accept a domain without a dot (an installation-local mail domain). */
18
+ readonly singleLabelDomain: boolean;
19
+ /** Require the domain to end in a Public Suffix List suffix. */
20
+ readonly publicSuffix: boolean;
21
+ }
22
+
23
+ /** The global SaaS/federated Personal namespace: ASCII, no IDN, a public mail domain. */
24
+ export const GLOBAL_PERSONAL: EmailPolicy = Object.freeze({
25
+ unicodeLocal: false,
26
+ idnDomain: false,
27
+ singleLabelDomain: false,
28
+ publicSuffix: true,
29
+ });
30
+
31
+ /** An organization's Corporate namespace before it sets its own rules. */
32
+ export const CORPORATE_BASELINE: EmailPolicy = Object.freeze({
33
+ unicodeLocal: false,
34
+ idnDomain: false,
35
+ singleLabelDomain: true,
36
+ publicSuffix: false,
37
+ });
38
+
39
+ /** An installation's own managed accounts (standalone). */
40
+ export const LOCAL: EmailPolicy = Object.freeze({
41
+ unicodeLocal: true,
42
+ idnDomain: true,
43
+ singleLabelDomain: true,
44
+ publicSuffix: false,
45
+ });
46
+
47
+ /** A verdict on one input; `pending` until the parser is loaded. */
48
+ export type EmailCheck = "valid" | "invalid" | "pending";
49
+
50
+ type Parser = typeof import("@structured-world/structured-email-address-ts");
51
+
52
+ /** How the Public Suffix List trie is fetched and cached. */
53
+ export type PslSource = NonNullable<
54
+ NonNullable<Parameters<Parser["init"]>[0]>["psl"]
55
+ >;
56
+
57
+ let parser: Parser | undefined;
58
+ let loading: Promise<Parser> | undefined;
59
+ // Reactive, so a verdict computed under it is recomputed when it changes.
60
+ const appPolicy = shallowRef<EmailPolicy>(LOCAL);
61
+ const loaded = ref(false);
62
+
63
+ /** Whether the parser is loaded; a rule depending on it re-runs when it is. */
64
+ export const emailReady: Readonly<Ref<boolean>> = readonly(loaded);
65
+
66
+ /**
67
+ * The trie this package ships, as the application's own asset: the bundler
68
+ * emits it beside the bundle, so the application's host serves it.
69
+ */
70
+ async function bundledPsl(): Promise<string> {
71
+ const asset =
72
+ await import("@structured-world/structured-public-domains/psl.bin?url");
73
+ return asset.default;
74
+ }
75
+
76
+ /**
77
+ * Load the parser (once) and set the application's email namespace, LOCAL
78
+ * unless named. The parser is the TypeScript port of the server's, checked
79
+ * byte-identical against it; its Public Suffix List trie comes from `psl`
80
+ * (`url`: the setting of where it is served, the application's own copy of
81
+ * it when unset; never a third-party CDN), cached in IndexedDB. An
82
+ * application calls this at start; a field starts it when nobody did.
83
+ */
84
+ export async function initEmail(options?: {
85
+ policy?: EmailPolicy;
86
+ psl?: PslSource;
87
+ }): Promise<void> {
88
+ if (options?.policy) appPolicy.value = options.policy;
89
+ loading ??= import("@structured-world/structured-email-address-ts").then(
90
+ async (module) => {
91
+ const url = options?.psl?.url ?? (await bundledPsl());
92
+ await module.init({ psl: { ...options?.psl, url } });
93
+ return module;
94
+ },
95
+ );
96
+ try {
97
+ parser = await loading;
98
+ } catch (error) {
99
+ // A failed load is retried by the next call, never cached as loaded.
100
+ loading = undefined;
101
+ throw error;
102
+ }
103
+ loaded.value = true;
104
+ }
105
+
106
+ /** The application's email namespace. */
107
+ export function emailPolicy(): EmailPolicy {
108
+ return appPolicy.value;
109
+ }
110
+
111
+ /** Whether a domain label is an IDNA A-label (`xn--`). */
112
+ function isALabel(label: string): boolean {
113
+ return label.slice(0, 4).toLowerCase() === "xn--";
114
+ }
115
+
116
+ /**
117
+ * Whether `value` is one mailbox the namespace admits: an RFC 5321 mailbox
118
+ * (dot-atom or quoted local part; no comments, display name or address
119
+ * literal) under the namespace's character, domain and suffix rules, with a
120
+ * local part that its equality rules (case, dots, `+tag`) do not empty.
121
+ */
122
+ export function checkEmail(
123
+ value: string,
124
+ policy: EmailPolicy = appPolicy.value,
125
+ ): EmailCheck {
126
+ if (!parser) return "pending";
127
+ if (
128
+ !policy.unicodeLocal &&
129
+ !policy.idnDomain &&
130
+ !/^[\x00-\x7f]*$/.test(value)
131
+ ) {
132
+ return "invalid";
133
+ }
134
+ let local: string;
135
+ let domain: string;
136
+ try {
137
+ const mailbox = parser.parse(value, {
138
+ strictness: "strict",
139
+ allowQuotedLocalPart: true,
140
+ casePolicy: "preserve",
141
+ dotPolicy: "preserve",
142
+ subaddress: "preserve",
143
+ allowSingleLabelDomain: policy.singleLabelDomain,
144
+ domainCheck: policy.publicSuffix ? "psl" : "syntax",
145
+ });
146
+ local = mailbox.localPart;
147
+ domain = mailbox.domain;
148
+ } catch {
149
+ return "invalid";
150
+ }
151
+ if (!policy.unicodeLocal && !/^[\x00-\x7f]*$/.test(local)) return "invalid";
152
+ // A Unicode domain arrives IDNA-encoded, so A-labels cover both spellings.
153
+ if (!policy.idnDomain && domain.split(".").some(isALabel)) return "invalid";
154
+ const base = local.toLowerCase().split("+", 1)[0] ?? "";
155
+ if (base.replaceAll(".", "") === "") return "invalid";
156
+ return "valid";
157
+ }
158
+
159
+ /** {@link checkEmail} as a yes/no; `false` while the parser is loading. */
160
+ export function isValidEmail(
161
+ value: string,
162
+ policy: EmailPolicy = appPolicy.value,
163
+ ): boolean {
164
+ return checkEmail(value, policy) === "valid";
165
+ }
@@ -1,5 +1,4 @@
1
1
  export {
2
- isValidEmail,
3
2
  isValidPhone,
4
3
  isValidUuid,
5
4
  isValidUsername,
@@ -8,6 +7,19 @@ export {
8
7
  isRequired,
9
8
  } from "./validators";
10
9
 
10
+ export {
11
+ CORPORATE_BASELINE,
12
+ GLOBAL_PERSONAL,
13
+ LOCAL,
14
+ checkEmail,
15
+ emailPolicy,
16
+ emailReady,
17
+ initEmail,
18
+ isValidEmail,
19
+ type EmailCheck,
20
+ type EmailPolicy,
21
+ } from "./email";
22
+
11
23
  export {
12
24
  formatDate,
13
25
  formatRelativeTime,