@getquick/content 0.5.1 → 0.6.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.
@@ -19,8 +19,13 @@ function isSameOrigin(request, origins) {
19
19
  }
20
20
  /** The body as text, or null once it exceeds `limit` bytes (declared or read). */
21
21
  async function readBoundedBody(request, limit) {
22
+ const bytes = await readBoundedBytes(request, limit);
23
+ return bytes === null ? null : new TextDecoder().decode(bytes);
24
+ }
25
+ /** The body's bytes, or null once it exceeds `limit` bytes (declared or read). */
26
+ async function readBoundedBytes(request, limit) {
22
27
  if (Number(request.headers.get("Content-Length") ?? "0") > limit) return null;
23
- if (!request.body) return "";
28
+ if (!request.body) return /* @__PURE__ */ new Uint8Array();
24
29
  const reader = request.body.getReader();
25
30
  const chunks = [];
26
31
  let size = 0;
@@ -40,7 +45,7 @@ async function readBoundedBody(request, limit) {
40
45
  bytes.set(chunk, offset);
41
46
  offset += chunk.byteLength;
42
47
  }
43
- return new TextDecoder().decode(bytes);
48
+ return bytes;
44
49
  }
45
50
  //#endregion
46
51
  //#region frontend/src/client-ip.ts
@@ -200,4 +205,4 @@ function rateLimitKey(address) {
200
205
  })}/64`;
201
206
  }
202
207
  //#endregion
203
- export { readBoundedBody as i, rateLimitKey as n, isSameOrigin as r, clientAddressResolver as t };
208
+ export { readBoundedBytes as a, readBoundedBody as i, rateLimitKey as n, isSameOrigin as r, clientAddressResolver as t };
@@ -1,6 +1,6 @@
1
1
  import { S as SERVICE_CATALOG, T as copyLanguage, c as grantedCategories, d as promptReason, f as readConsentCookie, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
2
2
  import { a as CONSENT_COPY, c as legalRevisions, r as escapeHtml } from "./embeds-CJJemAmE.js";
3
- import { t as CONSENT_UI_VERSION } from "./version-7v3zP5aF.js";
3
+ import { t as CONSENT_UI_VERSION } from "./version-CcJhZgHO.js";
4
4
  import { t as CONSENT_CONFIG_ID } from "./client-config-B3LNSOxl.js";
5
5
  import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
6
6
  //#region frontend/src/consent/html.ts
@@ -2,7 +2,7 @@ import { t as bearerMatches } from "./bearer-DMOCoSci.js";
2
2
  import { i as DECISION_VALIDITY_MS, l as isConsentId, t as CONSENT_ACTIONS, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
3
3
  import { a as parseConsentRecordRequest, t as MAX_RECORD_BYTES } from "./records-Dumrz759.js";
4
4
  import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
5
- import { i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-cnqPLdDa.js";
5
+ import { i as readBoundedBody, n as rateLimitKey, r as isSameOrigin, t as clientAddressResolver } from "./client-ip-NKt34dBM.js";
6
6
  //#region frontend/src/consent/server.ts
7
7
  const MAX_RETENTION_DAYS = 1826;
8
8
  const DAY_MS = 864e5;
package/dist/consent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as SERVICE_IDS, D as hostMatches, E as embedService, O as hostService, S as SERVICE_CATALOG, T as copyLanguage, _ as consentConfiguration, a as consentCookie, b as COPY_LANGUAGES, c as grantedCategories, d as promptReason, f as readConsentCookie, g as MEASUREMENT_ID, h as CONSENT_COPY_REVISION, i as DECISION_VALIDITY_MS, k as isServiceId, l as isConsentId, m as serviceGranted, n as CONSENT_COOKIE, o as decide, p as serializeConsentState, r as CONSENT_COOKIE_MAX_AGE_SECONDS, s as denyAll, t as CONSENT_ACTIONS, u as parseConsentState, v as needsBanner, w as catalogService, x as OPTIONAL_CATEGORIES, y as CONSENT_CATEGORIES } from "./state-BYWc5TUB.js";
2
2
  import { a as parseConsentRecordRequest, i as isRecordToken, n as consentRecordRequest, r as isLegalRoleName, t as MAX_RECORD_BYTES } from "./records-Dumrz759.js";
3
3
  import { a as CONSENT_COPY, c as legalRevisions, i as rewriteEmbeds, l as parseLegalLanguage, n as embedPlaceholder, o as LEGAL_ROLES, r as escapeHtml, s as REQUIRED_LEGAL_ROLES, t as blockedPlaceholder } from "./embeds-CJJemAmE.js";
4
- import { t as CONSENT_UI_VERSION } from "./version-7v3zP5aF.js";
4
+ import { t as CONSENT_UI_VERSION } from "./version-CcJhZgHO.js";
5
5
  export { CONSENT_ACTIONS, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_COOKIE_MAX_AGE_SECONDS, CONSENT_COPY, CONSENT_COPY_REVISION, CONSENT_UI_VERSION, COPY_LANGUAGES, DECISION_VALIDITY_MS, LEGAL_ROLES, MAX_RECORD_BYTES, MEASUREMENT_ID, OPTIONAL_CATEGORIES, REQUIRED_LEGAL_ROLES, SERVICE_CATALOG, SERVICE_IDS, blockedPlaceholder, catalogService, consentConfiguration, consentCookie, consentRecordRequest, copyLanguage, decide, denyAll, embedPlaceholder, embedService, escapeHtml, grantedCategories, hostMatches, hostService, isConsentId, isLegalRoleName, isRecordToken, isServiceId, legalRevisions, needsBanner, parseConsentRecordRequest, parseConsentState, parseLegalLanguage, promptReason, readConsentCookie, rewriteEmbeds, serializeConsentState, serviceGranted };
@@ -21,7 +21,8 @@ const STATUSES = /* @__PURE__ */ new Set([
21
21
  "invalid",
22
22
  "failed",
23
23
  "verification",
24
- "too-many"
24
+ "too-many",
25
+ "too-large"
25
26
  ]);
26
27
  /** The Set-Cookie header that carries a form's outcome to the page. */
27
28
  function formStateCookie(state, options) {
@@ -118,7 +119,10 @@ const FORMS_COPY = {
118
119
  range: "Enter a number in the allowed range.",
119
120
  url: "Enter a valid web address, starting with https://.",
120
121
  choice: "Choose one of the options.",
121
- "too-long": "This is longer than allowed."
122
+ "too-long": "This is longer than allowed.",
123
+ "file-type": "Choose files of an accepted type.",
124
+ "file-size": "A file is larger than allowed.",
125
+ "file-count": "Too many files."
122
126
  },
123
127
  failed: "Your message couldn't be sent. Please try again later.",
124
128
  verification: "We couldn't verify that you're not a robot. Please try again.",
@@ -128,7 +132,12 @@ const FORMS_COPY = {
128
132
  submit: "Submit",
129
133
  privacyPolicy: "Privacy policy",
130
134
  honeypot: "This field is for validation purposes and should be left unchanged.",
131
- needsJavaScript: "This form needs JavaScript to verify you're not a robot."
135
+ needsJavaScript: "This form needs JavaScript to verify you're not a robot.",
136
+ fileTypes: "Accepted file types: {types}.",
137
+ fileSize: "Up to {size} per file.",
138
+ fileCount: "Up to {count} files.",
139
+ filesAgain: "Choose your files again.",
140
+ tooLarge: "The files are too large to send together. Choose fewer or smaller files."
132
141
  },
133
142
  "pt-PT": {
134
143
  required: "obrigatório",
@@ -142,7 +151,10 @@ const FORMS_COPY = {
142
151
  range: "Introduza um número dentro do intervalo permitido.",
143
152
  url: "Introduza um endereço web válido, começado por https://.",
144
153
  choice: "Escolha uma das opções.",
145
- "too-long": "Excede o comprimento permitido."
154
+ "too-long": "Excede o comprimento permitido.",
155
+ "file-type": "Escolha ficheiros de um tipo aceite.",
156
+ "file-size": "Um ficheiro excede o tamanho permitido.",
157
+ "file-count": "Demasiados ficheiros."
146
158
  },
147
159
  failed: "Não foi possível enviar a sua mensagem. Tente novamente mais tarde.",
148
160
  verification: "Não foi possível verificar que não é um robô. Tente novamente.",
@@ -152,7 +164,12 @@ const FORMS_COPY = {
152
164
  submit: "Enviar",
153
165
  privacyPolicy: "Política de privacidade",
154
166
  honeypot: "Este campo serve para validação e deve ser deixado em branco.",
155
- needsJavaScript: "Este formulário precisa de JavaScript para verificar que não é um robô."
167
+ needsJavaScript: "Este formulário precisa de JavaScript para verificar que não é um robô.",
168
+ fileTypes: "Tipos de ficheiro aceites: {types}.",
169
+ fileSize: "Até {size} por ficheiro.",
170
+ fileCount: "Até {count} ficheiros.",
171
+ filesAgain: "Volte a escolher os ficheiros.",
172
+ tooLarge: "Os ficheiros são demasiado grandes para enviar em conjunto. Escolha menos ficheiros ou ficheiros mais pequenos."
156
173
  }
157
174
  };
158
175
  /** The copy language for a page's `lang`: Portuguese for `pt*`, else English. */
@@ -6,7 +6,7 @@ declare const FORM_DESCRIPTOR_SCHEMA = 1;
6
6
  /** The Gravity Forms block a Site's dispatcher routes to the renderer. */
7
7
  declare const GRAVITY_FORMS_BLOCK = "gravityforms/form";
8
8
  /** Field types this release renders and submits. */
9
- declare const SUPPORTED_FIELD_TYPES: readonly ["text", "textarea", "email", "phone", "number", "website", "select", "radio", "checkbox", "consent", "name", "hidden", "html", "section"];
9
+ declare const SUPPORTED_FIELD_TYPES: readonly ["text", "textarea", "email", "phone", "number", "website", "select", "radio", "checkbox", "consent", "name", "hidden", "html", "section", "fileupload"];
10
10
  type FormFieldType = (typeof SUPPORTED_FIELD_TYPES)[number];
11
11
  interface FormChoice {
12
12
  text: string;
@@ -34,6 +34,23 @@ interface FormConsent {
34
34
  /** Gravity Forms' form revision the text belongs to; submitted with the consent. */
35
35
  revisionId: string;
36
36
  }
37
+ /**
38
+ * The extensions an upload field may allow: the formats whose content the form
39
+ * endpoint verifies (ADR 0010 here). Another extension is a diagnostic.
40
+ */
41
+ declare const VERIFIABLE_FILE_EXTENSIONS: readonly ["jpg", "jpeg", "png", "gif", "webp", "pdf"];
42
+ type VerifiableFileExtension = (typeof VERIFIABLE_FILE_EXTENSIONS)[number];
43
+ /** A file upload field's public settings, as Gravity Forms enforces them. */
44
+ interface FormUpload {
45
+ /** Lower case, without dots (`jpg`, `pdf`). Empty: none set, which isn't supported. */
46
+ allowedExtensions: string[];
47
+ /** The largest file accepted, in bytes (the field's limit, capped by the CMS's PHP). */
48
+ maxFileSize: number;
49
+ /** Whether the field takes several files. */
50
+ multipleFiles: boolean;
51
+ /** The most files it takes: 1 for a single file; 0 for several without a maximum (unsupported). */
52
+ maxFiles: number;
53
+ }
37
54
  interface FormField {
38
55
  id: number;
39
56
  type: FormFieldType;
@@ -61,6 +78,8 @@ interface FormField {
61
78
  };
62
79
  /** The Site's CSS classes the editor set on the field. */
63
80
  cssClass?: string;
81
+ /** fileupload only. */
82
+ upload?: FormUpload;
64
83
  }
65
84
  type FormConfirmation = {
66
85
  type: "message";
@@ -114,6 +133,8 @@ declare function blockFormId(attributes: Record<string, unknown> | null | undefi
114
133
  declare function findForm(forms: readonly FormDescriptor[] | null | undefined, formId: number): FormDescriptor | null;
115
134
  /** The path a form posts to on the Site's own origin. */
116
135
  declare const formAction: (formId: number) => string;
136
+ /** Whether a descriptor has an upload field a visitor fills in: its form posts multipart. */
137
+ declare function hasUploads(descriptor: FormDescriptor): boolean;
117
138
  /** Everything readiness must fail for: an unavailable form, and each unsupported field or setting. */
118
139
  declare function formDiagnostics(descriptor: FormDescriptor): FormDiagnostic[];
119
140
  /** The spam controls a Site's forms use. */
@@ -128,16 +149,25 @@ interface FormsReadiness {
128
149
  /** Gravity Forms' honeypot is enabled on the form. */
129
150
  honeypot: boolean;
130
151
  }>;
131
- /** Controls the Site declares that no checked form uses (honeypot only). */
152
+ /**
153
+ * Controls the Site declares that no checked form uses (honeypot only), and
154
+ * forms whose largest upload exceeds the Site's `maxUploadBytes`.
155
+ */
132
156
  warnings: string[];
133
157
  }
158
+ /** The most a form's visible upload fields can post together (bytes): each field's limit times its files. */
159
+ declare function largestUpload(descriptor: FormDescriptor): number;
134
160
  /**
135
161
  * Readiness over the forms a Site's published content references: fails for
136
162
  * any diagnostic. `spam` is the Site's declared controls; declaring the
137
163
  * honeypot for a form that doesn't enable it is reported as a warning.
164
+ * `maxUploadBytes` is the Site's cap on a post (the form endpoint's): a form
165
+ * whose files can exceed it together is reported as a warning, since its
166
+ * largest posts will be refused.
138
167
  */
139
168
  declare function formsReadiness(descriptors: readonly FormDescriptor[], options?: {
140
169
  spam?: readonly FormSpamControl[];
170
+ maxUploadBytes?: number;
141
171
  }): FormsReadiness;
142
172
  //#endregion
143
- export { parseFormDescriptors as S, findForm as _, FormConsent as a, formsReadiness as b, FormDiagnosticCode as c, FormInput as d, FormSpamControl as f, blockFormId as g, SUPPORTED_FIELD_TYPES as h, FormConfirmation as i, FormField as l, GRAVITY_FORMS_BLOCK as m, FORM_DESCRIPTOR_SCHEMA as n, FormDescriptor as o, FormsReadiness as p, FormChoice as r, FormDiagnostic as s, FORMS_PATH as t, FormFieldType as u, formAction as v, parseFormDescriptor as x, formDiagnostics as y };
173
+ export { formsReadiness as C, parseFormDescriptors as D, parseFormDescriptor as E, formDiagnostics as S, largestUpload as T, VERIFIABLE_FILE_EXTENSIONS as _, FormConsent as a, findForm as b, FormDiagnosticCode as c, FormInput as d, FormSpamControl as f, SUPPORTED_FIELD_TYPES as g, GRAVITY_FORMS_BLOCK as h, FormConfirmation as i, FormField as l, FormsReadiness as m, FORM_DESCRIPTOR_SCHEMA as n, FormDescriptor as o, FormUpload as p, FormChoice as r, FormDiagnostic as s, FORMS_PATH as t, FormFieldType as u, VerifiableFileExtension as v, hasUploads as w, formAction as x, blockFormId as y };
@@ -20,7 +20,20 @@ const SUPPORTED_FIELD_TYPES = [
20
20
  "name",
21
21
  "hidden",
22
22
  "html",
23
- "section"
23
+ "section",
24
+ "fileupload"
25
+ ];
26
+ /**
27
+ * The extensions an upload field may allow: the formats whose content the form
28
+ * endpoint verifies (ADR 0010 here). Another extension is a diagnostic.
29
+ */
30
+ const VERIFIABLE_FILE_EXTENSIONS = [
31
+ "jpg",
32
+ "jpeg",
33
+ "png",
34
+ "gif",
35
+ "webp",
36
+ "pdf"
24
37
  ];
25
38
  function isRecord(value) {
26
39
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -53,6 +66,20 @@ function parseInput(value) {
53
66
  ...optionalText(value.autocomplete) ? { autocomplete: text(value.autocomplete) } : {}
54
67
  };
55
68
  }
69
+ const EXTENSION = /^[a-z0-9]{1,16}$/;
70
+ function parseUpload(value) {
71
+ const record = isRecord(value) ? value : {};
72
+ const extensions = Array.isArray(record.allowedExtensions) ? record.allowedExtensions : [];
73
+ const multipleFiles = record.multipleFiles === true;
74
+ const size = finite(record.maxFileSize);
75
+ const count = finite(record.maxFiles);
76
+ return {
77
+ allowedExtensions: [...new Set(extensions.map((each) => text(each).trim().toLowerCase().replace(/^\./, "")).filter((each) => EXTENSION.test(each)))],
78
+ maxFileSize: size !== void 0 && size > 0 ? Math.floor(size) : 0,
79
+ multipleFiles,
80
+ maxFiles: !multipleFiles ? 1 : count !== void 0 && count > 0 ? Math.floor(count) : 0
81
+ };
82
+ }
56
83
  function parseField(value) {
57
84
  if (!isRecord(value)) return null;
58
85
  const id = value.id;
@@ -93,6 +120,7 @@ function parseField(value) {
93
120
  ...max !== void 0 ? { max } : {}
94
121
  };
95
122
  }
123
+ if (field.type === "fileupload") field.upload = parseUpload(value.upload);
96
124
  return field;
97
125
  }
98
126
  function parseConfirmation(value) {
@@ -169,22 +197,53 @@ function findForm(forms, formId) {
169
197
  }
170
198
  /** The path a form posts to on the Site's own origin. */
171
199
  const formAction = (formId) => `${FORMS_PATH}/${formId}`;
200
+ /** Whether a descriptor has an upload field a visitor fills in: its form posts multipart. */
201
+ function hasUploads(descriptor) {
202
+ return descriptor.fields.some((field) => field.type === "fileupload" && field.visibility === "visible");
203
+ }
204
+ /** Why an upload field's settings can't be honoured, or null. */
205
+ function uploadProblem(upload) {
206
+ if (!upload || upload.allowedExtensions.length === 0) return `Set the field's allowed file extensions (${VERIFIABLE_FILE_EXTENSIONS.join(", ")}).`;
207
+ const unverifiable = upload.allowedExtensions.filter((each) => !VERIFIABLE_FILE_EXTENSIONS.includes(each));
208
+ if (unverifiable.length) return `The Frontend can't verify ${unverifiable.join(", ")} files: allow only ${VERIFIABLE_FILE_EXTENSIONS.join(", ")}.`;
209
+ if (upload.maxFileSize <= 0) return "The field has no file size limit.";
210
+ if (upload.maxFiles <= 0) return "Set the maximum number of files.";
211
+ return null;
212
+ }
172
213
  /** Everything readiness must fail for: an unavailable form, and each unsupported field or setting. */
