@getquick/content 0.3.2 → 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.
- package/README.md +6 -0
- package/dist/consent-astro.js +1 -1
- package/dist/consent-server.js +4 -37
- package/dist/consent.js +1 -1
- package/dist/copy-DLto_bWj.js +163 -0
- package/dist/descriptor-B-e7gHi1.d.ts +143 -0
- package/dist/descriptor-x8kHFQ79.js +205 -0
- package/dist/forms-astro.d.ts +50 -0
- package/dist/forms-astro.js +193 -0
- package/dist/forms-server.d.ts +80 -0
- package/dist/forms-server.js +294 -0
- package/dist/forms-testing.d.ts +66 -0
- package/dist/forms-testing.js +334 -0
- package/dist/forms.d.ts +25 -0
- package/dist/forms.js +4 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/request-guards-BUPr_vA4.js +46 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +158 -9
- package/dist/{site-language-BcPac0Ia.js → site-language-BCGjkpWY.js} +67780 -46785
- package/dist/state-CsMFLUUO.d.ts +66 -0
- package/dist/validate-wRgYUNxN.js +130 -0
- package/dist/{version-DkpQm85L.js → version-D81MsCZU.js} +1 -1
- package/dist/{wordpress-C1xs3xHN.d.ts → wordpress-BvkTDMA0.d.ts} +67 -2
- package/package.json +17 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { a as blockFormId, o as findForm, r as GRAVITY_FORMS_BLOCK, s as formAction } from "./descriptor-x8kHFQ79.js";
|
|
2
|
+
import { a as clearFormStateCookie, i as FORM_STATE_PARAM, l as readFormState, n as formsCopyLanguage, t as FORMS_COPY } from "./copy-DLto_bWj.js";
|
|
3
|
+
//#region frontend/src/forms/astro.ts
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
6
|
+
}
|
|
7
|
+
/** Text without tags: Gravity Forms allows HTML in a consent's checkbox label. */
|
|
8
|
+
const tagless = (value) => value.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
|
9
|
+
const attribute = (name, value) => value === null || value === void 0 ? "" : ` ${name}="${escapeHtml(value)}"`;
|
|
10
|
+
/** The HTML id of an input (`gq-form-1-input_1_3`). */
|
|
11
|
+
const inputId = (formId, name) => `gq-form-${formId}-${name}`;
|
|
12
|
+
const AUTOCOMPLETE = {
|
|
13
|
+
email: "email",
|
|
14
|
+
phone: "tel",
|
|
15
|
+
website: "url"
|
|
16
|
+
};
|
|
17
|
+
const INPUT_TYPE = {
|
|
18
|
+
text: "text",
|
|
19
|
+
email: "email",
|
|
20
|
+
phone: "tel",
|
|
21
|
+
website: "url",
|
|
22
|
+
number: "text"
|
|
23
|
+
};
|
|
24
|
+
function errorText(field, context) {
|
|
25
|
+
const message = context.state?.messages[field.id];
|
|
26
|
+
if (message) return message;
|
|
27
|
+
const code = context.state?.errors[field.id];
|
|
28
|
+
return code ? context.copy.errors[code] : null;
|
|
29
|
+
}
|
|
30
|
+
/** The value to show: what the visitor typed, after a failed post, else the field's default. */
|
|
31
|
+
/** What the visitor typed, when the outcome kept it; otherwise the form shows its defaults. */
|
|
32
|
+
function typed(context) {
|
|
33
|
+
const state = context.state;
|
|
34
|
+
return state && state.status !== "sent" && Object.keys(state.values).length ? state.values : null;
|
|
35
|
+
}
|
|
36
|
+
function shown(name, field, context) {
|
|
37
|
+
const values = typed(context);
|
|
38
|
+
if (values) return values[name] ?? "";
|
|
39
|
+
return field.defaultValue ?? "";
|
|
40
|
+
}
|
|
41
|
+
function requiredMark(field, copy) {
|
|
42
|
+
return field.required ? ` <span class="gq-form__required" aria-hidden="true">*</span><span class="gq-form__visually-hidden"> (${escapeHtml(copy.required)})</span>` : "";
|
|
43
|
+
}
|
|
44
|
+
/** The description and error elements of a field, and the ids that describe its input. */
|
|
45
|
+
function annotations(field, context) {
|
|
46
|
+
const base = `gq-form-${context.formId}-field-${field.id}`;
|
|
47
|
+
const error = errorText(field, context);
|
|
48
|
+
const ids = [];
|
|
49
|
+
let html = "";
|
|
50
|
+
if (field.description) {
|
|
51
|
+
ids.push(`${base}-description`);
|
|
52
|
+
html += `<p class="gq-form__description" id="${base}-description">${escapeHtml(field.description)}</p>`;
|
|
53
|
+
}
|
|
54
|
+
if (error) {
|
|
55
|
+
ids.push(`${base}-error`);
|
|
56
|
+
html += `<p class="gq-form__error" id="${base}-error">${escapeHtml(error)}</p>`;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
html,
|
|
60
|
+
describedBy: ids.length ? ids.join(" ") : null,
|
|
61
|
+
invalid: Boolean(error),
|
|
62
|
+
base
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function inputAttributes(field, context, notes) {
|
|
66
|
+
return attribute("aria-describedby", notes.describedBy) + (notes.invalid ? " aria-invalid=\"true\"" : "") + (field.required ? " required aria-required=\"true\"" : "");
|
|
67
|
+
}
|
|
68
|
+
function fieldClass(field, invalid, extra = "") {
|
|
69
|
+
return `gq-form__field gq-form__field--${field.type}${extra}` + (invalid ? " gq-form__field--invalid" : "") + (field.cssClass ? ` ${field.cssClass}` : "");
|
|
70
|
+
}
|
|
71
|
+
function renderField(field, context) {
|
|
72
|
+
const { formId, copy } = context;
|
|
73
|
+
if (field.type === "html") return `<div class="gq-form__html${field.cssClass ? ` ${escapeHtml(field.cssClass)}` : ""}">${field.content ?? ""}</div>`;
|
|
74
|
+
if (field.type === "section") return `<div class="gq-form__section"><h3 class="gq-form__section-title">${escapeHtml(field.label)}</h3>` + (field.description ? `<p class="gq-form__description">${escapeHtml(field.description)}</p>` : "") + `</div>`;
|
|
75
|
+
if (field.type === "hidden" || field.visibility === "hidden") return field.inputs.map((input) => `<input type="hidden" name="${escapeHtml(input.name)}" value="${escapeHtml(field.defaultValue ?? "")}">`).join("");
|
|
76
|
+
const notes = annotations(field, context);
|
|
77
|
+
const first = field.inputs[0];
|
|
78
|
+
if (!first) return "";
|
|
79
|
+
const classes = escapeHtml(fieldClass(field, notes.invalid));
|
|
80
|
+
const label = escapeHtml(field.label) + requiredMark(field, copy);
|
|
81
|
+
if (field.type === "radio" || field.type === "checkbox") {
|
|
82
|
+
const options = (field.choices ?? []).map((choice, index) => {
|
|
83
|
+
const input = field.type === "checkbox" ? field.inputs[index] : first;
|
|
84
|
+
if (!input) return "";
|
|
85
|
+
const id = `${inputId(formId, input.name)}-${index}`;
|
|
86
|
+
const values = typed(context);
|
|
87
|
+
const checked = values ? values[input.name] === choice.value : choice.selected;
|
|
88
|
+
return `<div class="gq-form__choice"><input type="${field.type}" id="${id}" name="${escapeHtml(input.name)}" value="${escapeHtml(choice.value)}"${checked ? " checked" : ""}${attribute("aria-describedby", notes.describedBy)}${notes.invalid ? " aria-invalid=\"true\"" : ""}><label for="${id}">${escapeHtml(choice.text)}</label></div>`;
|
|
89
|
+
});
|
|
90
|
+
return `<fieldset class="${classes}"${field.required ? " aria-required=\"true\"" : ""}><legend class="gq-form__label">${label}</legend>` + notes.html + options.join("") + `</fieldset>`;
|
|
91
|
+
}
|
|
92
|
+
if (field.type === "consent") {
|
|
93
|
+
const id = inputId(formId, first.name);
|
|
94
|
+
const checked = shown(first.name, field, context) === "1";
|
|
95
|
+
const policy = context.privacyPolicy ? ` <a class="gq-form__policy" href="${escapeHtml(context.privacyPolicy.url)}">${escapeHtml(context.privacyPolicy.title)}</a>` : "";
|
|
96
|
+
const consentText = field.consent?.description ? `<p class="gq-form__description gq-form__consent-text" id="${notes.base}-text">${escapeHtml(field.consent.description)}</p>` : "";
|
|
97
|
+
const describedBy = [notes.describedBy, consentText ? `${notes.base}-text` : null].filter(Boolean).join(" ");
|
|
98
|
+
return `<fieldset class="${classes}"><legend class="gq-form__label">${label}</legend>` + notes.html + consentText + `<div class="gq-form__choice"><input type="checkbox" id="${id}" name="${escapeHtml(first.name)}" value="1"${checked ? " checked" : ""}${attribute("aria-describedby", describedBy || null)}${notes.invalid ? " aria-invalid=\"true\"" : ""}${field.required ? " required aria-required=\"true\"" : ""}><label for="${id}">${escapeHtml(tagless(field.consent?.checkboxLabel ?? "") || field.label)}</label>${policy}</div></fieldset>`;
|
|
99
|
+
}
|
|
100
|
+
if (field.type === "name" && field.inputs.length > 1) {
|
|
101
|
+
const parts = field.inputs.map((input) => {
|
|
102
|
+
const id = inputId(formId, input.name);
|
|
103
|
+
return `<div class="gq-form__name-part"><label for="${id}">${escapeHtml(input.label)}</label><input type="text" id="${id}" name="${escapeHtml(input.name)}" value="${escapeHtml(shown(input.name, field, context))}"${attribute("autocomplete", input.autocomplete)}${attribute("placeholder", input.placeholder)}${attribute("aria-describedby", notes.describedBy)}${notes.invalid ? " aria-invalid=\"true\"" : ""}></div>`;
|
|
104
|
+
});
|
|
105
|
+
return `<fieldset class="${classes}"${field.required ? " aria-required=\"true\"" : ""}><legend class="gq-form__label">${label}</legend>` + notes.html + parts.join("") + `</fieldset>`;
|
|
106
|
+
}
|
|
107
|
+
const id = inputId(formId, first.name);
|
|
108
|
+
const common = attribute("placeholder", field.placeholder) + (field.maxLength ? ` maxlength="${field.maxLength}"` : "") + inputAttributes(field, context, notes);
|
|
109
|
+
let control;
|
|
110
|
+
if (field.type === "textarea") control = `<textarea id="${id}" name="${escapeHtml(first.name)}" rows="6"${common}>${escapeHtml(shown(first.name, field, context))}</textarea>`;
|
|
111
|
+
else if (field.type === "select") {
|
|
112
|
+
const value = typed(context) ? shown(first.name, field, context) : null;
|
|
113
|
+
const choices = field.choices ?? [];
|
|
114
|
+
const options = (choices.some((choice) => choice.value === "") ? "" : `<option value=""${value === "" ? " selected" : ""}></option>`) + choices.map((choice) => {
|
|
115
|
+
const selected = value !== null ? value === choice.value : choice.selected;
|
|
116
|
+
return `<option value="${escapeHtml(choice.value)}"${selected ? " selected" : ""}>${escapeHtml(choice.text)}</option>`;
|
|
117
|
+
}).join("");
|
|
118
|
+
control = `<select id="${id}" name="${escapeHtml(first.name)}"${inputAttributes(field, context, notes)}>${options}</select>`;
|
|
119
|
+
} else {
|
|
120
|
+
const inputmode = field.type === "number" ? " inputmode=\"decimal\"" : "";
|
|
121
|
+
const autocomplete = field.type === "name" ? "name" : AUTOCOMPLETE[field.type] ?? first.autocomplete;
|
|
122
|
+
control = `<input type="${INPUT_TYPE[field.type] ?? "text"}" id="${id}" name="${escapeHtml(first.name)}" value="${escapeHtml(shown(first.name, field, context))}"${attribute("autocomplete", autocomplete)}${inputmode}${common}>`;
|
|
123
|
+
}
|
|
124
|
+
let confirmation = "";
|
|
125
|
+
const second = field.inputs[1];
|
|
126
|
+
if (field.type === "email" && second) {
|
|
127
|
+
const confirmId = inputId(formId, second.name);
|
|
128
|
+
confirmation = `<label class="gq-form__label" for="${confirmId}">${escapeHtml(second.label)}${requiredMark(field, copy)}</label><input type="email" id="${confirmId}" name="${escapeHtml(second.name)}" value="${escapeHtml(shown(second.name, field, context))}" autocomplete="email"${inputAttributes(field, context, notes)}>`;
|
|
129
|
+
}
|
|
130
|
+
return `<div class="${classes}"><label class="gq-form__label" for="${id}">${label}</label>` + notes.html + control + confirmation + `</div>`;
|
|
131
|
+
}
|
|
132
|
+
/** Turnstile loads only once the visitor interacts with a form (CONSENT-1, CN-R3). */
|
|
133
|
+
const FORMS_TURNSTILE_SCRIPT = `(()=>{const s="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";let l;const load=()=>l??=new Promise((ok,no)=>{const e=document.createElement("script");e.src=s;e.async=true;e.onload=()=>ok(window.turnstile);e.onerror=no;document.head.append(e)});for(const f of document.querySelectorAll("form[data-gq-turnstile]")){const start=()=>{if(f.dataset.gqTurnstileStarted)return;f.dataset.gqTurnstileStarted="1";load().then(t=>t.render(f.querySelector("[data-gq-turnstile-widget]"),{sitekey:f.dataset.gqTurnstile,"response-field-name":"cf-turnstile-response"}))};f.addEventListener("focusin",start,{once:true});f.addEventListener("pointerdown",start,{once:true})}})();`;
|
|
134
|
+
/**
|
|
135
|
+
* A `gravityforms/form` block's HTML. Without a descriptor (the CMS hasn't
|
|
136
|
+
* delivered one) or for an unavailable form, a status message instead of a
|
|
137
|
+
* form that can't be sent.
|
|
138
|
+
*/
|
|
139
|
+
function renderGravityForm(input) {
|
|
140
|
+
const copy = FORMS_COPY[formsCopyLanguage(input.language)];
|
|
141
|
+
const descriptor = input.descriptor;
|
|
142
|
+
if (!descriptor || !descriptor.available) return `<div class="gq-form gq-form--unavailable"${descriptor ? ` id="gq-form-${descriptor.id}"` : ""}><p class="gq-form__status" role="status">${escapeHtml(copy.unavailable)}</p></div>`;
|
|
143
|
+
const formId = descriptor.id;
|
|
144
|
+
const state = input.state && input.state.formId === formId ? input.state : null;
|
|
145
|
+
const policy = input.legal?.roles.find((role) => role.role === "privacy_policy");
|
|
146
|
+
const context = {
|
|
147
|
+
formId,
|
|
148
|
+
copy,
|
|
149
|
+
state,
|
|
150
|
+
privacyPolicy: policy ? {
|
|
151
|
+
url: policy.url,
|
|
152
|
+
title: policy.title || copy.privacyPolicy
|
|
153
|
+
} : null
|
|
154
|
+
};
|
|
155
|
+
const level = input.headingLevel ?? 2;
|
|
156
|
+
const titleId = `gq-form-${formId}-title`;
|
|
157
|
+
const heading = input.showTitle !== false && descriptor.title ? `<h${level} class="gq-form__title" id="${titleId}">${escapeHtml(descriptor.title)}</h${level}>` : "";
|
|
158
|
+
const description = input.showDescription !== false && descriptor.description ? `<p class="gq-form__intro">${escapeHtml(descriptor.description)}</p>` : "";
|
|
159
|
+
const open = `<div class="gq-form" id="gq-form-${formId}" data-gq-form="${formId}" lang="${escapeHtml(input.language)}">${heading}${description}`;
|
|
160
|
+
if (state?.status === "sent") return `${open}<div class="gq-form__status gq-form__status--sent" role="status">${(state.confirmation || (descriptor.confirmation.type === "message" ? descriptor.confirmation.message : "") || copy.sent).split(/\n{2,}/).map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`).join("")}</div></div>`;
|
|
161
|
+
let status = "";
|
|
162
|
+
if (state) {
|
|
163
|
+
const problems = descriptor.fields.flatMap((field) => {
|
|
164
|
+
const error = errorText(field, context);
|
|
165
|
+
if (!error) return [];
|
|
166
|
+
const target = field.inputs[0];
|
|
167
|
+
return [`<li><a href="#${!target ? `gq-form-${formId}` : field.type === "radio" || field.type === "checkbox" ? `${inputId(formId, target.name)}-0` : inputId(formId, target.name)}">${escapeHtml(field.label)}: ${escapeHtml(error)}</a></li>`];
|
|
168
|
+
});
|
|
169
|
+
status = `<div class="gq-form__errors" role="alert" id="gq-form-${formId}-errors" tabindex="-1"><p>${escapeHtml(state.status === "invalid" ? copy.errorSummary : state.status === "verification" ? copy.verification : state.status === "too-many" ? copy.tooMany : copy.failed)}</p>${problems.length ? `<ul>${problems.join("")}</ul>` : ""}</div>`;
|
|
170
|
+
}
|
|
171
|
+
const required = descriptor.fields.some((field) => field.required && field.visibility === "visible") ? `<p class="gq-form__legend">${escapeHtml(copy.requiredFields)}</p>` : "";
|
|
172
|
+
const honeypot = descriptor.honeypot ? `<div class="gq-form__hp" aria-hidden="true" style="position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden"><label for="${inputId(formId, descriptor.honeypot.name)}">${escapeHtml(copy.honeypot)}</label><input type="text" id="${inputId(formId, descriptor.honeypot.name)}" name="${escapeHtml(descriptor.honeypot.name)}" value="" tabindex="-1" autocomplete="off"></div>` : "";
|
|
173
|
+
const turnstile = input.turnstile?.siteKey ? `<div class="gq-form__turnstile" data-gq-turnstile-widget></div><noscript><p class="gq-form__status">${escapeHtml(copy.needsJavaScript)}</p></noscript>` : "";
|
|
174
|
+
const script = input.turnstile?.siteKey ? `<script>${FORMS_TURNSTILE_SCRIPT}<\/script>` : "";
|
|
175
|
+
return open + status + `<form class="gq-form__form" method="post" action="${formAction(formId)}" novalidate` + (heading ? ` aria-labelledby="${titleId}"` : "") + attribute("data-gq-turnstile", input.turnstile?.siteKey || null) + `><input type="hidden" name="gq_source" value="${escapeHtml(input.source)}">` + required + honeypot + descriptor.fields.map((field) => renderField(field, context)).join("") + turnstile + `<div class="gq-form__actions"><button type="submit" class="gq-form__submit">${escapeHtml(descriptor.submitLabel || copy.submit)}</button></div></form></div>` + script;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A minimal default stylesheet (layout and states only). Sites usually style
|
|
179
|
+
* the `gq-form*` classes with their own tokens instead.
|
|
180
|
+
*/
|
|
181
|
+
const FORMS_STYLES = `
|
|
182
|
+
.gq-form__visually-hidden{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
183
|
+
.gq-form__form{display:grid;gap:var(--gq-form-gap,1rem)}
|
|
184
|
+
.gq-form__field{display:grid;gap:.375rem;border:0;margin:0;padding:0;min-width:0}
|
|
185
|
+
.gq-form__field input:not([type=checkbox]):not([type=radio]),.gq-form__field textarea,.gq-form__field select{font:inherit;width:100%;padding:.5rem .75rem;border:1px solid var(--gq-form-border,#767676);border-radius:var(--gq-form-radius,.25rem)}
|
|
186
|
+
.gq-form__field--invalid input,.gq-form__field--invalid textarea,.gq-form__field--invalid select{border-color:var(--gq-form-error,#b00020)}
|
|
187
|
+
.gq-form__error,.gq-form__errors{color:var(--gq-form-error,#b00020)}
|
|
188
|
+
.gq-form__errors{border:2px solid currentColor;padding:1rem}
|
|
189
|
+
.gq-form__choice{display:flex;gap:.5rem;align-items:baseline}
|
|
190
|
+
.gq-form :focus-visible{outline:3px solid var(--gq-form-focus,#1a73e8);outline-offset:2px}
|
|
191
|
+
`.trim();
|
|
192
|
+
//#endregion
|
|
193
|
+
export { FORMS_STYLES, FORMS_TURNSTILE_SCRIPT, FORM_STATE_PARAM, GRAVITY_FORMS_BLOCK, blockFormId, clearFormStateCookie, escapeHtml, findForm, inputId, readFormState, renderGravityForm };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { o as FormDescriptor, t as FORMS_PATH } from "./descriptor-B-e7gHi1.js";
|
|
2
|
+
//#region frontend/src/forms/server.d.ts
|
|
3
|
+
/** The largest form body accepted (bytes); larger posts are refused unread. */
|
|
4
|
+
export declare const MAX_FORM_BYTES: number;
|
|
5
|
+
/** Cloudflare's server-side Turnstile verification. */
|
|
6
|
+
export declare const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
7
|
+
/** The field Turnstile's widget posts its token in. */
|
|
8
|
+
export declare const TURNSTILE_RESPONSE_FIELD = "cf-turnstile-response";
|
|
9
|
+
/** A Workers Rate Limiting binding. */
|
|
10
|
+
export interface FormsRateLimiter {
|
|
11
|
+
limit(options: {
|
|
12
|
+
key: string;
|
|
13
|
+
}): Promise<{
|
|
14
|
+
success: boolean;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** The Worker bindings the handler reads. None is a Gravity Forms credential. */
|
|
18
|
+
export interface FormsBindings {
|
|
19
|
+
/** Turnstile's secret key (a Worker secret), when the Site uses Turnstile. */
|
|
20
|
+
TURNSTILE_SECRET_KEY?: string;
|
|
21
|
+
/**
|
|
22
|
+
* A Cloudflare Access service token for the CMS's submissions path only,
|
|
23
|
+
* when the CMS is behind Access. Both or neither.
|
|
24
|
+
*/
|
|
25
|
+
GQ_FORMS_ACCESS_CLIENT_ID?: string;
|
|
26
|
+
GQ_FORMS_ACCESS_CLIENT_SECRET?: string;
|
|
27
|
+
/** Rate limiting for form posts, keyed per client IP per request. */
|
|
28
|
+
FORMS_RATE_LIMITER?: FormsRateLimiter;
|
|
29
|
+
}
|
|
30
|
+
export interface FormLookup {
|
|
31
|
+
formId: number;
|
|
32
|
+
/** The page the form was posted from (a path on the Site). */
|
|
33
|
+
source: string;
|
|
34
|
+
}
|
|
35
|
+
export interface FormsServerConfig {
|
|
36
|
+
/** The CMS's origin (https://cms.example.com): Gravity Forms' REST API is under it. */
|
|
37
|
+
cmsOrigin: string;
|
|
38
|
+
/** WordPress's REST root, when not `<cmsOrigin>/wp-json`. */
|
|
39
|
+
restRoot?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The form's descriptor as published with the page it was posted from
|
|
42
|
+
* (the content runtime's `publishedForm(source, formId)`), or null.
|
|
43
|
+
*/
|
|
44
|
+
descriptor: (lookup: FormLookup) => Promise<FormDescriptor | null> | FormDescriptor | null;
|
|
45
|
+
/** The Site's spam controls. The honeypot is always applied when a form enables it. */
|
|
46
|
+
spam?: {
|
|
47
|
+
turnstile?: boolean;
|
|
48
|
+
};
|
|
49
|
+
bindings: () => FormsBindings | Promise<FormsBindings>;
|
|
50
|
+
/** The Site's public origin(s), when the Worker sees another (a proxy). */
|
|
51
|
+
siteOrigins?: readonly string[];
|
|
52
|
+
/** The page language for the handler's own messages (`pt-PT`, `en`). */
|
|
53
|
+
language?: string;
|
|
54
|
+
/** Milliseconds Gravity Forms and Turnstile have to answer (15 s). */
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
fetch?: typeof globalThis.fetch;
|
|
57
|
+
}
|
|
58
|
+
/** HTML as text: tags dropped, paragraphs and breaks as new lines, entities decoded. */
|
|
59
|
+
export declare function htmlText(html: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* The form endpoint. `handle` answers `POST /gq/forms/<id>` and null for any
|
|
62
|
+
* other path; `check` says what is configured, for readiness, never values.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createFormsServer(config: FormsServerConfig): {
|
|
65
|
+
handle: (request: Request) => Promise<Response | null>;
|
|
66
|
+
check: () => Promise<{
|
|
67
|
+
cmsOrigin: string;
|
|
68
|
+
spam: {
|
|
69
|
+
honeypot: boolean;
|
|
70
|
+
turnstile: boolean;
|
|
71
|
+
};
|
|
72
|
+
turnstileSecret: boolean;
|
|
73
|
+
access: string;
|
|
74
|
+
rateLimit: boolean;
|
|
75
|
+
ready: boolean;
|
|
76
|
+
}>;
|
|
77
|
+
};
|
|
78
|
+
export type FormsServer = ReturnType<typeof createFormsServer>;
|
|
79
|
+
//#endregion
|
|
80
|
+
export { FORMS_PATH, type FormDescriptor };
|
|
@@ -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(/ /g, " ").replace(/"/g, "\"").replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|