@websline/cms-view-utils 1.10.2 → 1.15.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/dist/previewBridge.global.js +1 -1
- package/package.json +6 -5
- package/src/ads/createAdRunner.js +15 -1
- package/src/blocks/registry.js +34 -0
- package/src/client/fetchFormChallenge.js +21 -0
- package/src/client/submitForm.js +32 -0
- package/src/cms/fetchPage.js +7 -1
- package/src/cms/proxyToCms.js +35 -2
- package/src/cms/urlResolver.js +16 -3
- package/src/components/BlockEditList.svelte +50 -0
- package/src/components/BlockEditSlot.svelte +47 -0
- package/src/components/BlockSlot.astro +17 -0
- package/src/components/Picture.svelte +4 -2
- package/src/editor/editorContent.svelte.js +168 -0
- package/src/editor/editorProps.js +14 -0
- package/src/editor/notify.js +5 -0
- package/src/forms/createFormController.js +326 -0
- package/src/forms/fieldStates.js +175 -0
- package/src/forms/formControls.js +58 -0
- package/src/forms/formTexts.js +173 -0
- package/src/forms/formValues.js +101 -0
- package/src/forms/spamChallenge.js +61 -0
- package/src/index.js +12 -0
- package/src/routes/cmsProxy.js +9 -6
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a form brings along itself — buttons, captions, error messages —
|
|
3
|
+
* as opposed to the labels the editor wrote, which arrive with the form. Every
|
|
4
|
+
* language here works out of the box; a project replaces single texts through
|
|
5
|
+
* `overrides` without copying the rest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TEXTS = {
|
|
9
|
+
de: {
|
|
10
|
+
adults: "Erwachsene",
|
|
11
|
+
arrival: "Anreise",
|
|
12
|
+
back: "Zurück",
|
|
13
|
+
childAge: "Alter Kind {number}",
|
|
14
|
+
children: "Kinder",
|
|
15
|
+
choose: "Bitte wählen",
|
|
16
|
+
departure: "Abreise",
|
|
17
|
+
errorInvalid: "Bitte prüfen Sie diese Angabe.",
|
|
18
|
+
errorPastDate: "Dieses Datum liegt in der Vergangenheit.",
|
|
19
|
+
errorRequired: "Bitte füllen Sie dieses Feld aus.",
|
|
20
|
+
errorTooLong: "Dieser Text ist zu lang.",
|
|
21
|
+
next: "Weiter",
|
|
22
|
+
privacy: "Datenschutzerklärung",
|
|
23
|
+
required: "Pflichtfeld",
|
|
24
|
+
sendFailed:
|
|
25
|
+
"Das Formular konnte nicht gesendet werden. Bitte versuchen Sie es erneut.",
|
|
26
|
+
sending: "Wird gesendet …",
|
|
27
|
+
step: "Schritt {current} von {total}",
|
|
28
|
+
submit: "Absenden",
|
|
29
|
+
thanks: "Vielen Dank! Ihre Nachricht ist bei uns eingegangen.",
|
|
30
|
+
tooManyRequests:
|
|
31
|
+
"Zu viele Anfragen. Bitte versuchen Sie es in einer Minute erneut.",
|
|
32
|
+
years: "{count} Jahre",
|
|
33
|
+
},
|
|
34
|
+
en: {
|
|
35
|
+
adults: "Adults",
|
|
36
|
+
arrival: "Arrival",
|
|
37
|
+
back: "Back",
|
|
38
|
+
childAge: "Age of child {number}",
|
|
39
|
+
children: "Children",
|
|
40
|
+
choose: "Please choose",
|
|
41
|
+
departure: "Departure",
|
|
42
|
+
errorInvalid: "Please check this entry.",
|
|
43
|
+
errorPastDate: "This date is in the past.",
|
|
44
|
+
errorRequired: "Please fill in this field.",
|
|
45
|
+
errorTooLong: "This text is too long.",
|
|
46
|
+
next: "Next",
|
|
47
|
+
privacy: "Privacy policy",
|
|
48
|
+
required: "Required",
|
|
49
|
+
sendFailed: "The form could not be sent. Please try again.",
|
|
50
|
+
sending: "Sending …",
|
|
51
|
+
step: "Step {current} of {total}",
|
|
52
|
+
submit: "Send",
|
|
53
|
+
thanks: "Thank you! We have received your message.",
|
|
54
|
+
tooManyRequests: "Too many requests. Please try again in a minute.",
|
|
55
|
+
years: "{count} years",
|
|
56
|
+
},
|
|
57
|
+
it: {
|
|
58
|
+
adults: "Adulti",
|
|
59
|
+
arrival: "Arrivo",
|
|
60
|
+
back: "Indietro",
|
|
61
|
+
childAge: "Età bambino {number}",
|
|
62
|
+
children: "Bambini",
|
|
63
|
+
choose: "Si prega di scegliere",
|
|
64
|
+
departure: "Partenza",
|
|
65
|
+
errorInvalid: "Si prega di verificare questo dato.",
|
|
66
|
+
errorPastDate: "Questa data è nel passato.",
|
|
67
|
+
errorRequired: "Si prega di compilare questo campo.",
|
|
68
|
+
errorTooLong: "Questo testo è troppo lungo.",
|
|
69
|
+
next: "Avanti",
|
|
70
|
+
privacy: "Informativa sulla privacy",
|
|
71
|
+
required: "Campo obbligatorio",
|
|
72
|
+
sendFailed: "Non è stato possibile inviare il modulo. Si prega di riprovare.",
|
|
73
|
+
sending: "Invio in corso …",
|
|
74
|
+
step: "Passo {current} di {total}",
|
|
75
|
+
submit: "Invia",
|
|
76
|
+
thanks: "Grazie! Abbiamo ricevuto il suo messaggio.",
|
|
77
|
+
tooManyRequests: "Troppe richieste. Si prega di riprovare tra un minuto.",
|
|
78
|
+
years: "{count} anni",
|
|
79
|
+
},
|
|
80
|
+
fr: {
|
|
81
|
+
adults: "Adultes",
|
|
82
|
+
arrival: "Arrivée",
|
|
83
|
+
back: "Retour",
|
|
84
|
+
childAge: "Âge de l'enfant {number}",
|
|
85
|
+
children: "Enfants",
|
|
86
|
+
choose: "Veuillez choisir",
|
|
87
|
+
departure: "Départ",
|
|
88
|
+
errorInvalid: "Veuillez vérifier cette donnée.",
|
|
89
|
+
errorPastDate: "Cette date est dans le passé.",
|
|
90
|
+
errorRequired: "Veuillez remplir ce champ.",
|
|
91
|
+
errorTooLong: "Ce texte est trop long.",
|
|
92
|
+
next: "Suivant",
|
|
93
|
+
privacy: "Politique de confidentialité",
|
|
94
|
+
required: "Champ obligatoire",
|
|
95
|
+
sendFailed: "Le formulaire n'a pas pu être envoyé. Veuillez réessayer.",
|
|
96
|
+
sending: "Envoi en cours …",
|
|
97
|
+
step: "Étape {current} sur {total}",
|
|
98
|
+
submit: "Envoyer",
|
|
99
|
+
thanks: "Merci ! Nous avons bien reçu votre message.",
|
|
100
|
+
tooManyRequests: "Trop de demandes. Veuillez réessayer dans une minute.",
|
|
101
|
+
years: "{count} ans",
|
|
102
|
+
},
|
|
103
|
+
es: {
|
|
104
|
+
adults: "Adultos",
|
|
105
|
+
arrival: "Llegada",
|
|
106
|
+
back: "Atrás",
|
|
107
|
+
childAge: "Edad del niño {number}",
|
|
108
|
+
children: "Niños",
|
|
109
|
+
choose: "Por favor, elija",
|
|
110
|
+
departure: "Salida",
|
|
111
|
+
errorInvalid: "Por favor, compruebe este dato.",
|
|
112
|
+
errorPastDate: "Esta fecha está en el pasado.",
|
|
113
|
+
errorRequired: "Por favor, rellene este campo.",
|
|
114
|
+
errorTooLong: "Este texto es demasiado largo.",
|
|
115
|
+
next: "Siguiente",
|
|
116
|
+
privacy: "Política de privacidad",
|
|
117
|
+
required: "Campo obligatorio",
|
|
118
|
+
sendFailed:
|
|
119
|
+
"No se ha podido enviar el formulario. Por favor, inténtelo de nuevo.",
|
|
120
|
+
sending: "Enviando …",
|
|
121
|
+
step: "Paso {current} de {total}",
|
|
122
|
+
submit: "Enviar",
|
|
123
|
+
thanks: "¡Gracias! Hemos recibido su mensaje.",
|
|
124
|
+
tooManyRequests:
|
|
125
|
+
"Demasiadas solicitudes. Por favor, inténtelo de nuevo en un minuto.",
|
|
126
|
+
years: "{count} años",
|
|
127
|
+
},
|
|
128
|
+
nl: {
|
|
129
|
+
adults: "Volwassenen",
|
|
130
|
+
arrival: "Aankomst",
|
|
131
|
+
back: "Terug",
|
|
132
|
+
childAge: "Leeftijd kind {number}",
|
|
133
|
+
children: "Kinderen",
|
|
134
|
+
choose: "Maak een keuze",
|
|
135
|
+
departure: "Vertrek",
|
|
136
|
+
errorInvalid: "Controleer dit gegeven.",
|
|
137
|
+
errorPastDate: "Deze datum ligt in het verleden.",
|
|
138
|
+
errorRequired: "Vul dit veld in.",
|
|
139
|
+
errorTooLong: "Deze tekst is te lang.",
|
|
140
|
+
next: "Volgende",
|
|
141
|
+
privacy: "Privacybeleid",
|
|
142
|
+
required: "Verplicht veld",
|
|
143
|
+
sendFailed: "Het formulier kon niet worden verzonden. Probeer het opnieuw.",
|
|
144
|
+
sending: "Wordt verzonden …",
|
|
145
|
+
step: "Stap {current} van {total}",
|
|
146
|
+
submit: "Verzenden",
|
|
147
|
+
thanks: "Dank u! Wij hebben uw bericht ontvangen.",
|
|
148
|
+
tooManyRequests: "Te veel aanvragen. Probeer het over een minuut opnieuw.",
|
|
149
|
+
years: "{count} jaar",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const FALLBACK_LOCALE = "en";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The form's texts in one language. A text is looked up in the project's
|
|
157
|
+
* overrides for that language, then in the built-in one, then in English.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} locale - Language of the form, e.g. `"de"`.
|
|
160
|
+
* @param {Record<string, Record<string, string>>} [overrides] - Texts the project words differently, per language, e.g. `{ de: { next: "Fortfahren" } }`. Keep the `{placeholders}` of the original.
|
|
161
|
+
* @returns {(key: string, params?: Record<string, string | number>) => string} Unknown keys come back as the key itself.
|
|
162
|
+
*/
|
|
163
|
+
const createFormTexts = (locale, overrides = {}) => {
|
|
164
|
+
const sources = [overrides[locale], TEXTS[locale], TEXTS[FALLBACK_LOCALE]];
|
|
165
|
+
|
|
166
|
+
return (key, params = {}) => {
|
|
167
|
+
const template = sources.find((texts) => texts?.[key] != null)?.[key] ?? key;
|
|
168
|
+
|
|
169
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ""));
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export { createFormTexts, TEXTS as FORM_TEXTS };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a form's values look like in the browser and what of them is sent. The
|
|
3
|
+
* browser checks only type and `required` — the same patterns the CMS uses, so
|
|
4
|
+
* a value that passes here is not turned down for its format there. Whether a
|
|
5
|
+
* date has passed, an option exists or a text is too long stays with the CMS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
const PHONE_PATTERN = /^\+?[0-9 ()/.-]{3,40}$/;
|
|
10
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ADULTS = 2;
|
|
13
|
+
|
|
14
|
+
const isValidDate = (value) => {
|
|
15
|
+
if (typeof value !== "string" || !DATE_PATTERN.test(value)) return false;
|
|
16
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
17
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The fields of some layouts, top to bottom, without empty columns. */
|
|
21
|
+
const fieldsOf = (layouts) =>
|
|
22
|
+
layouts.flatMap((layout) => layout.columns ?? []).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
const emptyValueOf = (field) => {
|
|
25
|
+
if (field.type === "checkbox") return field.options?.length ? [] : false;
|
|
26
|
+
if (field.type === "daterange") return { from: "", to: "" };
|
|
27
|
+
if (field.type === "persons") return { adults: DEFAULT_ADULTS, children: [] };
|
|
28
|
+
return "";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Every field's starting value: what the URL pre-filled, else an empty one of its type. */
|
|
32
|
+
const initialValues = (form) =>
|
|
33
|
+
Object.fromEntries(
|
|
34
|
+
form.pages
|
|
35
|
+
.flatMap((page) => fieldsOf(page.layouts))
|
|
36
|
+
.map((field) => [field.name, field.value ?? emptyValueOf(field)]),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const isEmpty = (field, value) => {
|
|
40
|
+
if (value == null || value === false || value === "") return true;
|
|
41
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
42
|
+
if (field.type === "daterange") return !value.from && !value.to;
|
|
43
|
+
return typeof value === "string" && value.trim() === "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const CHECKERS = {
|
|
47
|
+
email: (value) => EMAIL_PATTERN.test(value.trim()),
|
|
48
|
+
phone: (value) => PHONE_PATTERN.test(value.trim()),
|
|
49
|
+
date: isValidDate,
|
|
50
|
+
daterange: ({ from, to }) => isValidDate(from) && isValidDate(to) && to >= from,
|
|
51
|
+
persons: ({ adults, children }) =>
|
|
52
|
+
adults >= 1 && children.every((age) => Number.isInteger(age)),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The errors of some fields, as the CMS would name them.
|
|
57
|
+
*
|
|
58
|
+
* @param {object[]} fields
|
|
59
|
+
* @param {Record<string, unknown>} values - Keyed by field name.
|
|
60
|
+
* @returns {Record<string, "REQUIRED" | "INVALID">} Keyed by field name; empty when all is well.
|
|
61
|
+
*/
|
|
62
|
+
const validateValues = (fields, values) => {
|
|
63
|
+
const errors = {};
|
|
64
|
+
|
|
65
|
+
for (const field of fields) {
|
|
66
|
+
const value = values[field.name];
|
|
67
|
+
|
|
68
|
+
if (isEmpty(field, value)) {
|
|
69
|
+
if (field.required) errors[field.name] = "REQUIRED";
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (CHECKERS[field.type] && !CHECKERS[field.type](value)) {
|
|
74
|
+
errors[field.name] = "INVALID";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return errors;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The values to send: empty ones left out, as the CMS does not store them anyway. */
|
|
82
|
+
const submissionValues = (fields, values) =>
|
|
83
|
+
Object.fromEntries(
|
|
84
|
+
fields
|
|
85
|
+
.filter((field) => !isEmpty(field, values[field.name]))
|
|
86
|
+
.map((field) => [field.name, values[field.name]]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A persons value with `count` children. Ages already chosen stay; a new child
|
|
91
|
+
* starts without one, so the guest has to pick it.
|
|
92
|
+
*/
|
|
93
|
+
const withChildCount = (value, count) => ({
|
|
94
|
+
...value,
|
|
95
|
+
children: Array.from(
|
|
96
|
+
{ length: count },
|
|
97
|
+
(_, index) => value.children[index] ?? null,
|
|
98
|
+
),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export { fieldsOf, initialValues, submissionValues, validateValues, withChildCount };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { solveChallenge } from "altcha-lib";
|
|
2
|
+
import { deriveKey } from "altcha-lib/algorithms/web/pbkdf2";
|
|
3
|
+
import { fetchFormChallenge } from "../client/fetchFormChallenge.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The form's ALTCHA proof of work, solved in the background. WebCrypto derives
|
|
7
|
+
* the keys off the main thread and the solver yields regularly, so the page
|
|
8
|
+
* stays responsive without a worker a website would have to bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A challenge this close to its end is replaced before it is sent. */
|
|
12
|
+
const EXPIRY_MARGIN_S = 30;
|
|
13
|
+
const solve = async (uuid) => {
|
|
14
|
+
const challenge = await fetchFormChallenge({ uuid });
|
|
15
|
+
const solution = await solveChallenge({ challenge, deriveKey });
|
|
16
|
+
|
|
17
|
+
if (!solution) throw new Error("Challenge not solved");
|
|
18
|
+
|
|
19
|
+
return { challenge, solution };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} uuid - The form's UUID.
|
|
24
|
+
* @param {() => number} [now] - Milliseconds; for tests.
|
|
25
|
+
*/
|
|
26
|
+
const createSpamChallenge = (uuid, now = () => Date.now()) => {
|
|
27
|
+
let pending = null;
|
|
28
|
+
|
|
29
|
+
const isFresh = (payload) =>
|
|
30
|
+
payload.challenge.parameters.expiresAt - EXPIRY_MARGIN_S > now() / 1_000;
|
|
31
|
+
|
|
32
|
+
const restart = () => {
|
|
33
|
+
const attempt = solve(uuid);
|
|
34
|
+
pending = attempt;
|
|
35
|
+
// A failed attempt is retried at the next call instead of failing forever.
|
|
36
|
+
attempt.catch(() => {
|
|
37
|
+
if (pending === attempt) pending = null;
|
|
38
|
+
});
|
|
39
|
+
return attempt;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/** Starts solving, once; later calls change nothing. */
|
|
44
|
+
start() {
|
|
45
|
+
if (!pending) restart();
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/** A solved, unexpired payload, solving or replacing one as needed. */
|
|
49
|
+
async payload() {
|
|
50
|
+
const payload = await (pending ?? restart());
|
|
51
|
+
return isFresh(payload) ? payload : restart();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Throws the current one away, after the CMS refused it. */
|
|
55
|
+
renew() {
|
|
56
|
+
return restart();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { createSpamChallenge };
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export { isValid } from "./editor/blockValidation.js";
|
|
2
|
+
export { assertHydrationWired, resolveBlockProps } from "./blocks/registry.js";
|
|
3
|
+
export { editorProps } from "./editor/editorProps.js";
|
|
4
|
+
export { createEditorContent } from "./editor/editorContent.svelte.js";
|
|
2
5
|
export { fetchSimilarRooms } from "./client/fetchSimilarRooms.js";
|
|
3
6
|
export { fetchSimilarPackages } from "./client/fetchSimilarPackages.js";
|
|
4
7
|
export { fetchFaqs } from "./client/fetchFaqs.js";
|
|
@@ -10,12 +13,21 @@ export { fetchRatings } from "./client/fetchRatings.js";
|
|
|
10
13
|
export { fetchAds } from "./client/fetchAds.js";
|
|
11
14
|
|
|
12
15
|
export { recordAdEvent } from "./client/recordAdEvent.js";
|
|
16
|
+
export { submitForm } from "./client/submitForm.js";
|
|
17
|
+
export { fetchFormChallenge } from "./client/fetchFormChallenge.js";
|
|
13
18
|
|
|
14
19
|
export { createAdRunner } from "./ads/createAdRunner.js";
|
|
15
20
|
export { slotOf } from "./ads/slots.js";
|
|
16
21
|
export { toAdPath } from "./ads/adPath.js";
|
|
17
22
|
|
|
23
|
+
export { createFormController } from "./forms/createFormController.js";
|
|
24
|
+
export { createFormTexts } from "./forms/formTexts.js";
|
|
25
|
+
export { FORM_CONTROLS } from "./forms/formControls.js";
|
|
26
|
+
|
|
18
27
|
export { default as AddBlockPlaceholder } from "./components/AddBlockPlaceholder.svelte";
|
|
28
|
+
export { default as BlockEditList } from "./components/BlockEditList.svelte";
|
|
29
|
+
export { default as BlockEditSlot } from "./components/BlockEditSlot.svelte";
|
|
30
|
+
export { default as BlockSlot } from "./components/BlockSlot.astro";
|
|
19
31
|
export { default as BlockWrapper } from "./components/BlockWrapper.svelte";
|
|
20
32
|
export { default as EditableBlock } from "./components/EditableBlock.svelte";
|
|
21
33
|
export { default as PageContent } from "./components/PageContent.svelte";
|
package/src/routes/cmsProxy.js
CHANGED
|
@@ -2,14 +2,17 @@ import { createCmsProxyHandler } from "../cms/proxyToCms.js";
|
|
|
2
2
|
|
|
3
3
|
export const prerender = false;
|
|
4
4
|
|
|
5
|
-
/** The only
|
|
6
|
-
const
|
|
5
|
+
/** The only writes a browser has any business making through this proxy. */
|
|
6
|
+
const WRITE_PATHS = [
|
|
7
|
+
/^api\/public\/ads\/[^/]+\/events$/,
|
|
8
|
+
/^api\/public\/forms\/[^/]+\/submissions$/,
|
|
9
|
+
];
|
|
7
10
|
|
|
8
11
|
export const GET = createCmsProxyHandler();
|
|
9
12
|
|
|
10
|
-
// Ad events
|
|
11
|
-
// this proxy. Narrowed to
|
|
12
|
-
// management scopes, and an open POST would hand them to every visitor.
|
|
13
|
+
// Ad events and form entries come from the browser, which can only reach the CMS
|
|
14
|
+
// through this proxy. Narrowed to those paths on purpose: the forwarded token may
|
|
15
|
+
// hold management scopes, and an open POST would hand them to every visitor.
|
|
13
16
|
export const POST = createCmsProxyHandler({
|
|
14
|
-
allow: (path) =>
|
|
17
|
+
allow: (path) => WRITE_PATHS.some((pattern) => pattern.test(path)),
|
|
15
18
|
});
|