@10x-media/form-builder 0.1.0-beta.5 → 0.1.0-beta.7

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.
Files changed (213) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/actions/buildActionBlocks.js +12 -9
  3. package/dist/actions/buildActionBlocks.js.map +1 -1
  4. package/dist/actions/builtin/confirmation.js +27 -79
  5. package/dist/actions/builtin/confirmation.js.map +1 -1
  6. package/dist/actions/builtin/emailAction.d.ts +17 -0
  7. package/dist/actions/builtin/emailAction.js +83 -0
  8. package/dist/actions/builtin/emailAction.js.map +1 -0
  9. package/dist/actions/builtin/emailTeam.js +14 -84
  10. package/dist/actions/builtin/emailTeam.js.map +1 -1
  11. package/dist/actions/builtin/index.d.ts +9 -11
  12. package/dist/actions/builtin/index.js +11 -11
  13. package/dist/actions/builtin/index.js.map +1 -1
  14. package/dist/actions/defineAction.d.ts +2 -2
  15. package/dist/actions/defineAction.js.map +1 -1
  16. package/dist/actions/emailRecipients.d.ts +12 -0
  17. package/dist/actions/emailRecipients.js +116 -0
  18. package/dist/actions/emailRecipients.js.map +1 -0
  19. package/dist/actions/registry.d.ts +2 -1
  20. package/dist/actions/registry.js +2 -6
  21. package/dist/actions/registry.js.map +1 -1
  22. package/dist/actions/task.js +5 -3
  23. package/dist/actions/task.js.map +1 -1
  24. package/dist/calc/evaluate.js +1 -2
  25. package/dist/calc/evaluate.js.map +1 -1
  26. package/dist/calc/normalizeCalc.js +1 -2
  27. package/dist/calc/normalizeCalc.js.map +1 -1
  28. package/dist/calc/types.js.map +1 -1
  29. package/dist/client/ConsentBlockLabel.d.ts +15 -0
  30. package/dist/client/ConsentBlockLabel.js +71 -0
  31. package/dist/client/ConsentBlockLabel.js.map +1 -0
  32. package/dist/client/ConsentSourceRowLabel.d.ts +9 -0
  33. package/dist/client/ConsentSourceRowLabel.js +21 -0
  34. package/dist/client/ConsentSourceRowLabel.js.map +1 -0
  35. package/dist/client/EndpointOptionsSelect.d.ts +1 -0
  36. package/dist/client/EndpointOptionsSelect.js +4 -1
  37. package/dist/client/EndpointOptionsSelect.js.map +1 -1
  38. package/dist/client/FieldNameSelect.d.ts +1 -0
  39. package/dist/client/FieldNameSelect.js +4 -1
  40. package/dist/client/FieldNameSelect.js.map +1 -1
  41. package/dist/client/RecipientsSelect.d.ts +31 -0
  42. package/dist/client/RecipientsSelect.js +174 -0
  43. package/dist/client/RecipientsSelect.js.map +1 -0
  44. package/dist/client/admin.css +5 -0
  45. package/dist/collections/buttonFields.js +1 -1
  46. package/dist/collections/formSubmissions.js +4 -2
  47. package/dist/collections/formSubmissions.js.map +1 -1
  48. package/dist/collections/forms.js +46 -99
  49. package/dist/collections/forms.js.map +1 -1
  50. package/dist/collections/formsEndpoints.js +101 -0
  51. package/dist/collections/formsEndpoints.js.map +1 -0
  52. package/dist/conditions/evaluate.js +1 -1
  53. package/dist/conditions/evaluate.js.map +1 -1
  54. package/dist/conditions/fieldTypes.d.ts +3 -1
  55. package/dist/conditions/fieldTypes.js +4 -1
  56. package/dist/conditions/fieldTypes.js.map +1 -1
  57. package/dist/conditions/normalizeConditions.js +18 -5
  58. package/dist/conditions/normalizeConditions.js.map +1 -1
  59. package/dist/consent/captureConsent.d.ts +21 -5
  60. package/dist/consent/captureConsent.js +31 -7
  61. package/dist/consent/captureConsent.js.map +1 -1
  62. package/dist/consent/consentSourcesField.d.ts +8 -0
  63. package/dist/consent/consentSourcesField.js +13 -2
  64. package/dist/consent/consentSourcesField.js.map +1 -1
  65. package/dist/consent/resolveConsentEntries.d.ts +1 -1
  66. package/dist/consent/resolveConsentEntries.js +1 -4
  67. package/dist/consent/resolveConsentEntries.js.map +1 -1
  68. package/dist/consent/resolveConsentSourcesRequest.js +0 -1
  69. package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
  70. package/dist/consent/resolveConsentStatements.d.ts +1 -1
  71. package/dist/consent/resolveConsentStatements.js.map +1 -1
  72. package/dist/consent/types.d.ts +13 -1
  73. package/dist/email/departments.d.ts +13 -9
  74. package/dist/email/departments.js +7 -50
  75. package/dist/email/departments.js.map +1 -1
  76. package/dist/email/departmentsField.d.ts +4 -0
  77. package/dist/email/departmentsField.js +5 -1
  78. package/dist/email/departmentsField.js.map +1 -1
  79. package/dist/exports/client.d.ts +4 -1
  80. package/dist/exports/client.js +4 -1
  81. package/dist/exports/react.d.ts +3 -2
  82. package/dist/exports/react.js +4 -3
  83. package/dist/exports/rsc.js +1 -1
  84. package/dist/exports/types.d.ts +1 -1
  85. package/dist/fields/buildFieldBlocks.js +4 -4
  86. package/dist/fields/buildFieldBlocks.js.map +1 -1
  87. package/dist/fields/builtin/consent.js +6 -1
  88. package/dist/fields/builtin/consent.js.map +1 -1
  89. package/dist/fields/builtin/email.js +1 -1
  90. package/dist/fields/builtin/email.js.map +1 -1
  91. package/dist/fields/builtin/file.js +9 -3
  92. package/dist/fields/builtin/file.js.map +1 -1
  93. package/dist/fields/builtin/index.d.ts +1 -1
  94. package/dist/fields/builtin/index.js +3 -3
  95. package/dist/fields/builtin/index.js.map +1 -1
  96. package/dist/fields/builtin/message.js +1 -1
  97. package/dist/fields/builtin/repeater.js +1 -1
  98. package/dist/fields/builtin/select.js +1 -1
  99. package/dist/fields/registry.d.ts +2 -1
  100. package/dist/fields/registry.js +2 -6
  101. package/dist/fields/registry.js.map +1 -1
  102. package/dist/fields/sharedConfig.js +1 -1
  103. package/dist/fields/types.d.ts +9 -2
  104. package/dist/flow/normalizeFlow.js +7 -3
  105. package/dist/flow/normalizeFlow.js.map +1 -1
  106. package/dist/form/toFormDocument.d.ts +20 -3
  107. package/dist/form/toFormDocument.js +16 -4
  108. package/dist/form/toFormDocument.js.map +1 -1
  109. package/dist/form/types.d.ts +5 -5
  110. package/dist/index.d.ts +5 -193
  111. package/dist/index.js +18 -8
  112. package/dist/index.js.map +1 -1
  113. package/dist/options.d.ts +228 -0
  114. package/dist/plugin/applyRegistryConfig.d.ts +11 -0
  115. package/dist/plugin/applyRegistryConfig.js +20 -0
  116. package/dist/plugin/applyRegistryConfig.js.map +1 -0
  117. package/dist/plugin/registerCollections.js +3 -1
  118. package/dist/plugin/registerCollections.js.map +1 -1
  119. package/dist/plugin/uploadsCollection.d.ts +3 -0
  120. package/dist/plugin/uploadsCollection.js +10 -1
  121. package/dist/plugin/uploadsCollection.js.map +1 -1
  122. package/dist/poll/buildPollOptionSourceFields.js +2 -2
  123. package/dist/poll/buildPollOptionSourceFields.js.map +1 -1
  124. package/dist/poll/definePollOptionSource.d.ts +2 -2
  125. package/dist/poll/definePollOptionSource.js.map +1 -1
  126. package/dist/poll/pollTypeRegistry.js +2 -4
  127. package/dist/poll/pollTypeRegistry.js.map +1 -1
  128. package/dist/poll/registry.d.ts +2 -1
  129. package/dist/poll/registry.js +2 -6
  130. package/dist/poll/registry.js.map +1 -1
  131. package/dist/poll/resolvePollOptionsRequest.js +0 -1
  132. package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
  133. package/dist/prefill/valuesFromSearchParams.js +2 -7
  134. package/dist/prefill/valuesFromSearchParams.js.map +1 -1
  135. package/dist/react/Form.d.ts +3 -1
  136. package/dist/react/Form.js +93 -155
  137. package/dist/react/Form.js.map +1 -1
  138. package/dist/react/FormContext.d.ts +5 -1
  139. package/dist/react/FormContext.js.map +1 -1
  140. package/dist/react/FormFields.d.ts +18 -0
  141. package/dist/react/FormFields.js +114 -0
  142. package/dist/react/FormFields.js.map +1 -0
  143. package/dist/react/FormLayout.d.ts +4 -2
  144. package/dist/react/FormLayout.js +3 -2
  145. package/dist/react/FormLayout.js.map +1 -1
  146. package/dist/react/Poll.js +13 -3
  147. package/dist/react/Poll.js.map +1 -1
  148. package/dist/react/presentation/DialogSurface.js +1 -4
  149. package/dist/react/presentation/DialogSurface.js.map +1 -1
  150. package/dist/react/renderers/calculation.js +1 -3
  151. package/dist/react/renderers/calculation.js.map +1 -1
  152. package/dist/react/renderers/checkbox.js +1 -3
  153. package/dist/react/renderers/checkbox.js.map +1 -1
  154. package/dist/react/renderers/consent.js +2 -3
  155. package/dist/react/renderers/consent.js.map +1 -1
  156. package/dist/react/renderers/date.js +1 -3
  157. package/dist/react/renderers/date.js.map +1 -1
  158. package/dist/react/renderers/email.js +1 -3
  159. package/dist/react/renderers/email.js.map +1 -1
  160. package/dist/react/renderers/file.js +6 -7
  161. package/dist/react/renderers/file.js.map +1 -1
  162. package/dist/react/renderers/number.js +1 -3
  163. package/dist/react/renderers/number.js.map +1 -1
  164. package/dist/react/renderers/region.js +1 -3
  165. package/dist/react/renderers/region.js.map +1 -1
  166. package/dist/react/renderers/repeater.js +20 -4
  167. package/dist/react/renderers/repeater.js.map +1 -1
  168. package/dist/react/renderers/select.js +1 -3
  169. package/dist/react/renderers/select.js.map +1 -1
  170. package/dist/react/renderers/text.js +1 -3
  171. package/dist/react/renderers/text.js.map +1 -1
  172. package/dist/react/renderers/textarea.js +1 -3
  173. package/dist/react/renderers/textarea.js.map +1 -1
  174. package/dist/spam/constants.js +7 -1
  175. package/dist/spam/constants.js.map +1 -1
  176. package/dist/spam/resolveSpam.js +2 -1
  177. package/dist/spam/resolveSpam.js.map +1 -1
  178. package/dist/spam/spamGuard.js +22 -6
  179. package/dist/spam/spamGuard.js.map +1 -1
  180. package/dist/spam/types.d.ts +17 -2
  181. package/dist/submissions/SubmissionAnswers.js.map +1 -1
  182. package/dist/submissions/SubmissionAnswersClient.js +33 -26
  183. package/dist/submissions/SubmissionAnswersClient.js.map +1 -1
  184. package/dist/submissions/coerceBoolean.js +14 -0
  185. package/dist/submissions/coerceBoolean.js.map +1 -0
  186. package/dist/submissions/runSubmission.js +9 -8
  187. package/dist/submissions/runSubmission.js.map +1 -1
  188. package/dist/submissions/validateSubmission.js +4 -2
  189. package/dist/submissions/validateSubmission.js.map +1 -1
  190. package/dist/translations/de.js +14 -2
  191. package/dist/translations/de.js.map +1 -1
  192. package/dist/translations/en.js +14 -2
  193. package/dist/translations/en.js.map +1 -1
  194. package/dist/translations/keys.d.ts +14 -2
  195. package/dist/translations/keys.js +14 -2
  196. package/dist/translations/keys.js.map +1 -1
  197. package/dist/translations/server.js +8 -1
  198. package/dist/translations/server.js.map +1 -1
  199. package/dist/uploads/captureFileRef.d.ts +10 -3
  200. package/dist/uploads/captureFileRef.js +18 -9
  201. package/dist/uploads/captureFileRef.js.map +1 -1
  202. package/dist/uploads/resolveFileRef.js +2 -1
  203. package/dist/uploads/resolveFileRef.js.map +1 -1
  204. package/dist/validation/builtin/email.js +1 -1
  205. package/dist/validation/builtin/email.js.map +1 -1
  206. package/dist/validation/builtin/notAlreadySubmitted.js +1 -0
  207. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  208. package/dist/validation/emailPattern.js +10 -0
  209. package/dist/validation/emailPattern.js.map +1 -0
  210. package/dist/validation/registry.d.ts +2 -1
  211. package/dist/validation/registry.js +2 -9
  212. package/dist/validation/registry.js.map +1 -1
  213. package/package.json +2 -2
