@getquick/content 0.3.1 → 0.4.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.
@@ -0,0 +1,294 @@
1
+ import { c as formDiagnostics, t as FORMS_PATH } from "./descriptor-x8kHFQ79.js";
2
+ import { n as readBoundedBody, t as isSameOrigin } from "./request-guards-BUPr_vA4.js";
3
+ import { n as honeypotFilled, r as validateSubmission } from "./validate-wRgYUNxN.js";
4
+ import { c as formStateLocation, n as formsCopyLanguage, o as formStateCookie, t as FORMS_COPY } from "./copy-DLto_bWj.js";
5
+ //#region frontend/src/forms/server.ts
6
+ /** The largest form body accepted (bytes); larger posts are refused unread. */
7
+ const MAX_FORM_BYTES = 65536;
8
+ /** Cloudflare's server-side Turnstile verification. */
9
+ const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
10
+ /** The field Turnstile's widget posts its token in. */
11
+ const TURNSTILE_RESPONSE_FIELD = "cf-turnstile-response";
12
+ const SOURCE = /^\/(?!\/)[^\s\\]*$/;
13
+ /** HTML as text: tags dropped, paragraphs and breaks as new lines, entities decoded. */
14
+ function htmlText(html) {
15
+ return html.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|h[1-6])>/gi, "\n\n").replace(/<[^>]*>/g, "").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))).replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16))).replace(/&nbsp;/g, " ").replace(/&quot;/g, "\"").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, 2e3);
16
+ }
17
+ /**
18
+ * The form endpoint. `handle` answers `POST /gq/forms/<id>` and null for any
19
+ * other path; `check` says what is configured, for readiness, never values.
20
+ */
21
+ function createFormsServer(config) {
22
+ const cms = new URL(config.cmsOrigin);
23
+ if (cms.protocol !== "https:" && cms.protocol !== "http:") throw new Error("cmsOrigin must be the CMS's http(s) origin");
24
+ const restRoot = (config.restRoot ?? `${cms.origin}/wp-json`).replace(/\/+$/, "");
25
+ if (new URL(restRoot).origin !== cms.origin) throw new Error("restRoot must be on the CMS's origin");
26
+ const origins = new Set((config.siteOrigins ?? []).map((origin) => new URL(origin).origin));
27
+ const timeoutMs = config.timeoutMs ?? 15e3;
28
+ const fetcher = config.fetch ?? globalThis.fetch.bind(globalThis);
29
+ const copy = FORMS_COPY[formsCopyLanguage(config.language)];
30
+ const turnstileRequired = config.spam?.turnstile === true;
31
+ const wantsJson = (request) => {
32
+ const accept = request.headers.get("Accept") ?? "";
33
+ return /\bapplication\/json\b/i.test(accept) && !/\btext\/html\b/i.test(accept);
34
+ };
35
+ const json = (status, body, headers = {}) => Response.json(body, {
36
+ status,
37
+ headers: {
38
+ "Cache-Control": "no-store",
39
+ ...headers
40
+ }
41
+ });
42
+ /** The Access pair, if both are set; "partial" is a misconfiguration and fails closed. */
43
+ function accessHeaders(bindings) {
44
+ const id = bindings.GQ_FORMS_ACCESS_CLIENT_ID?.trim() ?? "";
45
+ const secret = bindings.GQ_FORMS_ACCESS_CLIENT_SECRET?.trim() ?? "";
46
+ if (!id && !secret) return {};
47
+ if (!id || !secret) return "partial";
48
+ return {
49
+ "CF-Access-Client-Id": id,
50
+ "CF-Access-Client-Secret": secret
51
+ };
52
+ }
53
+ /** The answer to a post: JSON for a script that asks for it, else a redirect back. */
54
+ function outcome(request, state, source) {
55
+ const full = {
56
+ values: {},
57
+ messages: {},
58
+ errors: {},
59
+ ...state
60
+ };
61
+ if (wantsJson(request)) return json({
62
+ sent: 200,
63
+ invalid: 422,
64
+ verification: 422,
65
+ "too-many": 429,
66
+ failed: 502
67
+ }[full.status], {
68
+ status: full.status,
69
+ errors: Object.fromEntries(Object.entries(full.errors).map(([id, code]) => [id, full.messages[Number(id)] ?? copy.errors[code]])),
70
+ ...full.confirmation ? { confirmation: full.confirmation } : {}
71
+ });
72
+ return new Response(null, {
73
+ status: 303,
74
+ headers: {
75
+ Location: formStateLocation(source, full.formId),
76
+ "Set-Cookie": formStateCookie(full, { secure: new URL(request.url).protocol === "https:" }),
77
+ "Cache-Control": "no-store"
78
+ }
79
+ });
80
+ }
81
+ async function verifyTurnstile(secret, token, ip) {
82
+ const body = new URLSearchParams({
83
+ secret,
84
+ response: token
85
+ });
86
+ if (ip) body.set("remoteip", ip);
87
+ try {
88
+ const response = await fetcher(TURNSTILE_SITEVERIFY_URL, {
89
+ method: "POST",
90
+ body,
91
+ signal: AbortSignal.timeout(timeoutMs)
92
+ });
93
+ if (!response.ok) return false;
94
+ return (await response.json()).success === true;
95
+ } catch (error) {
96
+ console.error(`Forms: Turnstile couldn't be verified: ${error.name}`);
97
+ return false;
98
+ }
99
+ }
100
+ /** Gravity Forms' field messages, by field id. */
101
+ function fieldMessages(value) {
102
+ if (!value || typeof value !== "object") return {};
103
+ return Object.fromEntries(Object.entries(value).flatMap(([id, message]) => /^\d+$/.test(id) && typeof message === "string" && message ? [[Number(id), htmlText(message)]] : []));
104
+ }
105
+ async function submit(request, formId, source, params) {
106
+ const bindings = await config.bindings();
107
+ let descriptor;
108
+ try {
109
+ descriptor = await config.descriptor({
110
+ formId,
111
+ source
112
+ });
113
+ } catch (error) {
114
+ console.error(`Forms: form ${formId}'s descriptor couldn't be read: ${error.message}`);
115
+ descriptor = null;
116
+ }
117
+ if (!descriptor || descriptor.id !== formId || !descriptor.available) return outcome(request, {
118
+ formId,
119
+ status: "failed"
120
+ }, source);
121
+ const diagnostics = formDiagnostics(descriptor);
122
+ if (diagnostics.length) {
123
+ console.error(`Forms: form ${formId} isn't submitted: ${diagnostics.map((each) => each.code).join(", ")}`);
124
+ return outcome(request, {
125
+ formId,
126
+ status: "failed"
127
+ }, source);
128
+ }
129
+ const sent = {
130
+ formId,
131
+ status: "sent",
132
+ ...descriptor.confirmation.type === "message" && descriptor.confirmation.message ? { confirmation: descriptor.confirmation.message } : {}
133
+ };
134
+ if (honeypotFilled(descriptor, params)) return outcome(request, sent, source);
135
+ const checked = validateSubmission(descriptor, params);
136
+ if (Object.keys(checked.errors).length) return outcome(request, {
137
+ formId,
138
+ status: "invalid",
139
+ errors: checked.errors,
140
+ values: checked.values
141
+ }, source);
142
+ if (turnstileRequired) {
143
+ const secret = bindings.TURNSTILE_SECRET_KEY?.trim();
144
+ if (!secret) {
145
+ console.error("Forms: Turnstile is required but TURNSTILE_SECRET_KEY isn't bound");
146
+ return outcome(request, {
147
+ formId,
148
+ status: "failed",
149
+ values: checked.values
150
+ }, source);
151
+ }
152
+ const token = params.get("cf-turnstile-response") ?? "";
153
+ if (!token || token.length > 2048 || !await verifyTurnstile(secret, token, request.headers.get("CF-Connecting-IP"))) return outcome(request, {
154
+ formId,
155
+ status: "verification",
156
+ values: checked.values
157
+ }, source);
158
+ }
159
+ const access = accessHeaders(bindings);
160
+ if (access === "partial") {
161
+ console.error("Forms: set both GQ_FORMS_ACCESS_CLIENT_ID and GQ_FORMS_ACCESS_CLIENT_SECRET, or neither");
162
+ return outcome(request, {
163
+ formId,
164
+ status: "failed",
165
+ values: checked.values
166
+ }, source);
167
+ }
168
+ let response;
169
+ try {
170
+ response = await fetcher(`${restRoot}/gf/v2/forms/${formId}/submissions`, {
171
+ method: "POST",
172
+ headers: {
173
+ "Content-Type": "application/json",
174
+ Accept: "application/json",
175
+ ...access
176
+ },
177
+ body: JSON.stringify(checked.input),
178
+ redirect: "manual",
179
+ signal: AbortSignal.timeout(timeoutMs)
180
+ });
181
+ } catch (error) {
182
+ console.error(`Forms: form ${formId} couldn't reach the CMS: ${error.name}`);
183
+ return outcome(request, {
184
+ formId,
185
+ status: "failed",
186
+ values: checked.values
187
+ }, source);
188
+ }
189
+ let result = null;
190
+ try {
191
+ result = await response.json();
192
+ } catch {
193
+ result = null;
194
+ }
195
+ if (response.status === 200 && result?.is_valid === true) {
196
+ if (result.confirmation_type === "redirect" && typeof result.confirmation_redirect === "string") {
197
+ let target = null;
198
+ try {
199
+ target = new URL(result.confirmation_redirect);
200
+ } catch {
201
+ target = null;
202
+ }
203
+ if (target && target.origin === cms.origin) target = new URL(`${target.pathname}${target.search}${target.hash}`, request.url);
204
+ if (target && (target.protocol === "https:" || target.protocol === "http:")) {
205
+ const location = target.origin === new URL(request.url).origin ? `${target.pathname}${target.search}${target.hash}` : target.href;
206
+ if (wantsJson(request)) return json(200, {
207
+ status: "sent",
208
+ redirect: location
209
+ });
210
+ return new Response(null, {
211
+ status: 303,
212
+ headers: {
213
+ Location: location,
214
+ "Cache-Control": "no-store"
215
+ }
216
+ });
217
+ }
218
+ }
219
+ const message = typeof result.confirmation_message === "string" ? htmlText(result.confirmation_message) : "";
220
+ return outcome(request, {
221
+ formId,
222
+ status: "sent",
223
+ ...message ? { confirmation: message } : sent
224
+ }, source);
225
+ }
226
+ if (response.status === 400 && result?.is_valid === false) {
227
+ const messages = fieldMessages(result.validation_messages);
228
+ return outcome(request, {
229
+ formId,
230
+ status: "invalid",
231
+ errors: Object.fromEntries(Object.keys(messages).map((id) => [Number(id), "required"])),
232
+ messages,
233
+ values: checked.values
234
+ }, source);
235
+ }
236
+ console.error(`Forms: Gravity Forms refused form ${formId} (HTTP ${response.status})`);
237
+ return outcome(request, {
238
+ formId,
239
+ status: "failed",
240
+ values: checked.values
241
+ }, source);
242
+ }
243
+ /** `POST /gq/forms/<id>`. */
244
+ async function handlePost(request, formId) {
245
+ if (request.method !== "POST") return json(405, { error: "Send forms with POST." }, { Allow: "POST" });
246
+ if (!isSameOrigin(request, origins)) return json(403, { error: "Forms are accepted from this Site only." });
247
+ if (!/^application\/x-www-form-urlencoded\b/i.test(request.headers.get("Content-Type") ?? "")) return json(415, { error: "Send the form as application/x-www-form-urlencoded." });
248
+ const bindings = await config.bindings();
249
+ const text = await readBoundedBody(request, MAX_FORM_BYTES);
250
+ if (text === null) return json(413, { error: "The form is too large." });
251
+ const params = new URLSearchParams(text);
252
+ const source = params.get("gq_source") ?? "";
253
+ if (!SOURCE.test(source) || source.length > 2048) return json(400, { error: "The form's page (gq_source) isn't a path on this Site." });
254
+ const limiter = bindings.FORMS_RATE_LIMITER;
255
+ if (limiter) {
256
+ const { success } = await limiter.limit({ key: `${formId}:${request.headers.get("CF-Connecting-IP") ?? "unknown"}` });
257
+ if (!success) return outcome(request, {
258
+ formId,
259
+ status: "too-many"
260
+ }, source);
261
+ }
262
+ return submit(request, formId, source, params);
263
+ }
264
+ /** Routes a form post; null when the path isn't a form endpoint. */
265
+ async function handle(request) {
266
+ const path = new URL(request.url).pathname.replace(/\/+$/, "");
267
+ if (!path.startsWith(`/gq/forms/`)) return null;
268
+ const id = path.slice(FORMS_PATH.length + 1);
269
+ if (!/^[1-9]\d{0,8}$/.test(id)) return null;
270
+ return handlePost(request, Number(id));
271
+ }
272
+ /** What is configured, for readiness: never values. */
273
+ async function check() {
274
+ const bindings = await config.bindings();
275
+ const access = accessHeaders(bindings);
276
+ return {
277
+ cmsOrigin: cms.origin,
278
+ spam: {
279
+ honeypot: true,
280
+ turnstile: turnstileRequired
281
+ },
282
+ turnstileSecret: Boolean(bindings.TURNSTILE_SECRET_KEY?.trim()),
283
+ access: access === "partial" ? "partial" : Object.keys(access).length ? "configured" : "none",
284
+ rateLimit: Boolean(bindings.FORMS_RATE_LIMITER),
285
+ ready: (!turnstileRequired || Boolean(bindings.TURNSTILE_SECRET_KEY?.trim())) && access !== "partial"
286
+ };
287
+ }
288
+ return {
289
+ handle,
290
+ check
291
+ };
292
+ }
293
+ //#endregion
294
+ export { FORMS_PATH, MAX_FORM_BYTES, TURNSTILE_RESPONSE_FIELD, TURNSTILE_SITEVERIFY_URL, createFormsServer, htmlText };
@@ -0,0 +1,66 @@
1
+ import { o as FormDescriptor } from "./descriptor-B-e7gHi1.js";
2
+ //#region frontend/src/forms/testing.d.ts
3
+ /** A contact form like Nomia's form 1: name, email, subject, message and privacy consent. */
4
+ export declare const CONTACT_FORM: FormDescriptor;
5
+ /** A form readiness must fail: Gravity Forms' CAPTCHA, a file upload, a payment field and a page break. */
6
+ export declare const UNSUPPORTED_FORM: FormDescriptor;
7
+ /** A referenced form that is missing, inactive or trashed. */
8
+ export declare const UNAVAILABLE_FORM: FormDescriptor;
9
+ /** Keys that must never appear anywhere in a delivered descriptor. */
10
+ export declare const PRIVATE_FORM_KEYS: readonly ["notifications", "notification", "confirmations", "entries", "entry", "entry_id", "feeds", "feed", "settings", "personalData", "routing", "to", "bcc", "from", "fromName", "replyTo", "save", "limitEntries", "scheduleForm", "requireLogin", "consumer_key", "consumer_secret"];
11
+ /**
12
+ * Throws unless a delivered descriptor is public: it parses, and nothing in it
13
+ * is a notification, entry, feed or admin setting (by key, at any depth).
14
+ */
15
+ export declare function assertPublicDescriptor(value: unknown): FormDescriptor;
16
+ /**
17
+ * Throws unless rendered form HTML is accessible: every visible control has a
18
+ * label (`for`) or is in a labelled fieldset, every `aria-describedby` id
19
+ * exists, invalid controls are described by their error, an error summary is
20
+ * an alert and a confirmation is a status.
21
+ */
22
+ export declare function assertAccessibleForm(html: string): void;
23
+ /** A request Gravity Forms' stand-in received. */
24
+ export interface RecordedRequest {
25
+ method: string;
26
+ url: string;
27
+ headers: Record<string, string>;
28
+ body: Record<string, string> | null;
29
+ }
30
+ export interface StoredEntry {
31
+ formId: number;
32
+ values: Record<string, string>;
33
+ spam: boolean;
34
+ }
35
+ /**
36
+ * A stand-in for Gravity Forms' REST API on a CMS: `POST
37
+ * /wp-json/gf/v2/forms/<id>/submissions` as Gravity Forms 3.2 answers it,
38
+ * validating required fields and storing entries for the given descriptors.
39
+ * A filled honeypot is stored as spam, Gravity Forms' default honeypot
40
+ * action. Anything else is a 404. `requests` records every request.
41
+ */
42
+ export declare function gravityFormsStub(forms: readonly FormDescriptor[]): {
43
+ entries: StoredEntry[];
44
+ requests: RecordedRequest[];
45
+ handle: (request: Request) => Promise<Response>;
46
+ /** A `fetch` that answers the CMS's requests at `origin` and refuses any other. */
47
+ fetchFor(origin: string): typeof globalThis.fetch;
48
+ };
49
+ /** Throws if any request asked for entries, or carried a Gravity Forms credential. */
50
+ export declare function assertNoEntryReadsOrCredentials(requests: readonly RecordedRequest[]): void;
51
+ /** Throws if a module exports anything that reads entries (by name; these assertions aside). */
52
+ export declare function assertNoEntryExports(module: Record<string, unknown>): void;
53
+ /** A form body as a browser posts it (application/x-www-form-urlencoded). */
54
+ export declare function formBody(values: Record<string, string>): string;
55
+ /** A filled-in, valid submission of CONTACT_FORM from `source`. */
56
+ export declare function contactSubmission(source?: string): Record<string, string>;
57
+ /** Cloudflare's Turnstile test keys: always pass, always fail. */
58
+ export declare const TURNSTILE_TEST_KEYS: {
59
+ readonly passingSiteKey: "1x00000000000000000000AA";
60
+ readonly failingSiteKey: "2x00000000000000000000AB";
61
+ readonly passingSecretKey: "1x0000000000000000000000000000000AA";
62
+ readonly failingSecretKey: "2x0000000000000000000000000000000AA";
63
+ /** The token the test site keys produce. */
64
+ readonly dummyToken: "XXXX.DUMMY.TOKEN.XXXX";
65
+ };
66
+ //#endregion
@@ -0,0 +1,334 @@
1
+ import { u as parseFormDescriptor } from "./descriptor-x8kHFQ79.js";
2
+ //#region frontend/src/forms/testing.ts
3
+ /** A contact form like Nomia's form 1: name, email, subject, message and privacy consent. */
4
+ const CONTACT_FORM = {
5
+ schema: 1,
6
+ id: 1,
7
+ available: true,
8
+ title: "Contactos",
9
+ description: "Envie-nos uma mensagem.",
10
+ submitLabel: "Enviar",
11
+ fields: [
12
+ {
13
+ id: 1,
14
+ type: "name",
15
+ label: "Nome",
16
+ description: "",
17
+ required: true,
18
+ visibility: "visible",
19
+ inputs: [{
20
+ id: "1.3",
21
+ name: "input_1_3",
22
+ label: "Nome próprio",
23
+ autocomplete: "given-name"
24
+ }, {
25
+ id: "1.6",
26
+ name: "input_1_6",
27
+ label: "Apelido",
28
+ autocomplete: "family-name"
29
+ }]
30
+ },
31
+ {
32
+ id: 2,
33
+ type: "email",
34
+ label: "Email",
35
+ description: "",
36
+ required: true,
37
+ visibility: "visible",
38
+ inputs: [{
39
+ id: "2",
40
+ name: "input_2",
41
+ label: "Email"
42
+ }]
43
+ },
44
+ {
45
+ id: 3,
46
+ type: "text",
47
+ label: "Assunto",
48
+ description: "",
49
+ required: false,
50
+ visibility: "visible",
51
+ maxLength: 120,
52
+ inputs: [{
53
+ id: "3",
54
+ name: "input_3",
55
+ label: "Assunto"
56
+ }]
57
+ },
58
+ {
59
+ id: 4,
60
+ type: "textarea",
61
+ label: "Mensagem",
62
+ description: "",
63
+ required: true,
64
+ visibility: "visible",
65
+ inputs: [{
66
+ id: "4",
67
+ name: "input_4",
68
+ label: "Mensagem"
69
+ }]
70
+ },
71
+ {
72
+ id: 5,
73
+ type: "consent",
74
+ label: "Privacidade",
75
+ description: "",
76
+ required: true,
77
+ visibility: "visible",
78
+ inputs: [
79
+ {
80
+ id: "5.1",
81
+ name: "input_5_1",
82
+ label: "Consentimento"
83
+ },
84
+ {
85
+ id: "5.2",
86
+ name: "input_5_2",
87
+ label: "Texto"
88
+ },
89
+ {
90
+ id: "5.3",
91
+ name: "input_5_3",
92
+ label: "Descrição"
93
+ }
94
+ ],
95
+ consent: {
96
+ checkboxLabel: "Aceito o tratamento dos meus dados para responder a este contacto.",
97
+ description: "Os dados são usados apenas para responder à sua mensagem.",
98
+ revisionId: "7"
99
+ }
100
+ }
101
+ ],
102
+ honeypot: { name: "input_6" },
103
+ confirmation: {
104
+ type: "message",
105
+ message: "Obrigado pelo seu contacto."
106
+ },
107
+ diagnostics: []
108
+ };
109
+ /** A form readiness must fail: Gravity Forms' CAPTCHA, a file upload, a payment field and a page break. */
110
+ const UNSUPPORTED_FORM = {
111
+ ...CONTACT_FORM,
112
+ id: 2,
113
+ title: "Candidatura",
114
+ fields: CONTACT_FORM.fields.slice(0, 2),
115
+ diagnostics: [
116
+ {
117
+ code: "unsupported-captcha",
118
+ fieldId: 10,
119
+ type: "captcha",
120
+ message: "CAPTCHA"
121
+ },
122
+ {
123
+ code: "unsupported-file-upload",
124
+ fieldId: 11,
125
+ type: "fileupload",
126
+ message: "File upload"
127
+ },
128
+ {
129
+ code: "unsupported-payment",
130
+ fieldId: 12,
131
+ type: "product",
132
+ message: "Payment"
133
+ },
134
+ {
135
+ code: "unsupported-multi-page",
136
+ fieldId: 13,
137
+ type: "page",
138
+ message: "Multi-page"
139
+ }
140
+ ]
141
+ };
142
+ /** A referenced form that is missing, inactive or trashed. */
143
+ const UNAVAILABLE_FORM = {
144
+ ...CONTACT_FORM,
145
+ id: 3,
146
+ available: false,
147
+ fields: [],
148
+ honeypot: null,
149
+ diagnostics: [{
150
+ code: "unavailable",
151
+ message: "Form 3 is inactive."
152
+ }]
153
+ };
154
+ /** Keys that must never appear anywhere in a delivered descriptor. */
155
+ const PRIVATE_FORM_KEYS = [
156
+ "notifications",
157
+ "notification",
158
+ "confirmations",
159
+ "entries",
160
+ "entry",
161
+ "entry_id",
162
+ "feeds",
163
+ "feed",
164
+ "settings",
165
+ "personalData",
166
+ "routing",
167
+ "to",
168
+ "bcc",
169
+ "from",
170
+ "fromName",
171
+ "replyTo",
172
+ "save",
173
+ "limitEntries",
174
+ "scheduleForm",
175
+ "requireLogin",
176
+ "consumer_key",
177
+ "consumer_secret"
178
+ ];
179
+ function keysOf(value, path = "", found = []) {
180
+ if (Array.isArray(value)) value.forEach((each, index) => keysOf(each, `${path}[${index}]`, found));
181
+ else if (typeof value === "object" && value !== null) for (const [key, each] of Object.entries(value)) {
182
+ found.push(`${path}.${key}`);
183
+ keysOf(each, `${path}.${key}`, found);
184
+ }
185
+ return found;
186
+ }
187
+ /**
188
+ * Throws unless a delivered descriptor is public: it parses, and nothing in it
189
+ * is a notification, entry, feed or admin setting (by key, at any depth).
190
+ */
191
+ function assertPublicDescriptor(value) {
192
+ const descriptor = parseFormDescriptor(value);
193
+ if (!descriptor) throw new Error("Not a form descriptor");
194
+ const forbidden = keysOf(value).filter((path) => PRIVATE_FORM_KEYS.includes(path.slice(path.lastIndexOf(".") + 1)));
195
+ if (forbidden.length) throw new Error(`The descriptor carries private form data: ${forbidden.join(", ")}`);
196
+ return descriptor;
197
+ }
198
+ /**
199
+ * Throws unless rendered form HTML is accessible: every visible control has a
200
+ * label (`for`) or is in a labelled fieldset, every `aria-describedby` id
201
+ * exists, invalid controls are described by their error, an error summary is
202
+ * an alert and a confirmation is a status.
203
+ */
204
+ function assertAccessibleForm(html) {
205
+ const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]));
206
+ const labelled = new Set([...html.matchAll(/<label[^>]*\sfor="([^"]+)"/g)].map((m) => m[1]));
207
+ const problems = [];
208
+ for (const match of html.matchAll(/<(input|select|textarea)\b([^>]*)>/g)) {
209
+ const attributes = match[2];
210
+ if (/\stype="hidden"/.test(attributes)) continue;
211
+ const id = /\sid="([^"]+)"/.exec(attributes)?.[1];
212
+ if (!id || !labelled.has(id)) problems.push(`a ${match[1]} without a label (${id ?? "no id"})`);
213
+ const describedBy = /\saria-describedby="([^"]+)"/.exec(attributes)?.[1];
214
+ for (const reference of describedBy?.split(/\s+/) ?? []) if (!ids.has(reference)) problems.push(`${id} is described by a missing #${reference}`);
215
+ if (/\saria-invalid="true"/.test(attributes) && !/-error\b/.test(describedBy ?? "")) problems.push(`${id} is invalid without its error in aria-describedby`);
216
+ }
217
+ if (/gq-form__errors/.test(html) && !/class="gq-form__errors" role="alert"/.test(html)) problems.push("the error summary isn't an alert");
218
+ if (/gq-form__status--sent/.test(html) && !/gq-form__status--sent" role="status"/.test(html)) problems.push("the confirmation isn't a status message");
219
+ if (/<form\b/.test(html) && !/<button type="submit"/.test(html)) problems.push("the form has no submit button");
220
+ if (problems.length) throw new Error(`Inaccessible form: ${problems.join("; ")}`);
221
+ }
222
+ /**
223
+ * A stand-in for Gravity Forms' REST API on a CMS: `POST
224
+ * /wp-json/gf/v2/forms/<id>/submissions` as Gravity Forms 3.2 answers it,
225
+ * validating required fields and storing entries for the given descriptors.
226
+ * A filled honeypot is stored as spam, Gravity Forms' default honeypot
227
+ * action. Anything else is a 404. `requests` records every request.
228
+ */
229
+ function gravityFormsStub(forms) {
230
+ const entries = [];
231
+ const requests = [];
232
+ async function handle(request) {
233
+ const url = new URL(request.url);
234
+ let body = null;
235
+ try {
236
+ body = request.method === "POST" ? await request.json() : null;
237
+ } catch {
238
+ body = null;
239
+ }
240
+ requests.push({
241
+ method: request.method,
242
+ url: url.pathname + url.search,
243
+ headers: Object.fromEntries(request.headers),
244
+ body
245
+ });
246
+ const match = /^\/wp-json\/gf\/v2\/forms\/(\d+)\/submissions$/.exec(url.pathname);
247
+ const form = match && forms.find((each) => each.id === Number(match[1]));
248
+ if (!match || request.method !== "POST") return Response.json({ code: "rest_no_route" }, { status: 404 });
249
+ if (!form || !form.available) return Response.json({
250
+ code: "form_not_found",
251
+ message: "Your form could not be found"
252
+ }, { status: 400 });
253
+ const values = body ?? {};
254
+ const messages = {};
255
+ for (const field of form.fields) {
256
+ if (!field.required) continue;
257
+ if ((field.type === "consent" ? [field.inputs[0].name] : field.inputs.map((input) => input.name)).every((name) => !values[name])) messages[field.id] = "This field is required.";
258
+ }
259
+ if (Object.keys(messages).length) return Response.json({
260
+ is_valid: false,
261
+ validation_messages: messages,
262
+ page_number: 1,
263
+ source_page_number: 1
264
+ }, { status: 400 });
265
+ const spam = Boolean(form.honeypot && values[form.honeypot.name]);
266
+ entries.push({
267
+ formId: form.id,
268
+ values,
269
+ spam
270
+ });
271
+ return Response.json({
272
+ is_valid: true,
273
+ page_number: 0,
274
+ source_page_number: 1,
275
+ confirmation_type: "message",
276
+ confirmation_message: `<div id='gform_confirmation_wrapper_${form.id}' class='gform_confirmation_wrapper '><div id='gform_confirmation_message_${form.id}' class='gform_confirmation_message_${form.id} gform_confirmation_message'>${form.confirmation.type === "message" ? form.confirmation.message : ""}</div></div>`,
277
+ is_spam: spam
278
+ });
279
+ }
280
+ return {
281
+ entries,
282
+ requests,
283
+ handle,
284
+ /** A `fetch` that answers the CMS's requests at `origin` and refuses any other. */
285
+ fetchFor(origin) {
286
+ return async (input, init) => {
287
+ const request = new Request(input, init);
288
+ if (new URL(request.url).origin !== origin) throw new TypeError(`Unexpected request to ${new URL(request.url).origin}`);
289
+ return handle(request);
290
+ };
291
+ }
292
+ };
293
+ }
294
+ /** Throws if any request asked for entries, or carried a Gravity Forms credential. */
295
+ function assertNoEntryReadsOrCredentials(requests) {
296
+ for (const request of requests) {
297
+ if (/\/entries\b/.test(request.url)) throw new Error(`An entry read reached the CMS: ${request.method} ${request.url}`);
298
+ if (request.headers.authorization) throw new Error(`A credential reached the CMS: ${request.method} ${request.url}`);
299
+ if (/consumer_(key|secret)|oauth_/i.test(request.url)) throw new Error(`A consumer key reached the CMS: ${request.method} ${request.url}`);
300
+ }
301
+ }
302
+ /** Throws if a module exports anything that reads entries (by name; these assertions aside). */
303
+ function assertNoEntryExports(module) {
304
+ const names = Object.keys(module).filter((name) => /entr(y|ies)/i.test(name) && !name.startsWith("assertNo"));
305
+ if (names.length) throw new Error(`Exports that read entries: ${names.join(", ")}`);
306
+ }
307
+ /** A form body as a browser posts it (application/x-www-form-urlencoded). */
308
+ function formBody(values) {
309
+ return new URLSearchParams(values).toString();
310
+ }
311
+ /** A filled-in, valid submission of CONTACT_FORM from `source`. */
312
+ function contactSubmission(source = "/contactos/") {
313
+ return {
314
+ gq_source: source,
315
+ input_1_3: "Ana",
316
+ input_1_6: "Silva",
317
+ input_2: "ana@example.com",
318
+ input_3: "Pergunta",
319
+ input_4: "Olá, gostava de saber mais.",
320
+ input_5_1: "1",
321
+ input_6: ""
322
+ };
323
+ }
324
+ /** Cloudflare's Turnstile test keys: always pass, always fail. */
325
+ const TURNSTILE_TEST_KEYS = {
326
+ passingSiteKey: "1x00000000000000000000AA",
327
+ failingSiteKey: "2x00000000000000000000AB",
328
+ passingSecretKey: "1x0000000000000000000000000000000AA",
329
+ failingSecretKey: "2x0000000000000000000000000000000AA",
330
+ /** The token the test site keys produce. */
331
+ dummyToken: "XXXX.DUMMY.TOKEN.XXXX"
332
+ };
333
+ //#endregion
334
+ export { CONTACT_FORM, PRIVATE_FORM_KEYS, TURNSTILE_TEST_KEYS, UNAVAILABLE_FORM, UNSUPPORTED_FORM, assertAccessibleForm, assertNoEntryExports, assertNoEntryReadsOrCredentials, assertPublicDescriptor, contactSubmission, formBody, gravityFormsStub };