173
214
  function formDiagnostics(descriptor) {
174
215
  const diagnostics = [...descriptor.diagnostics];
216
+ for (const field of descriptor.fields) {
217
+ if (field.type !== "fileupload") continue;
218
+ const problem = uploadProblem(field.upload);
219
+ if (problem && !diagnostics.some((each) => each.code === "unsupported-file-upload" && each.fieldId === field.id)) diagnostics.push({
220
+ code: "unsupported-file-upload",
221
+ fieldId: field.id,
222
+ type: "fileupload",
223
+ message: problem
224
+ });
225
+ }
175
226
  if (!descriptor.available && !diagnostics.some((each) => each.code === "unavailable")) diagnostics.unshift({
176
227
  code: "unavailable",
177
228
  message: `Form ${descriptor.id} is missing, inactive or in the trash.`
178
229
  });
179
230
  return diagnostics;
180
231
  }
232
+ /** The most a form's visible upload fields can post together (bytes): each field's limit times its files. */
233
+ function largestUpload(descriptor) {
234
+ return descriptor.fields.filter((field) => field.type === "fileupload" && field.visibility === "visible").reduce((total, field) => total + (field.upload?.maxFileSize ?? 0) * Math.max(field.upload?.maxFiles ?? 0, 1), 0);
235
+ }
181
236
  /**
182
237
  * Readiness over the forms a Site's published content references: fails for
183
238
  * any diagnostic. `spam` is the Site's declared controls; declaring the
184
239
  * honeypot for a form that doesn't enable it is reported as a warning.
240
+ * `maxUploadBytes` is the Site's cap on a post (the form endpoint's): a form
241
+ * whose files can exceed it together is reported as a warning, since its
242
+ * largest posts will be refused.
185
243
  */