@@ -6,7 +6,7 @@ import { defineFieldRenderer } from "../contract.js";
6
6
  import { FieldShell } from "../primitives/FieldShell.js";
7
7
  import { Checkbox } from "../primitives/Checkbox.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
- import { useId, useMemo } from "react";
9
+ import { useMemo } from "react";
10
10
  //#region src/react/renderers/consent.tsx
11
11
  const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw : void 0;
12
12
  /**
@@ -42,8 +42,7 @@ const linkOf = (raw) => {
42
42
  * injected by `toFormDocument(doc, { consentStatements })`; the form document itself carries only
43
43
  * the source key, so a form rendered without that step shows no statement rather than a stale one.
44
44
  */
45
- const consentRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
46
- const id = useId();
45
+ const consentRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
47
46
  const describedById = `${id}-desc`;
48
47
  const plainStatement = text(textOfBody(field.statement));
49
48
  const html = useMemo(() => richStatementHtml(field.statement), [field.statement]);
@@ -1 +1 @@
1
- {"version":3,"file":"consent.js","names":[],"sources":["../../../src/react/renderers/consent.tsx"],"sourcesContent":["'use client'\n\nimport { useId, useMemo } from 'react'\nimport { sanitizeUrl } from '../../actions/body/converters'\nimport { serializeBody } from '../../actions/body/serializeBody'\nimport { textOfBody } from '../../actions/body/textOfBody'\nimport { defineFieldRenderer } from '../contract'\nimport { Checkbox } from '../primitives/Checkbox'\nimport { FieldShell } from '../primitives/FieldShell'\n\ntype ConsentLink = { label: string; url: string }\n\nconst text = (raw: unknown): string | undefined =>\n\ttypeof raw === 'string' && raw.trim() !== '' ? raw : undefined\n\n/**\n * HTML for a rich text statement. A plain string never goes through this path: `serializeBody`\n * interpolates a string body unescaped (its pre-richText behavior), so rendering one as HTML would\n * let literal markup execute; the renderer shows a plain string as text instead.\n */\nconst richStatementHtml = (statement: unknown): string | undefined => {\n\tif (typeof statement === 'string' || statement == null) {\n\t\treturn undefined\n\t}\n\tconst html = serializeBody(statement, { values: [], descriptors: [] })\n\treturn html === '' ? undefined : html\n}\n\n/**\n * The resolved policy link, or nothing unless the source carries both a url and a name to show\n * for it: an anchor labelled by a raw URL or a machine key is worse than no anchor. The href is\n * sanitized like every other url the package renders, since a source is admin-authored and can\n * point anywhere (`sanitizeUrl` keeps http(s)/mailto/tel and relative urls, and neutralizes the\n * rest to `#`).\n */\nconst linkOf = (raw: unknown): ConsentLink | undefined => {\n\tconst link = raw as Partial<ConsentLink> | undefined\n\tconst label = text(link?.label)\n\treturn label && typeof link?.url === 'string' && link.url !== ''\n\t\t? { label, url: sanitizeUrl(link.url) }\n\t\t: undefined\n}\n\n/**\n * The `statement` and `link` this reads are server-resolved from the field's consent source and\n * injected by `toFormDocument(doc, { consentStatements })`; the form document itself carries only\n * the source key, so a form rendered without that step shows no statement rather than a stale one.\n */\nexport const consentRenderer = defineFieldRenderer<boolean>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\n\t\t// The checkbox's accessible name is always plain text, regardless of the statement's shape,\n\t\t// so it stays a single unambiguous string even though the visible statement can carry\n\t\t// formatting and inline links. With no statement resolved the field is misconfigured; the\n\t\t// machine name still beats leaving the control unnamed.\n\t\tconst plainStatement = text(textOfBody(field.statement))\n\t\tconst html = useMemo(() => richStatementHtml(field.statement), [field.statement])\n\t\tconst link = linkOf(field.link)\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t{html ? (\n\t\t\t\t\t// The statement is a plain sibling, never a <label>: a native <label> forwards clicks to\n\t\t\t\t\t// its control even for descendant links (they aren't \"labelable\" elements per the HTML\n\t\t\t\t\t// spec), so an inline link here would also silently toggle the checkbox. The checkbox's\n\t\t\t\t\t// name comes from aria-label instead. A <div>, because serializeBody emits block\n\t\t\t\t\t// elements (<p>, headings, lists) that are invalid inside inline wrappers.\n\t\t\t\t\t// Safe to inject: serializeBody HTML-escapes all text and sanitizes link URLs.\n\t\t\t\t\t// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is produced by our escaping serializer, never raw user input\n\t\t\t\t\t<div className=\"fb-consent__statement\" dangerouslySetInnerHTML={{ __html: html }} />\n\t\t\t\t) : plainStatement ? (\n\t\t\t\t\t<span className=\"fb-consent__statement\">{plainStatement}</span>\n\t\t\t\t) : null}\n\t\t\t\t{required && (html || plainStatement) ? (\n\t\t\t\t\t<span className=\"fb-field__required\" aria-hidden=\"true\">\n\t\t\t\t\t\t{' *'}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t\t<Checkbox\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tchecked={value ?? false}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t\tariaLabel={plainStatement ?? name}\n\t\t\t\t/>\n\t\t\t\t{link ? (\n\t\t\t\t\t<span className=\"fb-consent__links\">\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref={link.url}\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\tclassName=\"fb-consent__link\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{link.label}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAYA,MAAM,QAAQ,QACb,OAAO,QAAQ,YAAY,IAAI,KAAK,MAAM,KAAK,MAAM,KAAA;;;;;;AAOtD,MAAM,qBAAqB,cAA2C;CACrE,IAAI,OAAO,cAAc,YAAY,aAAa,MACjD;CAED,MAAM,OAAO,cAAc,WAAW;EAAE,QAAQ,CAAC;EAAG,aAAa,CAAC;CAAE,CAAC;CACrE,OAAO,SAAS,KAAK,KAAA,IAAY;AAClC;;;;;;;;AASA,MAAM,UAAU,QAA0C;CACzD,MAAM,OAAO;CACb,MAAM,QAAQ,KAAK,MAAM,KAAK;CAC9B,OAAO,SAAS,OAAO,MAAM,QAAQ,YAAY,KAAK,QAAQ,KAC3D;EAAE;EAAO,KAAK,YAAY,KAAK,GAAG;CAAE,IACpC,KAAA;AACJ;;;;;;AAOA,MAAa,kBAAkB,qBAC7B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAM5B,MAAM,iBAAiB,KAAK,WAAW,MAAM,SAAS,CAAC;CACvD,MAAM,OAAO,cAAc,kBAAkB,MAAM,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC;CAChF,MAAM,OAAO,OAAO,MAAM,IAAI;CAE9B,OACC,qBAAC,YAAD;EACK;EACJ,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YALhB;GAOE,OAQA,oBAAC,OAAD;IAAK,WAAU;IAAwB,yBAAyB,EAAE,QAAQ,KAAK;GAAI,CAAA,IAChF,iBACH,oBAAC,QAAD;IAAM,WAAU;cAAyB;GAAqB,CAAA,IAC3D;GACH,aAAa,QAAQ,kBACrB,oBAAC,QAAD;IAAM,WAAU;IAAqB,eAAY;cAC/C;GACI,CAAA,IACH;GACJ,oBAAC,UAAD;IACK;IACE;IACN,SAAS,SAAS;IACR;IACF;IACE;IACA;IACV,SAAS,OAAO,SAAS;IACV;IACf,WAAW,kBAAkB;GAC7B,CAAA;GACA,OACA,oBAAC,QAAD;IAAM,WAAU;cACf,oBAAC,KAAD;KACC,MAAM,KAAK;KACX,QAAO;KACP,KAAI;KACJ,WAAU;eAET,KAAK;IACJ,CAAA;GACE,CAAA,IACH;EACO;;AAEd,CACD"}
1
+ {"version":3,"file":"consent.js","names":[],"sources":["../../../src/react/renderers/consent.tsx"],"sourcesContent":["'use client'\n\nimport { useMemo } from 'react'\nimport { sanitizeUrl } from '../../actions/body/converters'\nimport { serializeBody } from '../../actions/body/serializeBody'\nimport { textOfBody } from '../../actions/body/textOfBody'\nimport { defineFieldRenderer } from '../contract'\nimport { Checkbox } from '../primitives/Checkbox'\nimport { FieldShell } from '../primitives/FieldShell'\n\ntype ConsentLink = { label: string; url: string }\n\nconst text = (raw: unknown): string | undefined =>\n\ttypeof raw === 'string' && raw.trim() !== '' ? raw : undefined\n\n/**\n * HTML for a rich text statement. A plain string never goes through this path: `serializeBody`\n * interpolates a string body unescaped (its pre-richText behavior), so rendering one as HTML would\n * let literal markup execute; the renderer shows a plain string as text instead.\n */\nconst richStatementHtml = (statement: unknown): string | undefined => {\n\tif (typeof statement === 'string' || statement == null) {\n\t\treturn undefined\n\t}\n\tconst html = serializeBody(statement, { values: [], descriptors: [] })\n\treturn html === '' ? undefined : html\n}\n\n/**\n * The resolved policy link, or nothing unless the source carries both a url and a name to show\n * for it: an anchor labelled by a raw URL or a machine key is worse than no anchor. The href is\n * sanitized like every other url the package renders, since a source is admin-authored and can\n * point anywhere (`sanitizeUrl` keeps http(s)/mailto/tel and relative urls, and neutralizes the\n * rest to `#`).\n */\nconst linkOf = (raw: unknown): ConsentLink | undefined => {\n\tconst link = raw as Partial<ConsentLink> | undefined\n\tconst label = text(link?.label)\n\treturn label && typeof link?.url === 'string' && link.url !== ''\n\t\t? { label, url: sanitizeUrl(link.url) }\n\t\t: undefined\n}\n\n/**\n * The `statement` and `link` this reads are server-resolved from the field's consent source and\n * injected by `toFormDocument(doc, { consentStatements })`; the form document itself carries only\n * the source key, so a form rendered without that step shows no statement rather than a stale one.\n */\nexport const consentRenderer = defineFieldRenderer<boolean>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\n\t\t// The checkbox's accessible name is always plain text, regardless of the statement's shape,\n\t\t// so it stays a single unambiguous string even though the visible statement can carry\n\t\t// formatting and inline links. With no statement resolved the field is misconfigured; the\n\t\t// machine name still beats leaving the control unnamed.\n\t\tconst plainStatement = text(textOfBody(field.statement))\n\t\tconst html = useMemo(() => richStatementHtml(field.statement), [field.statement])\n\t\tconst link = linkOf(field.link)\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t{html ? (\n\t\t\t\t\t// The statement is a plain sibling, never a <label>: a native <label> forwards clicks to\n\t\t\t\t\t// its control even for descendant links (they aren't \"labelable\" elements per the HTML\n\t\t\t\t\t// spec), so an inline link here would also silently toggle the checkbox. The checkbox's\n\t\t\t\t\t// name comes from aria-label instead. A <div>, because serializeBody emits block\n\t\t\t\t\t// elements (<p>, headings, lists) that are invalid inside inline wrappers.\n\t\t\t\t\t// Safe to inject: serializeBody HTML-escapes all text and sanitizes link URLs.\n\t\t\t\t\t// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is produced by our escaping serializer, never raw user input\n\t\t\t\t\t<div className=\"fb-consent__statement\" dangerouslySetInnerHTML={{ __html: html }} />\n\t\t\t\t) : plainStatement ? (\n\t\t\t\t\t<span className=\"fb-consent__statement\">{plainStatement}</span>\n\t\t\t\t) : null}\n\t\t\t\t{required && (html || plainStatement) ? (\n\t\t\t\t\t<span className=\"fb-field__required\" aria-hidden=\"true\">\n\t\t\t\t\t\t{' *'}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t\t<Checkbox\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tchecked={value ?? false}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t\tariaLabel={plainStatement ?? name}\n\t\t\t\t/>\n\t\t\t\t{link ? (\n\t\t\t\t\t<span className=\"fb-consent__links\">\n\t\t\t\t\t\t<a\n\t\t\t\t\t\t\thref={link.url}\n\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\t\t\tclassName=\"fb-consent__link\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{link.label}\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAYA,MAAM,QAAQ,QACb,OAAO,QAAQ,YAAY,IAAI,KAAK,MAAM,KAAK,MAAM,KAAA;;;;;;AAOtD,MAAM,qBAAqB,cAA2C;CACrE,IAAI,OAAO,cAAc,YAAY,aAAa,MACjD;CAED,MAAM,OAAO,cAAc,WAAW;EAAE,QAAQ,CAAC;EAAG,aAAa,CAAC;CAAE,CAAC;CACrE,OAAO,SAAS,KAAK,KAAA,IAAY;AAClC;;;;;;;;AASA,MAAM,UAAU,QAA0C;CACzD,MAAM,OAAO;CACb,MAAM,QAAQ,KAAK,MAAM,KAAK;CAC9B,OAAO,SAAS,OAAO,MAAM,QAAQ,YAAY,KAAK,QAAQ,KAC3D;EAAE;EAAO,KAAK,YAAY,KAAK,GAAG;CAAE,IACpC,KAAA;AACJ;;;;;;AAOA,MAAa,kBAAkB,qBAC7B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAM5B,MAAM,iBAAiB,KAAK,WAAW,MAAM,SAAS,CAAC;CACvD,MAAM,OAAO,cAAc,kBAAkB,MAAM,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC;CAChF,MAAM,OAAO,OAAO,MAAM,IAAI;CAE9B,OACC,qBAAC,YAAD;EACK;EACJ,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YALhB;GAOE,OAQA,oBAAC,OAAD;IAAK,WAAU;IAAwB,yBAAyB,EAAE,QAAQ,KAAK;GAAI,CAAA,IAChF,iBACH,oBAAC,QAAD;IAAM,WAAU;cAAyB;GAAqB,CAAA,IAC3D;GACH,aAAa,QAAQ,kBACrB,oBAAC,QAAD;IAAM,WAAU;IAAqB,eAAY;cAC/C;GACI,CAAA,IACH;GACJ,oBAAC,UAAD;IACK;IACE;IACN,SAAS,SAAS;IACR;IACF;IACE;IACA;IACV,SAAS,OAAO,SAAS;IACV;IACf,WAAW,kBAAkB;GAC7B,CAAA;GACA,OACA,oBAAC,QAAD;IAAM,WAAU;cACf,oBAAC,KAAD;KACC,MAAM,KAAK;KACX,QAAO;KACP,KAAI;KACJ,WAAU;eAET,KAAK;IACJ,CAAA;GACE,CAAA,IACH;EACO;;AAEd,CACD"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/date.tsx
8
- const dateRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const dateRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  return /* @__PURE__ */ jsx(FieldShell, {
12
10
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"date.js","names":[],"sources":["../../../src/react/renderers/date.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const dateRenderer = defineFieldRenderer<string>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"date\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACE;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"date.js","names":[],"sources":["../../../src/react/renderers/date.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const dateRenderer = defineFieldRenderer<string>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"date\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,eAAe,qBAC1B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACE;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/email.tsx
8
- const emailRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const emailRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  return /* @__PURE__ */ jsx(FieldShell, {
12
10
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"email.js","names":[],"sources":["../../../src/react/renderers/email.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const emailRenderer = defineFieldRenderer<string>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"email\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,gBAAgB,qBAC3B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"email.js","names":[],"sources":["../../../src/react/renderers/email.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const emailRenderer = defineFieldRenderer<string>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"email\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,gBAAgB,qBAC3B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -6,7 +6,7 @@ import { defineFieldRenderer } from "../contract.js";
6
6
  import { FieldShell } from "../primitives/FieldShell.js";
7
7
  import { uploadFile } from "../uploadFile.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
- import { useId, useState } from "react";
9
+ import { useState } from "react";
10
10
  //#region src/react/renderers/file.tsx
11
11
  const typesOf = (mimeTypes) => {
12
12
  if (!Array.isArray(mimeTypes)) return [];
@@ -18,8 +18,7 @@ const typesOf = (mimeTypes) => {
18
18
  * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear
19
19
  * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.
20
20
  */
21
- const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled, t }) => {
22
- const id = useId();
21
+ const fileRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled, t }) => {
23
22
  const describedById = `${id}-desc`;
24
23
  const [uploading, setUploading] = useState(false);
25
24
  const [filename, setFilename] = useState(void 0);
@@ -30,7 +29,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
30
29
  const maxSize = typeof field.maxSize === "number" && field.maxSize >= 0 ? field.maxSize : void 0;
31
30
  const hints = [types.length > 0 ? resolveMessage(t(keys.fileHintAccepted), { types: types.join(", ") }) : void 0, maxSize !== void 0 ? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) }) : void 0].filter((hint) => typeof hint === "string");
32
31
  const allErrors = localError ? [...errors, localError] : errors;
33
- const displayName = filename ?? (value !== void 0 && value !== null && value !== "" ? "Uploaded file" : void 0);
32
+ const displayName = filename ?? (value !== void 0 && value !== null && value !== "" ? t(keys.fileUploaded) : void 0);
34
33
  const handleChange = async (event) => {
35
34
  const selected = event.target.files?.[0];
36
35
  if (!selected) return;
@@ -52,7 +51,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
52
51
  if (result.ok) {
53
52
  setFilename(selected.name);
54
53
  onChange(result.id);
55
- } else setLocalError(result.message ?? "Upload failed");
54
+ } else setLocalError(result.message ?? t(keys.fileUploadFailed));
56
55
  };
57
56
  const clear = () => {
58
57
  setFilename(void 0);
@@ -87,7 +86,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
87
86
  uploading ? /* @__PURE__ */ jsx("p", {
88
87
  className: "fb-field__file-status",
89
88
  "aria-live": "polite",
90
- children: "Uploading"
89
+ children: t(keys.fileUploading)
91
90
  }) : null,
92
91
  displayName ? /* @__PURE__ */ jsxs("p", {
93
92
  className: "fb-field__file-name",
@@ -96,7 +95,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
96
95
  className: "fb-field__file-clear",
97
96
  onClick: clear,
98
97
  disabled,
99
- children: "Remove"
98
+ children: t(keys.fileRemove)
100
99
  })]
101
100
  }) : null
102
101
  ]
@@ -1 +1 @@
1
- {"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useId, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled, t }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\t// Stamped server-side onto the block from plugin config; absent only for stale form docs saved\n\t\t// before uploads were configured (uploading then fails with a visible error, never a guess).\n\t\tconst collection =\n\t\t\ttypeof field.uploadsCollection === 'string' ? field.uploadsCollection : undefined\n\t\tconst types = typesOf(field.mimeTypes)\n\t\tconst accept = types.length > 0 ? types.join(',') : undefined\n\t\t// Zero is a real limit, matching resolveFileRef server-side (filesize > maxSize rejects any non-empty file).\n\t\tconst maxSize =\n\t\t\ttypeof field.maxSize === 'number' && field.maxSize >= 0 ? field.maxSize : undefined\n\t\tconst hints = [\n\t\t\ttypes.length > 0\n\t\t\t\t? resolveMessage(t(keys.fileHintAccepted), { types: types.join(', ') })\n\t\t\t\t: undefined,\n\t\t\tmaxSize !== undefined\n\t\t\t\t? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) })\n\t\t\t\t: undefined,\n\t\t].filter((hint): hint is string => typeof hint === 'string')\n\t\tconst allErrors = localError ? [...errors, localError] : errors\n\t\t// Show the just-uploaded filename, or a generic indicator when the field already holds an id (recall/prefill): the filename is not part of the client value.\n\t\tconst hasValue = value !== undefined && value !== null && value !== ''\n\t\tconst displayName = filename ?? (hasValue ? 'Uploaded file' : undefined)\n\n\t\tconst handleChange = async (event: ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst selected = event.target.files?.[0]\n\t\t\tif (!selected) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (maxSize !== undefined && selected.size > maxSize) {\n\t\t\t\tsetLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!collection) {\n\t\t\t\tsetLocalError(t(keys.fileUploadMisconfigured))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetUploading(true)\n\t\t\tsetLocalError(undefined)\n\t\t\tconst result = await uploadFile({ file: selected, collection })\n\t\t\tsetUploading(false)\n\t\t\tif (result.ok) {\n\t\t\t\tsetFilename(selected.name)\n\t\t\t\tonChange(result.id)\n\t\t\t} else {\n\t\t\t\tsetLocalError(result.message ?? 'Upload failed')\n\t\t\t}\n\t\t}\n\n\t\tconst clear = () => {\n\t\t\tsetFilename(undefined)\n\t\t\tsetLocalError(undefined)\n\t\t\tonChange('')\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={allErrors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"file\"\n\t\t\t\t\taccept={accept}\n\t\t\t\t\tdisabled={disabled || uploading}\n\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\tvoid handleChange(event)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\taria-invalid={allErrors.length > 0}\n\t\t\t\t\taria-describedby={describedById}\n\t\t\t\t/>\n\t\t\t\t{hints.length > 0 ? <p className=\"fb-field__file-hint\">{hints.join(' · ')}</p> : null}\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<p className=\"fb-field__file-status\" aria-live=\"polite\">\n\t\t\t\t\t\tUploading\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t\t{displayName ? (\n\t\t\t\t\t<p className=\"fb-field__file-name\">\n\t\t\t\t\t\t<span>{displayName}</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName=\"fb-field__file-clear\"\n\t\t\t\t\t\t\tonClick={clear}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAUA,MAAM,WAAW,cAAiC;CACjD,IAAI,CAAC,MAAM,QAAQ,SAAS,GAC3B,OAAO,CAAC;CAET,OAAO,UAAU,QAAQ,UAA2B,OAAO,UAAU,QAAQ;AAC9E;;;;;;;AAQA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,UAAU,QAAQ;CAC5E,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,CAAC,WAAW,gBAAgB,SAAS,KAAK;CAChD,MAAM,CAAC,UAAU,eAAe,SAA6B,KAAA,CAAS;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAA6B,KAAA,CAAS;CAI1E,MAAM,aACL,OAAO,MAAM,sBAAsB,WAAW,MAAM,oBAAoB,KAAA;CACzE,MAAM,QAAQ,QAAQ,MAAM,SAAS;CACrC,MAAM,SAAS,MAAM,SAAS,IAAI,MAAM,KAAK,GAAG,IAAI,KAAA;CAEpD,MAAM,UACL,OAAO,MAAM,YAAY,YAAY,MAAM,WAAW,IAAI,MAAM,UAAU,KAAA;CAC3E,MAAM,QAAQ,CACb,MAAM,SAAS,IACZ,eAAe,EAAE,KAAK,gBAAgB,GAAG,EAAE,OAAO,MAAM,KAAK,IAAI,EAAE,CAAC,IACpE,KAAA,GACH,YAAY,KAAA,IACT,eAAe,EAAE,KAAK,eAAe,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,IACrE,KAAA,CACJ,EAAE,QAAQ,SAAyB,OAAO,SAAS,QAAQ;CAC3D,MAAM,YAAY,aAAa,CAAC,GAAG,QAAQ,UAAU,IAAI;CAGzD,MAAM,cAAc,aADH,UAAU,KAAA,KAAa,UAAU,QAAQ,UAAU,KACxB,kBAAkB,KAAA;CAE9D,MAAM,eAAe,OAAO,UAAyC;EACpE,MAAM,WAAW,MAAM,OAAO,QAAQ;EACtC,IAAI,CAAC,UACJ;EAED,IAAI,YAAY,KAAA,KAAa,SAAS,OAAO,SAAS;GACrD,cAAc,eAAe,EAAE,KAAK,YAAY,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;GACjF;EACD;EACA,IAAI,CAAC,YAAY;GAChB,cAAc,EAAE,KAAK,uBAAuB,CAAC;GAC7C;EACD;EACA,aAAa,IAAI;EACjB,cAAc,KAAA,CAAS;EACvB,MAAM,SAAS,MAAM,WAAW;GAAE,MAAM;GAAU;EAAW,CAAC;EAC9D,aAAa,KAAK;EAClB,IAAI,OAAO,IAAI;GACd,YAAY,SAAS,IAAI;GACzB,SAAS,OAAO,EAAE;EACnB,OACC,cAAc,OAAO,WAAW,eAAe;CAEjD;CAEA,MAAM,cAAc;EACnB,YAAY,KAAA,CAAS;EACrB,cAAc,KAAA,CAAS;EACvB,SAAS,EAAE;CACZ;CAEA,OACC,qBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACV,QAAQ;EACO;YANhB;GAQC,oBAAC,SAAD;IACK;IACE;IACN,MAAK;IACG;IACR,UAAU,YAAY;IACtB,WAAW,UAAU;KACpB,aAAkB,KAAK;IACxB;IACQ;IACR,gBAAc,UAAU,SAAS;IACjC,oBAAkB;GAClB,CAAA;GACA,MAAM,SAAS,IAAI,oBAAC,KAAD;IAAG,WAAU;cAAuB,MAAM,KAAK,KAAK;GAAK,CAAA,IAAI;GAChF,YACA,oBAAC,KAAD;IAAG,WAAU;IAAwB,aAAU;cAAS;GAErD,CAAA,IACA;GACH,cACA,qBAAC,KAAD;IAAG,WAAU;cAAb,CACC,oBAAC,QAAD,EAAA,UAAO,YAAkB,CAAA,GACzB,oBAAC,UAAD;KACC,MAAK;KACL,WAAU;KACV,SAAS;KACC;eACV;IAEO,CAAA,CACN;QACA;EACO;;AAEd,CACD"}
1
+ {"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled, t }) => {\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\t// Stamped server-side onto the block from plugin config; absent only for stale form docs saved\n\t\t// before uploads were configured (uploading then fails with a visible error, never a guess).\n\t\tconst collection =\n\t\t\ttypeof field.uploadsCollection === 'string' ? field.uploadsCollection : undefined\n\t\tconst types = typesOf(field.mimeTypes)\n\t\tconst accept = types.length > 0 ? types.join(',') : undefined\n\t\t// Zero is a real limit, matching resolveFileRef server-side (filesize > maxSize rejects any non-empty file).\n\t\tconst maxSize =\n\t\t\ttypeof field.maxSize === 'number' && field.maxSize >= 0 ? field.maxSize : undefined\n\t\tconst hints = [\n\t\t\ttypes.length > 0\n\t\t\t\t? resolveMessage(t(keys.fileHintAccepted), { types: types.join(', ') })\n\t\t\t\t: undefined,\n\t\t\tmaxSize !== undefined\n\t\t\t\t? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) })\n\t\t\t\t: undefined,\n\t\t].filter((hint): hint is string => typeof hint === 'string')\n\t\tconst allErrors = localError ? [...errors, localError] : errors\n\t\t// Show the just-uploaded filename, or a generic indicator when the field already holds an id (recall/prefill): the filename is not part of the client value.\n\t\tconst hasValue = value !== undefined && value !== null && value !== ''\n\t\tconst displayName = filename ?? (hasValue ? t(keys.fileUploaded) : undefined)\n\n\t\tconst handleChange = async (event: ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst selected = event.target.files?.[0]\n\t\t\tif (!selected) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (maxSize !== undefined && selected.size > maxSize) {\n\t\t\t\tsetLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!collection) {\n\t\t\t\tsetLocalError(t(keys.fileUploadMisconfigured))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetUploading(true)\n\t\t\tsetLocalError(undefined)\n\t\t\tconst result = await uploadFile({ file: selected, collection })\n\t\t\tsetUploading(false)\n\t\t\tif (result.ok) {\n\t\t\t\tsetFilename(selected.name)\n\t\t\t\tonChange(result.id)\n\t\t\t} else {\n\t\t\t\tsetLocalError(result.message ?? t(keys.fileUploadFailed))\n\t\t\t}\n\t\t}\n\n\t\tconst clear = () => {\n\t\t\tsetFilename(undefined)\n\t\t\tsetLocalError(undefined)\n\t\t\tonChange('')\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={allErrors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"file\"\n\t\t\t\t\taccept={accept}\n\t\t\t\t\tdisabled={disabled || uploading}\n\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\tvoid handleChange(event)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\taria-invalid={allErrors.length > 0}\n\t\t\t\t\taria-describedby={describedById}\n\t\t\t\t/>\n\t\t\t\t{hints.length > 0 ? <p className=\"fb-field__file-hint\">{hints.join(' · ')}</p> : null}\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<p className=\"fb-field__file-status\" aria-live=\"polite\">\n\t\t\t\t\t\t{t(keys.fileUploading)}\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t\t{displayName ? (\n\t\t\t\t\t<p className=\"fb-field__file-name\">\n\t\t\t\t\t\t<span>{displayName}</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName=\"fb-field__file-clear\"\n\t\t\t\t\t\t\tonClick={clear}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{t(keys.fileRemove)}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":"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"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/number.tsx
8
- const numberRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const numberRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  return /* @__PURE__ */ jsx(FieldShell, {
12
10
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"number.js","names":[],"sources":["../../../src/react/renderers/number.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const numberRenderer = defineFieldRenderer<number | undefined>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"number\"\n\t\t\t\t\tvalue={value == null ? '' : String(value)}\n\t\t\t\t\tonChange={(raw) => {\n\t\t\t\t\t\tconst next = Number(raw)\n\t\t\t\t\t\tonChange(raw === '' || Number.isNaN(next) ? undefined : next)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,iBAAiB,qBAC5B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS,OAAO,KAAK,OAAO,KAAK;GACxC,WAAW,QAAQ;IAClB,MAAM,OAAO,OAAO,GAAG;IACvB,SAAS,QAAQ,MAAM,OAAO,MAAM,IAAI,IAAI,KAAA,IAAY,IAAI;GAC7D;GACQ;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"number.js","names":[],"sources":["../../../src/react/renderers/number.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const numberRenderer = defineFieldRenderer<number | undefined>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"number\"\n\t\t\t\t\tvalue={value == null ? '' : String(value)}\n\t\t\t\t\tonChange={(raw) => {\n\t\t\t\t\t\tconst next = Number(raw)\n\t\t\t\t\t\tonChange(raw === '' || Number.isNaN(next) ? undefined : next)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,iBAAiB,qBAC5B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS,OAAO,KAAK,OAAO,KAAK;GACxC,WAAW,QAAQ;IAClB,MAAM,OAAO,OAAO,GAAG;IACvB,SAAS,QAAQ,MAAM,OAAO,MAAM,IAAI,IAAI,KAAA,IAAY,IAAI;GAC7D;GACQ;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -4,7 +4,6 @@ import { defineFieldRenderer } from "../contract.js";
4
4
  import { FieldShell } from "../primitives/FieldShell.js";
5
5
  import { Select } from "../primitives/Select.js";
6
6
  import { jsx } from "react/jsx-runtime";
7
- import { useId } from "react";
8
7
  //#region src/react/renderers/region.tsx
9
8
  /**
10
9
  * A `<select>` over a fixed, code-supplied option set, for field types (country/state) whose choices
@@ -12,8 +11,7 @@ import { useId } from "react";
12
11
  * options as a closure argument instead of reading `field.options`, so the stored value is one of the
13
12
  * fixed codes and the placeholder still gates the empty selection.
14
13
  */
15
- const regionRenderer = (options) => defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
16
- const id = useId();
14
+ const regionRenderer = (options) => defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
17
15
  const describedById = `${id}-desc`;
18
16
  return /* @__PURE__ */ jsx(FieldShell, {
19
17
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"region.js","names":[],"sources":["../../../src/react/renderers/region.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { COUNTRIES, US_STATES } from '../../fields/data/regions'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Select, type SelectOption } from '../primitives/Select'\n\n/**\n * A `<select>` over a fixed, code-supplied option set, for field types (country/state) whose choices\n * live in the plugin rather than in per-instance `options`. Mirrors `selectRenderer` but takes the\n * options as a closure argument instead of reading `field.options`, so the stored value is one of the\n * fixed codes and the placeholder still gates the empty selection.\n */\nconst regionRenderer = (options: SelectOption[]) =>\n\tdefineFieldRenderer<string>(\n\t\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\t\tconst id = useId()\n\t\t\tconst describedById = `${id}-desc`\n\t\t\treturn (\n\t\t\t\t<FieldShell\n\t\t\t\t\tid={id}\n\t\t\t\t\tlabel={field.label}\n\t\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\terrors={errors}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t>\n\t\t\t\t\t<Select\n\t\t\t\t\t\tid={id}\n\t\t\t\t\t\tname={name}\n\t\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\t\toptions={options}\n\t\t\t\t\t\tonChange={onChange}\n\t\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\t\trequired={required}\n\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t\t/>\n\t\t\t\t</FieldShell>\n\t\t\t)\n\t\t}\n\t)\n\nexport const countryRenderer = regionRenderer(COUNTRIES)\nexport const stateRenderer = regionRenderer(US_STATES)\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,kBAAkB,YACvB,qBACE,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,QAAD;GACK;GACE;GACN,OAAO,SAAS;GACP;GACC;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD;AAED,MAAa,kBAAkB,eAAe,SAAS;AACvD,MAAa,gBAAgB,eAAe,SAAS"}
1
+ {"version":3,"file":"region.js","names":[],"sources":["../../../src/react/renderers/region.tsx"],"sourcesContent":["'use client'\n\nimport { COUNTRIES, US_STATES } from '../../fields/data/regions'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Select, type SelectOption } from '../primitives/Select'\n\n/**\n * A `<select>` over a fixed, code-supplied option set, for field types (country/state) whose choices\n * live in the plugin rather than in per-instance `options`. Mirrors `selectRenderer` but takes the\n * options as a closure argument instead of reading `field.options`, so the stored value is one of the\n * fixed codes and the placeholder still gates the empty selection.\n */\nconst regionRenderer = (options: SelectOption[]) =>\n\tdefineFieldRenderer<string>(\n\t\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\t\tconst describedById = `${id}-desc`\n\t\t\treturn (\n\t\t\t\t<FieldShell\n\t\t\t\t\tid={id}\n\t\t\t\t\tlabel={field.label}\n\t\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\terrors={errors}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t>\n\t\t\t\t\t<Select\n\t\t\t\t\t\tid={id}\n\t\t\t\t\t\tname={name}\n\t\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\t\toptions={options}\n\t\t\t\t\t\tonChange={onChange}\n\t\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\t\trequired={required}\n\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t\t/>\n\t\t\t\t</FieldShell>\n\t\t\t)\n\t\t}\n\t)\n\nexport const countryRenderer = regionRenderer(COUNTRIES)\nexport const stateRenderer = regionRenderer(US_STATES)\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAM,kBAAkB,YACvB,qBACE,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,QAAD;GACK;GACE;GACN,OAAO,SAAS;GACP;GACC;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD;AAED,MAAa,kBAAkB,eAAe,SAAS;AACvD,MAAa,gBAAgB,eAAe,SAAS"}
@@ -3,11 +3,14 @@ import { keys } from "../../translations/keys.js";
3
3
  import { isNamedField } from "../../fields/fieldKey.js";
4
4
  import { defineFieldRenderer } from "../contract.js";
5
5
  import { useFormContext } from "../FormContext.js";
6
- import { FieldShell } from "../primitives/FieldShell.js";
7
6
  import { useField } from "../useField.js";
7
+ import { FieldShell } from "../primitives/FieldShell.js";
8
8
  import { jsx, jsxs } from "react/jsx-runtime";
9
- import { createElement, useId } from "react";
9
+ import { createElement, useId, useRef } from "react";
10
10
  //#region src/react/renderers/repeater.tsx
11
+ /** Process-wide counter for stable per-row React keys (rows are plain objects with no id of their own). */
12
+ let rowKeySeq = 0;
13
+ const nextRowKey = () => `fbrow-${rowKeySeq++}`;
11
14
  /**
12
15
  * Thin wrapper so each sub-field renderer is a proper React component (has stable hook identity
13
16
  * per row/field). Reads errors for the composite path `field[row].subField` from
@@ -24,20 +27,28 @@ const SubFieldWrapper = ({ renderer, name, ...props }) => {
24
27
  });
25
28
  };
26
29
  const repeaterRenderer = defineFieldRenderer(({ field, name, id: rootId, errors, required, t, locale }) => {
27
- const { rendererRegistry } = useFormContext();
30
+ const { rendererRegistry, dispatch } = useFormContext();
28
31
  const { value, setValue, onBlur } = useField(name);
29
32
  const addId = useId();
33
+ const rowKeysRef = useRef([]);
30
34
  const rows = Array.isArray(value) ? value : [];
35
+ if (rowKeysRef.current.length !== rows.length) {
36
+ const next = rowKeysRef.current.slice(0, rows.length);
37
+ while (next.length < rows.length) next.push(nextRowKey());
38
+ rowKeysRef.current = next;
39
+ }
31
40
  const subFields = (Array.isArray(field.subFields) ? field.subFields : []).filter(isNamedField);
32
41
  const maxRows = typeof field.maxRows === "number" ? field.maxRows : void 0;
33
42
  const minRows = typeof field.minRows === "number" ? field.minRows : 0;
34
43
  const addLabel = typeof field.addLabel === "string" && field.addLabel.length > 0 ? field.addLabel : t(keys.repeaterAddRow);
35
44
  const addRow = () => {
36
45
  if (maxRows != null && rows.length >= maxRows) return;
46
+ rowKeysRef.current = [...rowKeysRef.current, nextRowKey()];
37
47
  setValue([...rows, {}]);
38
48
  onBlur();
39
49
  };
40
50
  const removeRow = (index) => {
51
+ rowKeysRef.current = rowKeysRef.current.filter((_, i) => i !== index);
41
52
  setValue(rows.filter((_, i) => i !== index));
42
53
  onBlur();
43
54
  };
@@ -46,6 +57,11 @@ const repeaterRenderer = defineFieldRenderer(({ field, name, id: rootId, errors,
46
57
  ...row,
47
58
  [fieldName]: fieldValue
48
59
  } : row));
60
+ dispatch({
61
+ type: "SET_FIELD_ISSUES",
62
+ name: `${name}[${index}].${fieldName}`,
63
+ errors: []
64
+ });
49
65
  };
50
66
  return /* @__PURE__ */ jsx(FieldShell, {
51
67
  id: rootId,
@@ -93,7 +109,7 @@ const repeaterRenderer = defineFieldRenderer(({ field, name, id: rootId, errors,
93
109
  }, subField.name);
94
110
  })
95
111
  })]
96
- }, rowIndex);
112
+ }, rowKeysRef.current[rowIndex]);
97
113
  }), (maxRows == null || rows.length < maxRows) && /* @__PURE__ */ jsx("button", {
98
114
  id: addId,
99
115
  type: "button",
@@ -1 +1 @@
1
- {"version":3,"file":"repeater.js","names":[],"sources":["../../../src/react/renderers/repeater.tsx"],"sourcesContent":["'use client'\n\nimport { createElement, useId } from 'react'\nimport { isNamedField } from '../../fields/fieldKey'\nimport type { FormFieldInstance } from '../../submissions/types'\nimport { keys } from '../../translations/keys'\nimport type { FieldRenderer } from '../contract'\nimport { defineFieldRenderer } from '../contract'\nimport { useFormContext } from '../FormContext'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { useField } from '../useField'\n\ntype RepeaterRow = Record<string, unknown>\n\n/**\n * Thin wrapper so each sub-field renderer is a proper React component (has stable hook identity\n * per row/field). Reads errors for the composite path `field[row].subField` from\n * form state so server-side validation errors are surfaced inline next to the sub-field input,\n * overriding any static values passed by the parent.\n */\nconst SubFieldWrapper = ({\n\trenderer,\n\tname,\n\t...props\n}: { renderer: FieldRenderer } & Parameters<FieldRenderer>[0]) => {\n\tconst { state } = useFormContext()\n\tconst errors = (state.errors as Record<string, string[]>)?.[name] ?? []\n\treturn renderer({ ...props, name, errors })\n}\n\nexport const repeaterRenderer = defineFieldRenderer<RepeaterRow[]>(\n\t({ field, name, id: rootId, errors, required, t, locale }) => {\n\t\tconst { rendererRegistry } = useFormContext()\n\t\tconst { value, setValue, onBlur } = useField<RepeaterRow[]>(name)\n\t\tconst addId = useId()\n\n\t\tconst rows = Array.isArray(value) ? value : []\n\t\tconst subFields = (\n\t\t\tArray.isArray(field.subFields) ? (field.subFields as FormFieldInstance[]) : []\n\t\t).filter(isNamedField)\n\t\tconst maxRows = typeof field.maxRows === 'number' ? field.maxRows : undefined\n\t\tconst minRows = typeof field.minRows === 'number' ? field.minRows : 0\n\t\tconst addLabel =\n\t\t\ttypeof field.addLabel === 'string' && field.addLabel.length > 0\n\t\t\t\t? field.addLabel\n\t\t\t\t: t(keys.repeaterAddRow)\n\n\t\tconst addRow = () => {\n\t\t\tif (maxRows != null && rows.length >= maxRows) return\n\t\t\tsetValue([...rows, {}])\n\t\t\tonBlur()\n\t\t}\n\n\t\tconst removeRow = (index: number) => {\n\t\t\tsetValue(rows.filter((_, i) => i !== index))\n\t\t\tonBlur()\n\t\t}\n\n\t\tconst updateRow = (index: number, fieldName: string, fieldValue: unknown) => {\n\t\t\tsetValue(rows.map((row, i) => (i === index ? { ...row, [fieldName]: fieldValue } : row)))\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={rootId}\n\t\t\t\tlabel={typeof field.label === 'string' ? field.label : undefined}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={`${rootId}-desc`}\n\t\t\t>\n\t\t\t\t<div className=\"fb-repeater\">\n\t\t\t\t\t{rows.map((row, rowIndex) => {\n\t\t\t\t\t\tconst rowLabel = t(keys.repeaterRow).replace('{n}', String(rowIndex + 1))\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t// biome-ignore lint/suspicious/noArrayIndexKey: repeater rows have no stable ID; index key is intentional\n\t\t\t\t\t\t\t<fieldset key={rowIndex} className=\"fb-repeater__row\">\n\t\t\t\t\t\t\t\t<legend className=\"fb-repeater__row-legend\">\n\t\t\t\t\t\t\t\t\t{rowLabel}\n\t\t\t\t\t\t\t\t\t{rows.length > minRows && (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"fb-repeater__remove\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => removeRow(rowIndex)}\n\t\t\t\t\t\t\t\t\t\t\taria-label={`${t(keys.repeaterRemoveRow)} ${rowLabel}`}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{t(keys.repeaterRemoveRow)}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</legend>\n\t\t\t\t\t\t\t\t<div className=\"fb-repeater__row-fields\">\n\t\t\t\t\t\t\t\t\t{subFields.map((subField) => {\n\t\t\t\t\t\t\t\t\t\tconst renderer = rendererRegistry.get(subField.blockType)\n\t\t\t\t\t\t\t\t\t\tif (!renderer) return null\n\t\t\t\t\t\t\t\t\t\tconst subId = `${rootId}-${rowIndex}-${subField.name}`\n\t\t\t\t\t\t\t\t\t\tconst compositeName = `${name}[${rowIndex}].${subField.name}`\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div key={subField.name} className=\"fb-repeater__sub-field\">\n\t\t\t\t\t\t\t\t\t\t\t\t{createElement(SubFieldWrapper, {\n\t\t\t\t\t\t\t\t\t\t\t\t\trenderer,\n\t\t\t\t\t\t\t\t\t\t\t\t\tfield: subField,\n\t\t\t\t\t\t\t\t\t\t\t\t\tid: subId,\n\t\t\t\t\t\t\t\t\t\t\t\t\tname: compositeName,\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue: row[subField.name],\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange: (v) => updateRow(rowIndex, subField.name, v),\n\t\t\t\t\t\t\t\t\t\t\t\t\tonBlur,\n\t\t\t\t\t\t\t\t\t\t\t\t\t// SubFieldWrapper overrides these from form state; passed here to satisfy the type\n\t\t\t\t\t\t\t\t\t\t\t\t\terrors: [],\n\t\t\t\t\t\t\t\t\t\t\t\t\trequired: subField.required ?? false,\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled: false,\n\t\t\t\t\t\t\t\t\t\t\t\t\tlocale,\n\t\t\t\t\t\t\t\t\t\t\t\t\tt,\n\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t\t{(maxRows == null || rows.length < maxRows) && (\n\t\t\t\t\t\t<button id={addId} type=\"button\" className=\"fb-repeater__add\" onClick={addRow}>\n\t\t\t\t\t\t\t{addLabel}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":"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"}
1
+ {"version":3,"file":"repeater.js","names":[],"sources":["../../../src/react/renderers/repeater.tsx"],"sourcesContent":["'use client'\n\nimport { createElement, useId, useRef } from 'react'\nimport { isNamedField } from '../../fields/fieldKey'\nimport type { FormFieldInstance } from '../../submissions/types'\nimport { keys } from '../../translations/keys'\nimport type { FieldRenderer } from '../contract'\nimport { defineFieldRenderer } from '../contract'\nimport { useFormContext } from '../FormContext'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { useField } from '../useField'\n\ntype RepeaterRow = Record<string, unknown>\n\n/** Process-wide counter for stable per-row React keys (rows are plain objects with no id of their own). */\nlet rowKeySeq = 0\nconst nextRowKey = () => `fbrow-${rowKeySeq++}`\n\n/**\n * Thin wrapper so each sub-field renderer is a proper React component (has stable hook identity\n * per row/field). Reads errors for the composite path `field[row].subField` from\n * form state so server-side validation errors are surfaced inline next to the sub-field input,\n * overriding any static values passed by the parent.\n */\nconst SubFieldWrapper = ({\n\trenderer,\n\tname,\n\t...props\n}: { renderer: FieldRenderer } & Parameters<FieldRenderer>[0]) => {\n\tconst { state } = useFormContext()\n\tconst errors = (state.errors as Record<string, string[]>)?.[name] ?? []\n\treturn renderer({ ...props, name, errors })\n}\n\nexport const repeaterRenderer = defineFieldRenderer<RepeaterRow[]>(\n\t({ field, name, id: rootId, errors, required, t, locale }) => {\n\t\tconst { rendererRegistry, dispatch } = useFormContext()\n\t\tconst { value, setValue, onBlur } = useField<RepeaterRow[]>(name)\n\t\tconst addId = useId()\n\t\tconst rowKeysRef = useRef<string[]>([])\n\n\t\tconst rows = Array.isArray(value) ? value : []\n\t\t// One stable key per row so removing a middle row does not re-index survivors onto each other's\n\t\t// React instances (which would strand a stateful sub-renderer's local state, e.g. the file\n\t\t// renderer's filename). Reconciled to the row count each render; add/remove keep keys aligned.\n\t\tif (rowKeysRef.current.length !== rows.length) {\n\t\t\tconst next = rowKeysRef.current.slice(0, rows.length)\n\t\t\twhile (next.length < rows.length) next.push(nextRowKey())\n\t\t\trowKeysRef.current = next\n\t\t}\n\t\tconst subFields = (\n\t\t\tArray.isArray(field.subFields) ? (field.subFields as FormFieldInstance[]) : []\n\t\t).filter(isNamedField)\n\t\tconst maxRows = typeof field.maxRows === 'number' ? field.maxRows : undefined\n\t\tconst minRows = typeof field.minRows === 'number' ? field.minRows : 0\n\t\tconst addLabel =\n\t\t\ttypeof field.addLabel === 'string' && field.addLabel.length > 0\n\t\t\t\t? field.addLabel\n\t\t\t\t: t(keys.repeaterAddRow)\n\n\t\tconst addRow = () => {\n\t\t\tif (maxRows != null && rows.length >= maxRows) return\n\t\t\trowKeysRef.current = [...rowKeysRef.current, nextRowKey()]\n\t\t\tsetValue([...rows, {}])\n\t\t\tonBlur()\n\t\t}\n\n\t\tconst removeRow = (index: number) => {\n\t\t\trowKeysRef.current = rowKeysRef.current.filter((_, i) => i !== index)\n\t\t\tsetValue(rows.filter((_, i) => i !== index))\n\t\t\tonBlur()\n\t\t}\n\n\t\tconst updateRow = (index: number, fieldName: string, fieldValue: unknown) => {\n\t\t\tsetValue(rows.map((row, i) => (i === index ? { ...row, [fieldName]: fieldValue } : row)))\n\t\t\t// SET_VALUE clears only the repeater field's own error key, not composite sub-field keys, so a\n\t\t\t// stale server-side error for this sub-field would linger until the next submit. Clear it now.\n\t\t\tdispatch({ type: 'SET_FIELD_ISSUES', name: `${name}[${index}].${fieldName}`, errors: [] })\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={rootId}\n\t\t\t\tlabel={typeof field.label === 'string' ? field.label : undefined}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={`${rootId}-desc`}\n\t\t\t>\n\t\t\t\t<div className=\"fb-repeater\">\n\t\t\t\t\t{rows.map((row, rowIndex) => {\n\t\t\t\t\t\tconst rowLabel = t(keys.repeaterRow).replace('{n}', String(rowIndex + 1))\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<fieldset key={rowKeysRef.current[rowIndex]} className=\"fb-repeater__row\">\n\t\t\t\t\t\t\t\t<legend className=\"fb-repeater__row-legend\">\n\t\t\t\t\t\t\t\t\t{rowLabel}\n\t\t\t\t\t\t\t\t\t{rows.length > minRows && (\n\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"fb-repeater__remove\"\n\t\t\t\t\t\t\t\t\t\t\tonClick={() => removeRow(rowIndex)}\n\t\t\t\t\t\t\t\t\t\t\taria-label={`${t(keys.repeaterRemoveRow)} ${rowLabel}`}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{t(keys.repeaterRemoveRow)}\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</legend>\n\t\t\t\t\t\t\t\t<div className=\"fb-repeater__row-fields\">\n\t\t\t\t\t\t\t\t\t{subFields.map((subField) => {\n\t\t\t\t\t\t\t\t\t\tconst renderer = rendererRegistry.get(subField.blockType)\n\t\t\t\t\t\t\t\t\t\tif (!renderer) return null\n\t\t\t\t\t\t\t\t\t\tconst subId = `${rootId}-${rowIndex}-${subField.name}`\n\t\t\t\t\t\t\t\t\t\tconst compositeName = `${name}[${rowIndex}].${subField.name}`\n\t\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t\t<div key={subField.name} className=\"fb-repeater__sub-field\">\n\t\t\t\t\t\t\t\t\t\t\t\t{createElement(SubFieldWrapper, {\n\t\t\t\t\t\t\t\t\t\t\t\t\trenderer,\n\t\t\t\t\t\t\t\t\t\t\t\t\tfield: subField,\n\t\t\t\t\t\t\t\t\t\t\t\t\tid: subId,\n\t\t\t\t\t\t\t\t\t\t\t\t\tname: compositeName,\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue: row[subField.name],\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange: (v) => updateRow(rowIndex, subField.name, v),\n\t\t\t\t\t\t\t\t\t\t\t\t\tonBlur,\n\t\t\t\t\t\t\t\t\t\t\t\t\t// SubFieldWrapper overrides these from form state; passed here to satisfy the type\n\t\t\t\t\t\t\t\t\t\t\t\t\terrors: [],\n\t\t\t\t\t\t\t\t\t\t\t\t\trequired: subField.required ?? false,\n\t\t\t\t\t\t\t\t\t\t\t\t\tdisabled: false,\n\t\t\t\t\t\t\t\t\t\t\t\t\tlocale,\n\t\t\t\t\t\t\t\t\t\t\t\t\tt,\n\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t\t)\n\t\t\t\t\t})}\n\t\t\t\t\t{(maxRows == null || rows.length < maxRows) && (\n\t\t\t\t\t\t<button id={addId} type=\"button\" className=\"fb-repeater__add\" onClick={addRow}>\n\t\t\t\t\t\t\t{addLabel}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":"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"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Select } from "../primitives/Select.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/select.tsx
8
- const selectRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const selectRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  const options = Array.isArray(field.options) ? field.options : [];
12
10
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -1 +1 @@
1
- {"version":3,"file":"select.js","names":[],"sources":["../../../src/react/renderers/select.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Select } from '../primitives/Select'\n\nexport const selectRenderer = defineFieldRenderer<string>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst options = Array.isArray(field.options)\n\t\t\t? (field.options as { label: string; value: string }[])\n\t\t\t: []\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Select\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\toptions={options}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,iBAAiB,qBAC5B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,UAAU,MAAM,QAAQ,MAAM,OAAO,IACvC,MAAM,UACP,CAAC;CACJ,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,QAAD;GACK;GACE;GACN,OAAO,SAAS;GACP;GACC;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"select.js","names":[],"sources":["../../../src/react/renderers/select.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Select } from '../primitives/Select'\n\nexport const selectRenderer = defineFieldRenderer<string>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\tconst options = Array.isArray(field.options)\n\t\t\t? (field.options as { label: string; value: string }[])\n\t\t\t: []\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Select\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\toptions={options}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,iBAAiB,qBAC5B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,UAAU,MAAM,QAAQ,MAAM,OAAO,IACvC,MAAM,UACP,CAAC;CACJ,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,QAAD;GACK;GACE;GACN,OAAO,SAAS;GACP;GACC;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Input } from "../primitives/Input.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/text.tsx
8
- const textRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const textRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  return /* @__PURE__ */ jsx(FieldShell, {
12
10
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"text.js","names":[],"sources":["../../../src/react/renderers/text.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const textRenderer = defineFieldRenderer<string>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"text.js","names":[],"sources":["../../../src/react/renderers/text.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Input } from '../primitives/Input'\n\nexport const textRenderer = defineFieldRenderer<string>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,eAAe,qBAC1B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,OAAD;GACK;GACE;GACN,MAAK;GACL,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -3,10 +3,8 @@ import { defineFieldRenderer } from "../contract.js";
3
3
  import { FieldShell } from "../primitives/FieldShell.js";
4
4
  import { Textarea } from "../primitives/Textarea.js";
5
5
  import { jsx } from "react/jsx-runtime";
6
- import { useId } from "react";
7
6
  //#region src/react/renderers/textarea.tsx
8
- const textareaRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, required, disabled }) => {
9
- const id = useId();
7
+ const textareaRenderer = defineFieldRenderer(({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {
10
8
  const describedById = `${id}-desc`;
11
9
  return /* @__PURE__ */ jsx(FieldShell, {
12
10
  id,
@@ -1 +1 @@
1
- {"version":3,"file":"textarea.js","names":[],"sources":["../../../src/react/renderers/textarea.tsx"],"sourcesContent":["'use client'\n\nimport { useId } from 'react'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Textarea } from '../primitives/Textarea'\n\nexport const textareaRenderer = defineFieldRenderer<string>(\n\t({ field, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Textarea\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;AAOA,MAAa,mBAAmB,qBAC9B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CACzE,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,UAAD;GACK;GACE;GACN,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
1
+ {"version":3,"file":"textarea.js","names":[],"sources":["../../../src/react/renderers/textarea.tsx"],"sourcesContent":["'use client'\n\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { Textarea } from '../primitives/Textarea'\n\nexport const textareaRenderer = defineFieldRenderer<string>(\n\t({ field, id, name, value, onChange, onBlur, errors, required, disabled }) => {\n\t\tconst describedById = `${id}-desc`\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={errors}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<Textarea\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value ?? ''}\n\t\t\t\t\tonChange={onChange}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\tplaceholder={typeof field.placeholder === 'string' ? field.placeholder : undefined}\n\t\t\t\t\trequired={required}\n\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\tinvalid={errors.length > 0}\n\t\t\t\t\tdescribedById={describedById}\n\t\t\t\t/>\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;AAMA,MAAa,mBAAmB,qBAC9B,EAAE,OAAO,IAAI,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,eAAe;CAC7E,MAAM,gBAAgB,GAAG,GAAG;CAC5B,OACC,oBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACF;EACO;YAEf,oBAAC,UAAD;GACK;GACE;GACN,OAAO,SAAS;GACN;GACF;GACR,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;GAC/D;GACA;GACV,SAAS,OAAO,SAAS;GACV;EACf,CAAA;CACU,CAAA;AAEd,CACD"}
@@ -7,9 +7,15 @@
7
7
  const DEFAULT_HONEYPOT_FIELD = "website";
8
8
  /** Reserved `values` entry carrying a captcha token from the client. */
9
9
  const CAPTCHA_TOKEN_KEY = "__fb_captcha";
10
+ /**
11
+ * Reserved `values` entry the honeypot decoy is submitted under. The decoy's DOM input keeps a
12
+ * plausible cosmetic name (e.g. `website`), but the submitted value rides this fixed key so a real
13
+ * field sharing that name is never stripped or mistaken for the decoy.
14
+ */
15
+ const HONEYPOT_VALUE_KEY = "__fb_hp";
10
16
  /** `req.context` key under which the spam guard stashes the resolved identity for upload-ownership verification. */
11
17
  const IDENTITY_CONTEXT_KEY = "formBuilderSpamIdentity";
12
18
  //#endregion
13
- export { CAPTCHA_TOKEN_KEY, DEFAULT_HONEYPOT_FIELD, IDENTITY_CONTEXT_KEY };
19
+ export { CAPTCHA_TOKEN_KEY, DEFAULT_HONEYPOT_FIELD, HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY };
14
20
 
15
21
  //# sourceMappingURL=constants.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","names":[],"sources":["../../src/spam/constants.ts"],"sourcesContent":["/**\n * Default honeypot decoy field name. \"website\" is plausible to bots but doesn't trigger Chrome's\n * email-address autofill (unlike names containing \"email\"). Bots fill every input; real users\n * never see the hidden field.\n */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/** Reserved `values` entry carrying a captcha token from the client. */\nexport const CAPTCHA_TOKEN_KEY = '__fb_captcha'\n\n/** `req.context` key under which the spam guard stashes the resolved identity for upload-ownership verification. */\nexport const IDENTITY_CONTEXT_KEY = 'formBuilderSpamIdentity'\n"],"mappings":";;;;;;AAKA,MAAa,yBAAyB;;AAGtC,MAAa,oBAAoB;;AAGjC,MAAa,uBAAuB"}
1
+ {"version":3,"file":"constants.js","names":[],"sources":["../../src/spam/constants.ts"],"sourcesContent":["/**\n * Default honeypot decoy field name. \"website\" is plausible to bots but doesn't trigger Chrome's\n * email-address autofill (unlike names containing \"email\"). Bots fill every input; real users\n * never see the hidden field.\n */\nexport const DEFAULT_HONEYPOT_FIELD = 'website'\n\n/** Reserved `values` entry carrying a captcha token from the client. */\nexport const CAPTCHA_TOKEN_KEY = '__fb_captcha'\n\n/**\n * Reserved `values` entry the honeypot decoy is submitted under. The decoy's DOM input keeps a\n * plausible cosmetic name (e.g. `website`), but the submitted value rides this fixed key so a real\n * field sharing that name is never stripped or mistaken for the decoy.\n */\nexport const HONEYPOT_VALUE_KEY = '__fb_hp'\n\n/** `req.context` key under which the spam guard stashes the resolved identity for upload-ownership verification. */\nexport const IDENTITY_CONTEXT_KEY = 'formBuilderSpamIdentity'\n"],"mappings":";;;;;;AAKA,MAAa,yBAAyB;;AAGtC,MAAa,oBAAoB;;;;;;AAOjC,MAAa,qBAAqB;;AAGlC,MAAa,uBAAuB"}
@@ -33,7 +33,8 @@ const resolveSpamConfig = (option) => {
33
33
  metadata: {
34
34
  ip: cfg.metadata?.ip ?? false,
35
35
  ua: cfg.metadata?.ua ?? false
36
- }
36
+ },
37
+ uploadOwnership: cfg.uploadOwnership ?? "lenient"
37
38
  };
38
39
  };
39
40
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"resolveSpam.js","names":[],"sources":["../../src/spam/resolveSpam.ts"],"sourcesContent":["import { DEFAULT_HONEYPOT_FIELD } from './constants'\nimport { defaultIdentify } from './identify'\nimport { createKvRateLimiter } from './rateLimiter'\nimport type {\n\tRateLimitConfig,\n\tResolvedRateLimit,\n\tResolvedSpamConfig,\n\tSpamConfig,\n\tSpamOption,\n} from './types'\n\nconst DEFAULT_WINDOW = 60_000\nconst DEFAULT_SUBMISSION_MAX = 5\nconst DEFAULT_UPLOAD_MAX = 20\n\nconst resolveRateLimit = (\n\toption: false | RateLimitConfig | undefined,\n\tdefaultMax: number\n): false | ResolvedRateLimit => {\n\tif (option === false) {\n\t\treturn false\n\t}\n\tconst cfg = option ?? {}\n\treturn {\n\t\twindow: cfg.window ?? DEFAULT_WINDOW,\n\t\tmax: cfg.max ?? defaultMax,\n\t\tlimiter: cfg.limiter ?? createKvRateLimiter(),\n\t}\n}\n\n/**\n * Resolve the `spam` plugin option into a concrete config. `false` disables the whole subsystem.\n * Otherwise honeypot + both rate limits default on, captcha + metadata capture default off, and the\n * identity seam defaults to user id / trusted IP header.\n */\nexport const resolveSpamConfig = (option: SpamOption | undefined): ResolvedSpamConfig | false => {\n\tif (option === false) {\n\t\treturn false\n\t}\n\tconst cfg: SpamConfig = option ?? {}\n\tconst ipHeader = cfg.ipHeader ?? 'x-forwarded-for'\n\treturn {\n\t\thoneypot:\n\t\t\tcfg.honeypot === false\n\t\t\t\t? false\n\t\t\t\t: { fieldName: cfg.honeypot?.fieldName ?? DEFAULT_HONEYPOT_FIELD },\n\t\trateLimit: resolveRateLimit(cfg.rateLimit, DEFAULT_SUBMISSION_MAX),\n\t\tuploadRateLimit: resolveRateLimit(cfg.uploadRateLimit, DEFAULT_UPLOAD_MAX),\n\t\tcaptcha: cfg.captcha,\n\t\tidentify: cfg.identify ?? defaultIdentify(ipHeader),\n\t\tipHeader,\n\t\tmetadata: { ip: cfg.metadata?.ip ?? false, ua: cfg.metadata?.ua ?? false },\n\t}\n}\n"],"mappings":";;;;AAWA,MAAM,iBAAiB;AACvB,MAAM,yBAAyB;AAC/B,MAAM,qBAAqB;AAE3B,MAAM,oBACL,QACA,eAC+B;CAC/B,IAAI,WAAW,OACd,OAAO;CAER,MAAM,MAAM,UAAU,CAAC;CACvB,OAAO;EACN,QAAQ,IAAI,UAAU;EACtB,KAAK,IAAI,OAAO;EAChB,SAAS,IAAI,WAAW,oBAAoB;CAC7C;AACD;;;;;;AAOA,MAAa,qBAAqB,WAA+D;CAChG,IAAI,WAAW,OACd,OAAO;CAER,MAAM,MAAkB,UAAU,CAAC;CACnC,MAAM,WAAW,IAAI,YAAY;CACjC,OAAO;EACN,UACC,IAAI,aAAa,QACd,QACA,EAAE,WAAW,IAAI,UAAU,aAAA,UAAoC;EACnE,WAAW,iBAAiB,IAAI,WAAW,sBAAsB;EACjE,iBAAiB,iBAAiB,IAAI,iBAAiB,kBAAkB;EACzE,SAAS,IAAI;EACb,UAAU,IAAI,YAAY,gBAAgB,QAAQ;EAClD;EACA,UAAU;GAAE,IAAI,IAAI,UAAU,MAAM;GAAO,IAAI,IAAI,UAAU,MAAM;EAAM;CAC1E;AACD"}
1
+ {"version":3,"file":"resolveSpam.js","names":[],"sources":["../../src/spam/resolveSpam.ts"],"sourcesContent":["import { DEFAULT_HONEYPOT_FIELD } from './constants'\nimport { defaultIdentify } from './identify'\nimport { createKvRateLimiter } from './rateLimiter'\nimport type {\n\tRateLimitConfig,\n\tResolvedRateLimit,\n\tResolvedSpamConfig,\n\tSpamConfig,\n\tSpamOption,\n} from './types'\n\nconst DEFAULT_WINDOW = 60_000\nconst DEFAULT_SUBMISSION_MAX = 5\nconst DEFAULT_UPLOAD_MAX = 20\n\nconst resolveRateLimit = (\n\toption: false | RateLimitConfig | undefined,\n\tdefaultMax: number\n): false | ResolvedRateLimit => {\n\tif (option === false) {\n\t\treturn false\n\t}\n\tconst cfg = option ?? {}\n\treturn {\n\t\twindow: cfg.window ?? DEFAULT_WINDOW,\n\t\tmax: cfg.max ?? defaultMax,\n\t\tlimiter: cfg.limiter ?? createKvRateLimiter(),\n\t}\n}\n\n/**\n * Resolve the `spam` plugin option into a concrete config. `false` disables the whole subsystem.\n * Otherwise honeypot + both rate limits default on, captcha + metadata capture default off, and the\n * identity seam defaults to user id / trusted IP header.\n */\nexport const resolveSpamConfig = (option: SpamOption | undefined): ResolvedSpamConfig | false => {\n\tif (option === false) {\n\t\treturn false\n\t}\n\tconst cfg: SpamConfig = option ?? {}\n\tconst ipHeader = cfg.ipHeader ?? 'x-forwarded-for'\n\treturn {\n\t\thoneypot:\n\t\t\tcfg.honeypot === false\n\t\t\t\t? false\n\t\t\t\t: { fieldName: cfg.honeypot?.fieldName ?? DEFAULT_HONEYPOT_FIELD },\n\t\trateLimit: resolveRateLimit(cfg.rateLimit, DEFAULT_SUBMISSION_MAX),\n\t\tuploadRateLimit: resolveRateLimit(cfg.uploadRateLimit, DEFAULT_UPLOAD_MAX),\n\t\tcaptcha: cfg.captcha,\n\t\tidentify: cfg.identify ?? defaultIdentify(ipHeader),\n\t\tipHeader,\n\t\tmetadata: { ip: cfg.metadata?.ip ?? false, ua: cfg.metadata?.ua ?? false },\n\t\tuploadOwnership: cfg.uploadOwnership ?? 'lenient',\n\t}\n}\n"],"mappings":";;;;AAWA,MAAM,iBAAiB;AACvB,MAAM,yBAAyB;AAC/B,MAAM,qBAAqB;AAE3B,MAAM,oBACL,QACA,eAC+B;CAC/B,IAAI,WAAW,OACd,OAAO;CAER,MAAM,MAAM,UAAU,CAAC;CACvB,OAAO;EACN,QAAQ,IAAI,UAAU;EACtB,KAAK,IAAI,OAAO;EAChB,SAAS,IAAI,WAAW,oBAAoB;CAC7C;AACD;;;;;;AAOA,MAAa,qBAAqB,WAA+D;CAChG,IAAI,WAAW,OACd,OAAO;CAER,MAAM,MAAkB,UAAU,CAAC;CACnC,MAAM,WAAW,IAAI,YAAY;CACjC,OAAO;EACN,UACC,IAAI,aAAa,QACd,QACA,EAAE,WAAW,IAAI,UAAU,aAAA,UAAoC;EACnE,WAAW,iBAAiB,IAAI,WAAW,sBAAsB;EACjE,iBAAiB,iBAAiB,IAAI,iBAAiB,kBAAkB;EACzE,SAAS,IAAI;EACb,UAAU,IAAI,YAAY,gBAAgB,QAAQ;EAClD;EACA,UAAU;GAAE,IAAI,IAAI,UAAU,MAAM;GAAO,IAAI,IAAI,UAAU,MAAM;EAAM;EACzE,iBAAiB,IAAI,mBAAmB;CACzC;AACD"}
@@ -1,10 +1,17 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asTranslate } from "../translations/server.js";
3
3
  import { firstHop } from "./clientIp.js";
4
- import { IDENTITY_CONTEXT_KEY } from "./constants.js";
4
+ import { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from "./constants.js";
5
5
  import { extractReservedValues, isHoneypotTripped } from "./reserved.js";
6
6
  import { APIError } from "payload";
7
7
  //#region src/spam/spamGuard.ts
8
+ let warnedRateLimitSkipped = false;
9
+ /** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */
10
+ const warnRateLimitSkippedOnce = (req) => {
11
+ if (warnedRateLimitSkipped) return;
12
+ warnedRateLimitSkipped = true;
13
+ req.payload.logger?.warn("@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.");
14
+ };
8
15
  /**
9
16
  * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before
10
17
  * the form load + field validation. On create it: resolves the request identity once (stashing it on
@@ -20,7 +27,12 @@ const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
20
27
  const authenticated = Boolean(req.user);
21
28
  const identity = await spam.identify(req);
22
29
  if (identity != null) req.context[IDENTITY_CONTEXT_KEY] = identity;
23
- if (spam.rateLimit !== false && identity != null) {
30
+ let rateLimitState;
31
+ if (spam.rateLimit === false) rateLimitState = "disabled";
32
+ else if (identity == null) {
33
+ rateLimitState = "skipped-no-identity";
34
+ warnRateLimitSkippedOnce(req);
35
+ } else {
24
36
  const formKey = data.form != null ? String(data.form) : "unknown";
25
37
  const { ok } = await spam.rateLimit.limiter.check({
26
38
  key: `submissions:${formKey}:${identity}`,
@@ -29,11 +41,12 @@ const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
29
41
  req
30
42
  });
31
43
  if (!ok) throw new APIError(t(keys.spamRateLimited), 429);
44
+ rateLimitState = "enforced";
32
45
  }
33
- const honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName;
34
- const { cleaned, honeypot, captchaToken } = extractReservedValues(data.values ?? [], honeypotField);
46
+ const honeypotKey = spam.honeypot === false ? null : HONEYPOT_VALUE_KEY;
47
+ const { cleaned, honeypot, captchaToken } = extractReservedValues(data.values ?? [], honeypotKey);
35
48
  data.values = cleaned;
36
- if (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) throw new APIError(t(keys.spamRejected), 400);
49
+ if (!authenticated && honeypotKey !== null && isHoneypotTripped(honeypot)) throw new APIError(t(keys.spamRejected), 400);
37
50
  const captchaChecked = Boolean(spam.captcha) && !authenticated;
38
51
  if (spam.captcha && !authenticated) {
39
52
  if (!(typeof captchaToken === "string" && captchaToken.length > 0 ? await spam.captcha.verify({
@@ -43,7 +56,10 @@ const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
43
56
  }
44
57
  const serverMeta = {
45
58
  at: (/* @__PURE__ */ new Date()).toISOString(),
46
- spam: { captcha: captchaChecked ? "passed" : "skipped" }
59
+ spam: {
60
+ captcha: captchaChecked ? "passed" : "skipped",
61
+ rateLimit: rateLimitState
62
+ }
47
63
  };
48
64
  if (spam.metadata.ip) {
49
65
  const ip = firstHop(req.headers, spam.ipHeader);
@@ -1 +1 @@
1
- {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tif (spam.rateLimit !== false && identity != null) {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t}\n\n\t\tconst honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotField)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped' },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI,KAAK,cAAc,SAAS,YAAY,MAAM;EACjD,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;CAEjD;CAEA,MAAM,gBAAgB,KAAK,aAAa,QAAQ,OAAO,KAAK,SAAS;CAErE,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,aAAa;CACvF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,kBAAkB,QAAQ,kBAAkB,QAAQ,GACzE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM,EAAE,SAAS,iBAAiB,WAAW,UAAU;CACxD;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
1
+ {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, type PayloadRequest } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nlet warnedRateLimitSkipped = false\n\n/** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */\nconst warnRateLimitSkippedOnce = (req: PayloadRequest): void => {\n\tif (warnedRateLimitSkipped) {\n\t\treturn\n\t}\n\twarnedRateLimitSkipped = true\n\treq.payload.logger?.warn(\n\t\t'@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.'\n\t)\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tlet rateLimitState: 'enforced' | 'skipped-no-identity' | 'disabled'\n\t\tif (spam.rateLimit === false) {\n\t\t\trateLimitState = 'disabled'\n\t\t} else if (identity == null) {\n\t\t\t// Fail open (an unidentifiable submitter cannot be keyed) but never silently: record it on\n\t\t\t// meta.spam and warn once, so a deployment with no proxy or a misconfigured ipHeader is visible.\n\t\t\trateLimitState = 'skipped-no-identity'\n\t\t\twarnRateLimitSkippedOnce(req)\n\t\t} else {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t\trateLimitState = 'enforced'\n\t\t}\n\n\t\t// The decoy rides a fixed reserved key (not its cosmetic DOM name), so a real field sharing that\n\t\t// name is never stripped or mistaken for the honeypot. `spam.honeypot.fieldName` is now only the\n\t\t// client's DOM-input name hint and no longer participates in server matching.\n\t\tconst honeypotKey = spam.honeypot === false ? null : HONEYPOT_VALUE_KEY\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotKey)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotKey !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped', rateLimit: rateLimitState },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;AASA,IAAI,yBAAyB;;AAG7B,MAAM,4BAA4B,QAA8B;CAC/D,IAAI,wBACH;CAED,yBAAyB;CACzB,IAAI,QAAQ,QAAQ,KACnB,uNACD;AACD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI;CACJ,IAAI,KAAK,cAAc,OACtB,iBAAiB;MACX,IAAI,YAAY,MAAM;EAG5B,iBAAiB;EACjB,yBAAyB,GAAG;CAC7B,OAAO;EACN,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;EAEhD,iBAAiB;CAClB;CAKA,MAAM,cAAc,KAAK,aAAa,QAAQ,OAAO;CAErD,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,WAAW;CACrF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,gBAAgB,QAAQ,kBAAkB,QAAQ,GACvE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM;GAAE,SAAS,iBAAiB,WAAW;GAAW,WAAW;EAAe;CACnF;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}