186
244
  function formsReadiness(descriptors, options = {}) {
187
- const forms = [...new Map(descriptors.map((descriptor) => [descriptor.id, descriptor])).values()].map((descriptor) => {
245
+ const unique = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
246
+ const forms = [...unique.values()].map((descriptor) => {
188
247
  const diagnostics = formDiagnostics(descriptor);
189
248
  return {
190
249
  id: descriptor.id,
@@ -195,6 +254,11 @@ function formsReadiness(descriptors, options = {}) {
195
254
  };
196
255
  });
197
256
  const warnings = options.spam?.includes("honeypot") ? forms.filter((form) => !form.honeypot).map((form) => `Form ${form.id} doesn't enable Gravity Forms' anti-spam honeypot.`) : [];
257
+ const cap = options.maxUploadBytes;
258
+ if (cap !== void 0) for (const descriptor of unique.values()) {
259
+ const largest = largestUpload(descriptor);
260
+ if (largest > cap) warnings.push(`Form ${descriptor.id}'s files can reach ${(largest / 1048576).toFixed(1)} MB together, more than the ${(cap / 1048576).toFixed(1)} MB the Site accepts per post: lower its upload fields' size limits or numbers of files.`);
261
+ }
198
262
  return {
199
263
  ok: forms.every((form) => form.ok),
200
264
  forms,
@@ -202,4 +266,4 @@ function formsReadiness(descriptors, options = {}) {
202
266
  };
203
267
  }
204
268
  //#endregion
205
- export { blockFormId as a, formDiagnostics as c, parseFormDescriptors as d, SUPPORTED_FIELD_TYPES as i, formsReadiness as l, FORM_DESCRIPTOR_SCHEMA as n, findForm as o, GRAVITY_FORMS_BLOCK as r, formAction as s, FORMS_PATH as t, parseFormDescriptor as u };
269
+ export { VERIFIABLE_FILE_EXTENSIONS as a, formAction as c, hasUploads as d, largestUpload as f, SUPPORTED_FIELD_TYPES as i, formDiagnostics as l, parseFormDescriptors as m, FORM_DESCRIPTOR_SCHEMA as n, blockFormId as o, parseFormDescriptor as p, GRAVITY_FORMS_BLOCK as r, findForm as s, FORMS_PATH as t, formsReadiness as u };
@@ -1,5 +1,5 @@
1
- import { _ as findForm, g as blockFormId, l as FormField, m as GRAVITY_FORMS_BLOCK, o as FormDescriptor } from "./descriptor-B-e7gHi1.js";
2
- import { a as clearFormStateCookie, l as readFormState, n as FORM_STATE_PARAM, r as FormState } from "./state-CsMFLUUO.js";
1
+ import { b as findForm, h as GRAVITY_FORMS_BLOCK, l as FormField, o as FormDescriptor, y as blockFormId } from "./descriptor-BF5Y9a1J.js";
2
+ import { a as clearFormStateCookie, l as readFormState, n as FORM_STATE_PARAM, r as FormState } from "./state-Bw-HSBeO.js";
3
3
  //#region frontend/src/forms/astro.d.ts
4
4
  /** LEGAL-1's links in the page's language (the chrome's `legal`), structurally. */
5
5
  export interface FormsLegal {
@@ -33,6 +33,8 @@ export interface GravityFormInput {
33
33
  export declare function escapeHtml(value: string): string;
34
34
  /** The HTML id of an input (`gq-form-1-input_1_3`). */
35
35
  export declare const inputId: (formId: number, name: string) => string;
36
+ /** A file size as visitors read it (5 MB, 512 KB), never more than the limit. */
37
+ export declare function formatFileSize(bytes: number, language: string): string;
36
38
  /** Turnstile loads only once the visitor interacts with a form (CONSENT-1, CN-R3). */
37
39
  export declare 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})}})();";
38
40
  /**
@@ -1,5 +1,5 @@
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";
1
+ import { c as formAction, d as hasUploads, o as blockFormId, r as GRAVITY_FORMS_BLOCK, s as findForm } from "./descriptor-DXmLmjEa.js";
2
+ import { a as clearFormStateCookie, i as FORM_STATE_PARAM, l as readFormState, n as formsCopyLanguage, t as FORMS_COPY } from "./copy-CfNr6M73.js";
3
3
  //#region frontend/src/forms/astro.ts
4
4
  function escapeHtml(value) {
5
5
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
@@ -41,7 +41,29 @@ function shown(name, field, context) {
41
41
  function requiredMark(field, copy) {
42
42
  return field.required ? ` <span class="gq-form__required" aria-hidden="true">*</span><span class="gq-form__visually-hidden"> (${escapeHtml(copy.required)})</span>` : "";
43
43
  }
44
- /** The description and error elements of a field, and the ids that describe its input. */
44
+ /** A file size as visitors read it (5 MB, 512 KB), never more than the limit. */
45
+ function formatFileSize(bytes, language) {
46
+ let format;
47
+ try {
48
+ format = new Intl.NumberFormat(language, { maximumFractionDigits: 1 });
49
+ } catch {
50
+ format = new Intl.NumberFormat("en", { maximumFractionDigits: 1 });
51
+ }
52
+ const megabytes = bytes / 1048576;
53
+ if (megabytes >= 1) return `${format.format(Math.floor(megabytes * 10) / 10)} MB`;
54
+ return `${format.format(Math.max(1, Math.floor(bytes / 1024)))} KB`;
55
+ }
56
+ /** An upload field's hint: the accepted types, the size limit and how many files. */
57
+ function uploadHint(upload, context) {
58
+ const { copy } = context;
59
+ const parts = [];
60
+ if (upload.allowedExtensions.length) parts.push(copy.fileTypes.replace("{types}", upload.allowedExtensions.map((each) => each.toUpperCase()).join(", ")));
61
+ if (upload.maxFileSize > 0) parts.push(copy.fileSize.replace("{size}", formatFileSize(upload.maxFileSize, context.language)));
62
+ if (upload.multipleFiles && upload.maxFiles > 1) parts.push(copy.fileCount.replace("{count}", String(upload.maxFiles)));
63
+ if (context.state && context.state.status !== "sent") parts.push(copy.filesAgain);
64
+ return parts.join(" ");
65
+ }
66
+ /** The description, hint and error elements of a field, and the ids that describe its input. */
45
67
  function annotations(field, context) {
46
68
  const base = `gq-form-${context.formId}-field-${field.id}`;
47
69
  const error = errorText(field, context);
@@ -51,6 +73,11 @@ function annotations(field, context) {
51
73
  ids.push(`${base}-description`);
52
74
  html += `<p class="gq-form__description" id="${base}-description">${escapeHtml(field.description)}</p>`;
53
75
  }
76
+ const hint = field.upload ? uploadHint(field.upload, context) : "";
77
+ if (hint) {
78
+ ids.push(`${base}-hint`);
79
+ html += `<p class="gq-form__hint" id="${base}-hint">${escapeHtml(hint)}</p>`;
80
+ }
54
81
  if (error) {
55
82
  ids.push(`${base}-error`);
56
83
  html += `<p class="gq-form__error" id="${base}-error">${escapeHtml(error)}</p>`;
@@ -107,7 +134,10 @@ function renderField(field, context) {
107
134
  const id = inputId(formId, first.name);
108
135
  const common = attribute("placeholder", field.placeholder) + (field.maxLength ? ` maxlength="${field.maxLength}"` : "") + inputAttributes(field, context, notes);
109
136
  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>`;
137
+ if (field.type === "fileupload") {
138
+ const accept = (field.upload?.allowedExtensions ?? []).map((each) => `.${each}`).join(",");
139
+ control = `<input type="file" id="${id}" name="${escapeHtml(first.name)}"${attribute("accept", accept || null)}${field.upload?.multipleFiles ? " multiple" : ""}${inputAttributes(field, context, notes)}>`;
140
+ } else if (field.type === "textarea") control = `<textarea id="${id}" name="${escapeHtml(first.name)}" rows="6"${common}>${escapeHtml(shown(first.name, field, context))}</textarea>`;
111
141
  else if (field.type === "select") {
112
142
  const value = typed(context) ? shown(first.name, field, context) : null;
113
143
  const choices = field.choices ?? [];
@@ -145,6 +175,7 @@ function renderGravityForm(input) {
145
175
  const policy = input.legal?.roles.find((role) => role.role === "privacy_policy");
146
176
  const context = {
147
177
  formId,
178
+ language: input.language,
148
179
  copy,
149
180
  state,
150
181
  privacyPolicy: policy ? {
@@ -166,13 +197,15 @@ function renderGravityForm(input) {
166
197
  const target = field.inputs[0];
167
198
  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
199
  });
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>`;
200
+ 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 : state.status === "too-large" ? copy.tooLarge : copy.failed)}</p>${problems.length ? `<ul>${problems.join("")}</ul>` : ""}</div>`;
170
201
  }
171
202
  const required = descriptor.fields.some((field) => field.required && field.visibility === "visible") ? `<p class="gq-form__legend">${escapeHtml(copy.requiredFields)}</p>` : "";
172
203
  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
204
  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
205
  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;
206
+ const uploads = hasUploads(descriptor);
207
+ const action = uploads ? `${formAction(formId)}?gq_source=${encodeURIComponent(input.source)}` : formAction(formId);
208
+ return open + status + `<form class="gq-form__form" method="post" action="${escapeHtml(action)}"` + (uploads ? ` enctype="multipart/form-data"` : "") + ` 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
209
  }
177
210
  /**
178
211
  * A minimal default stylesheet (layout and states only). Sites usually style
@@ -190,4 +223,4 @@ const FORMS_STYLES = `
190
223
  .gq-form :focus-visible{outline:3px solid var(--gq-form-focus,#1a73e8);outline-offset:2px}
191
224
  `.trim();
192
225
  //#endregion
193
- export { FORMS_STYLES, FORMS_TURNSTILE_SCRIPT, FORM_STATE_PARAM, GRAVITY_FORMS_BLOCK, blockFormId, clearFormStateCookie, escapeHtml, findForm, inputId, readFormState, renderGravityForm };
226
+ export { FORMS_STYLES, FORMS_TURNSTILE_SCRIPT, FORM_STATE_PARAM, GRAVITY_FORMS_BLOCK, blockFormId, clearFormStateCookie, escapeHtml, findForm, formatFileSize, inputId, readFormState, renderGravityForm };
@@ -1,7 +1,11 @@
1
- import { o as FormDescriptor, t as FORMS_PATH } from "./descriptor-B-e7gHi1.js";
1
+ import { o as FormDescriptor, t as FORMS_PATH } from "./descriptor-BF5Y9a1J.js";
2
2
  //#region frontend/src/forms/server.d.ts
3
3
  /** The largest form body accepted (bytes); larger posts are refused unread. */
4
4
  export declare const MAX_FORM_BYTES: number;
5
+ /** The largest multipart (upload) body accepted by default (bytes), files included. */
6
+ export declare const MAX_UPLOAD_BYTES: number;
7
+ /** The most `maxUploadBytes` may be: the Worker holds a post in memory. */
8
+ export declare const MAX_UPLOAD_BYTES_LIMIT: number;
5
9
  /** Cloudflare's server-side Turnstile verification. */
6
10
  export declare const TURNSTILE_SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
7
11
  /** The field Turnstile's widget posts its token in. */
@@ -58,7 +62,13 @@ export interface FormsServerConfig {
58
62
  trustedProxies?: readonly string[];
59
63
  /** The page language for the handler's own messages (`pt-PT`, `en`). */
60
64
  language?: string;
61
- /** Milliseconds Gravity Forms and Turnstile have to answer (15 s). */
65
+ /**
66
+ * The hard cap on a multipart post, files included (bytes; 10 MiB, at most
67
+ * 32 MiB). Keep it within what the Site's proxies and the CMS's PHP
68
+ * (`post_max_size`) accept.
69
+ */
70
+ maxUploadBytes?: number;
71
+ /** Milliseconds Gravity Forms and Turnstile have to answer (15 s; 60 s for uploads). */
62
72
  timeoutMs?: number;
63
73
  fetch?: typeof globalThis.fetch;
64
74
  }
@@ -79,6 +89,7 @@ export declare function createFormsServer(config: FormsServerConfig): {
79
89
  turnstileSecret: boolean;
80
90
  access: string;
81
91
  rateLimit: boolean;
92
+ maxUploadBytes: number;
82
93
  ready: boolean;
83
94
  }>;
84
95
  };