@aglyn/aglyn 1.0.0-beta.246 → 1.0.0-beta.249

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 (55) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/contact-custom-fields.d.ts +23 -1
  3. package/src/lib/app-utils/contact-custom-fields.js +84 -2
  4. package/src/lib/app-utils/contact-custom-fields.js.map +1 -1
  5. package/src/lib/app-utils/crm.d.ts +7 -1
  6. package/src/lib/app-utils/crm.js.map +1 -1
  7. package/src/lib/app-utils/default-site.js +21 -33
  8. package/src/lib/app-utils/default-site.js.map +1 -1
  9. package/src/lib/app-utils/docs-help-sections.generated.js +8 -8
  10. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  11. package/src/lib/app-utils/docs-help.generated.d.ts +8 -8
  12. package/src/lib/app-utils/docs-help.generated.js +9 -5
  13. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  14. package/src/lib/app-utils/docs-index.generated.js +129 -59
  15. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  16. package/src/lib/app-utils/element-pick-protocol.d.ts +54 -0
  17. package/src/lib/app-utils/element-pick-protocol.js +41 -0
  18. package/src/lib/app-utils/element-pick-protocol.js.map +1 -0
  19. package/src/lib/app-utils/element-pick-target.d.ts +32 -0
  20. package/src/lib/app-utils/element-pick-target.js +47 -0
  21. package/src/lib/app-utils/element-pick-target.js.map +1 -0
  22. package/src/lib/app-utils/error-beacon.d.ts +19 -0
  23. package/src/lib/app-utils/error-beacon.js +31 -5
  24. package/src/lib/app-utils/error-beacon.js.map +1 -1
  25. package/src/lib/app-utils/form-contract.js +13 -6
  26. package/src/lib/app-utils/form-contract.js.map +1 -1
  27. package/src/lib/app-utils/forms.d.ts +31 -0
  28. package/src/lib/app-utils/forms.js +213 -16
  29. package/src/lib/app-utils/forms.js.map +1 -1
  30. package/src/lib/app-utils/node-definition-sanitizer.js +12 -0
  31. package/src/lib/app-utils/node-definition-sanitizer.js.map +1 -1
  32. package/src/lib/app-utils/seo-locale.d.ts +14 -0
  33. package/src/lib/app-utils/seo-locale.js +15 -0
  34. package/src/lib/app-utils/seo-locale.js.map +1 -1
  35. package/src/lib/app-utils/seo-site-scan.js +18 -3
  36. package/src/lib/app-utils/seo-site-scan.js.map +1 -1
  37. package/src/lib/app-utils/site-live-url.d.ts +24 -0
  38. package/src/lib/app-utils/site-live-url.js +60 -0
  39. package/src/lib/app-utils/site-live-url.js.map +1 -0
  40. package/src/lib/app-utils/starter-template-nodes.d.ts +13 -0
  41. package/src/lib/app-utils/starter-template-nodes.js +40 -0
  42. package/src/lib/app-utils/starter-template-nodes.js.map +1 -1
  43. package/src/lib/app-utils/starter-templates.d.ts +1 -1
  44. package/src/lib/app-utils/starter-templates.js +19 -31
  45. package/src/lib/app-utils/starter-templates.js.map +1 -1
  46. package/src/lib/plugin-manager/feature-plugins.d.ts +74 -3
  47. package/src/lib/plugin-manager/feature-plugins.js +9 -0
  48. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  49. package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +11 -0
  50. package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
  51. package/src/lib/plugin-manager/plugin-resource-drafts.d.ts +23 -0
  52. package/src/lib/plugin-manager/plugin-resource-drafts.js.map +1 -1
  53. package/src/lib/plugin-manager/stock-photo-provider.d.ts +67 -0
  54. package/src/lib/plugin-manager/stock-photo-provider.js +14 -1
  55. package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/form-contract.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What a form's DESIGN must still satisfy for its submissions to arrive.\n *\n * A reusable component is only ever drawn: the worst an author can do to one\n * is make it ugly, and the page still renders. A form is drawn AND it is a\n * contract — `/api/forms/submit` keys submissions on its id, reads consent out\n * of a field it names, and creates a lead from an address it expects to find.\n * Every one of those couplings is resolved by NAME at submit time, so the\n * design can break them, and breaking them is silent in every case: the form\n * still renders, the visitor still submits, the row still lands, and the thing\n * the merchant believed they were collecting is simply not there.\n *\n * That asymmetry is the whole reason this module exists. Publishing a form is\n * not \"save the drawing\"; it is \"promise the server can still read this\".\n *\n * ## The six silent failures, and why each one is checked here\n *\n * Every check below corresponds to something a design can do that produces NO\n * error at submit time. A design that fails loudly needs no guard — the author\n * finds out. These are the ones where nobody finds out.\n *\n * 1. **The form node stops naming this form.** Submissions carry `formId` and\n * the route stamps it only after verifying it against this host's forms;\n * the Inbox's per-form list is an equality on it. Clear or rebind it and\n * every later submission is unattributable — while still landing in the\n * Inbox, so nothing looks broken and the form's own list just stops\n * growing.\n * 2. **A field with no name.** `formFieldDeclsFromNodes` drops it, and the\n * runtime falls back to `name = fieldName || 'field'`, so several unnamed\n * inputs collapse onto ONE submission key. The visitor types, the value is\n * posted, and it is not retrievable under any name the author knows.\n * 3. **Two fields sharing a name.** `FormData` joins entries under one key,\n * so the second input contributes no separate value and the declaration\n * keeps only the first. Two questions, one answer.\n * 4. **Lead routing with nothing to extract an address from.**\n * `routing.lead` only ever fires when `extractEmailFromFields` finds one,\n * and that prefers a key matching `/email/i` before falling back to any\n * email-shaped value. A form with no such field can still route to leads\n * on paper and never create one.\n * 5. **Lead routing with nothing that records consent.** The route files\n * the lead either way, and stamps an opt-in on it only from the field the\n * form declares or from an undeclared field on the closed name list it\n * falls back to. A form with neither files leads nobody may email — the\n * send-time consent join refuses every one — so the sales team works a\n * list the campaigns cannot reach, and nothing at submit time says so.\n * 6. **A consent field that no longer exists.** `readFormDeclaredConsent`\n * looks up exactly `consentFieldName`. Rename or delete that field and it\n * returns `false` forever — the form keeps collecting the tick and the\n * opt-in stops being recorded. This is the one with consequences outside\n * the product, since an advertising basis that quietly stops is\n * indistinguishable downstream from one that was never given.\n *\n * ## What is NOT checked\n *\n * Anything the author can see. A missing label, an empty select, a field in\n * the wrong order — those are visible on the canvas and are the author's to\n * judge. This module is only for couplings that are invisible at design time\n * and asymptomatic at run time.\n *\n * Pure and dependency-free, like `forms.ts` beside it: it reaches client\n * bundles through `app-utils/index`, so nothing here may import a Node\n * builtin.\n */\n\nimport type {\n AglynNodeSchema,\n NodeId,\n} from '../foundation/definitions/components.types'\nimport {\n collectFormFieldNodeIds,\n FORM_COMPONENT_ID,\n type FormDocument,\n isMarketingConsentFieldName,\n} from './forms'\n\n/**\n * Which coupling a violation is about.\n *\n * A code rather than a message so a caller can decide presentation — the\n * besigner shows these beside the offending node, the API route puts them in\n * a 422 body — without either one parsing prose.\n */\nexport type FormContractViolationCode =\n | 'forms-off-for-site'\n | 'form-node-missing'\n | 'form-id-unbound'\n | 'field-unnamed'\n | 'field-name-duplicated'\n | 'lead-routing-has-no-email-field'\n | 'lead-routing-has-no-consent-field'\n | 'consent-field-missing'\n\n/** One way this design would fail to keep the server's side of the bargain. */\nexport interface FormContractViolation {\n code: FormContractViolationCode\n /** The node the author has to fix, when a single node is at fault. */\n nodeId?: NodeId\n /** The name at issue, for the two checks that are about names. */\n fieldName?: string\n /** Author-facing, and stated as the CONSEQUENCE rather than the rule. */\n message: string\n}\n\n/** A field as the design declares it, before any de-duplication. */\ninterface DrawnField {\n nodeId: NodeId\n fieldName: string\n fieldType: string\n}\n\n/**\n * `extractEmailFromFields` prefers a key matching this before falling back to\n * any email-shaped value. Restated rather than imported so this module stays\n * free of `contacts.ts`; {@link EMAIL_KEY_PATTERN} and that function agreeing\n * is asserted in the spec rather than assumed.\n */\nconst EMAIL_KEY_PATTERN = /email/i\n\n/**\n * Reads the fields a design draws, keeping every one of them.\n *\n * Deliberately NOT `formFieldDeclsFromNodes`. That function drops an unnamed\n * field and the second of a duplicate pair, which is right for adoption — it\n * describes what a page already submits — and useless here, because the\n * dropped ones are precisely what this module has to report. Asking it would\n * be asking a question whose answer has the evidence removed from it.\n */\nfunction drawnFields(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId: string,\n): DrawnField[] {\n return collectFormFieldNodeIds(nodes, formNodeId, formFieldComponentId).map(\n (nodeId) => {\n const props = (nodes?.[nodeId]?.props ?? {}) as Record<string, unknown>\n return {\n nodeId,\n fieldName: String(props['fieldName'] ?? '').trim(),\n fieldType: String(props['fieldType'] ?? 'text'),\n }\n },\n )\n}\n\n/**\n * Whether a submission carrying these fields could yield an address to key\n * a lead on — the one precondition of lead routing (AGL-2612).\n *\n * Exported because the check is asked in two places that never see a node\n * tree: the form's own page decides whether to offer the routing switch, and\n * the CRM's Leads section offers to turn routing on for a form from the\n * declared field list alone. Both must agree with the publish check here or\n * a switch flipped on one surface is refused on the next.\n */\nexport function formFieldsCanYieldAnEmail(\n fields: ReadonlyArray<{ fieldName: string; fieldType: string }>,\n): boolean {\n return fields.some(\n (field) =>\n field.fieldType === 'email' || EMAIL_KEY_PATTERN.test(field.fieldName),\n )\n}\n\n/** The same question, of the fields a design DRAWS. */\nfunction canYieldAnEmail(fields: DrawnField[]): boolean {\n return formFieldsCanYieldAnEmail(fields)\n}\n\n/**\n * Whether a submission carrying these fields could record an opt-in — the\n * other precondition of lead routing, and the one that decides whether a\n * lead is worth filing: a lead nobody consented with is one the campaigns\n * refuse at send time.\n *\n * Consent is recorded from the field the form DECLARES, or, when it declares\n * none, from an undeclared field the route recognizes by name. Whether a\n * declared field still exists is `consent-field-missing`'s question, asked\n * separately so a lost field is reported once, as the loss it is.\n *\n * Exported for the same two surfaces as {@link formFieldsCanYieldAnEmail}:\n * the switch on the form's page and the Leads section's offer must refuse\n * what the publish check would refuse.\n */\nexport function formFieldsCaptureConsent(\n fields: ReadonlyArray<{ fieldName: string }>,\n consentFieldName: string | null | undefined,\n): boolean {\n if (String(consentFieldName ?? '').trim()) return true\n return fields.some((field) => isMarketingConsentFieldName(field.fieldName))\n}\n\n/**\n * A site that switched Forms off (AGL-3029) publishes no form — neither a form\n * design nor a page, layout or component that carries one.\n *\n * Not one of the six silent failures above, and checked ahead of all of them:\n * on such a site the published page does not draw a form and `/api/forms/submit`\n * refuses every submission, so publishing one would put a hole on a live page\n * while the author believed they had shipped a form. Refusing it is what makes\n * the empty space on a page published BEFORE the switch the only kind there is.\n */\nexport const FORMS_OFF_FOR_SITE_VIOLATION: FormContractViolation = {\n code: 'forms-off-for-site',\n message:\n 'Forms is switched off for this site, so a form published here would not ' +\n 'show on its pages or accept submissions. Switch Forms back on for this ' +\n 'site in Admin › Plugins to publish it.',\n}\n\n/**\n * Whether a node map carries a form: a `form` node anywhere in it, bound to\n * an entity or drawn inline. Read by `componentId`, which every form node has\n * carried from the start, rather than by the `pluginId` a node was stamped\n * with when it was placed.\n */\nexport function nodesCarryForm(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): boolean {\n return Object.values(nodes ?? {}).some(\n (node) => node?.componentId === FORM_COMPONENT_ID,\n )\n}\n\n/**\n * The same refusal, for a page, layout or component that CARRIES a form: the\n * sentence names the two ways forward, because removing the form publishes\n * the rest of the page.\n */\nexport const FORMS_OFF_FOR_SITE_PAGE_VIOLATION: FormContractViolation = {\n code: 'forms-off-for-site',\n message:\n 'Forms is switched off for this site, so the form on this page would not ' +\n 'show or accept submissions. Remove the form, or switch Forms back on for ' +\n 'this site in Admin › Plugins, to publish it.',\n}\n\n/**\n * The refusal for publishing a page, layout or component that carries a form\n * on a site that switched Forms off, or `null` (AGL-3029). `formsOnForSite`\n * absent means the caller could not tell, which refuses nothing.\n */\nexport function formsOffPublishViolation(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formsOnForSite: boolean | undefined,\n): FormContractViolation | null {\n return formsOnForSite === false && nodesCarryForm(nodes)\n ? FORMS_OFF_FOR_SITE_PAGE_VIOLATION\n : null\n}\n\n/**\n * Everything this design would break, or an empty array.\n *\n * @param options.form the stored document, for what it ROUTES and what it\n * names as its consent field. `routing` and\n * `consentFieldName` are the only two parts read: the\n * stored `fields` are the previous design's output,\n * not a contract the new one owes anything to.\n * @param options.formId the id this design is the design OF.\n * @param options.nodes the flat node map, canvas or published.\n * @param options.formNodeId the `form` node inside it.\n * @param options.formsOnForSite whether Forms runs on the site the form is\n * published to; `false` refuses the design outright.\n * Absent, the caller could not tell, and nothing is\n * refused for it.\n */\nexport function checkFormContract(options: {\n form: Pick<FormDocument, 'routing' | 'consentFieldName'> | null | undefined\n formId: string\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null\n formNodeId: NodeId | undefined | null\n /** Overridable for a host whose plugin registers these under other ids. */\n componentIds?: { form?: string; formField?: string }\n formsOnForSite?: boolean\n}): FormContractViolation[] {\n const { form, formId, nodes, formNodeId } = options\n // Nothing about the design matters on a site that will not draw it.\n if (options.formsOnForSite === false) return [FORMS_OFF_FOR_SITE_VIOLATION]\n const formComponentId = options.componentIds?.form ?? 'form'\n const formFieldComponentId = options.componentIds?.formField ?? 'formField'\n const violations: FormContractViolation[] = []\n\n const formNode = formNodeId ? nodes?.[formNodeId] : undefined\n if (!formNode || formNode.componentId !== formComponentId) {\n // Nothing else is answerable without it, and reporting a cascade of\n // consequences would bury the one thing the author has to do.\n return [\n {\n code: 'form-node-missing',\n message:\n 'This design no longer contains a form. Submissions have nowhere ' +\n 'to come from, so nothing would reach the Inbox.',\n },\n ]\n }\n\n const boundId = String(\n (formNode.props as Record<string, unknown> | undefined)?.['formId'] ?? '',\n ).trim()\n if (boundId !== formId) {\n violations.push({\n code: 'form-id-unbound',\n nodeId: formNodeId as NodeId,\n message: boundId\n ? `This form's design is bound to a different form (${boundId}). ` +\n 'Submissions would be filed under that one instead of this one.'\n : 'This form lost its binding. Submissions would still arrive in the ' +\n 'Inbox but would not appear in this form’s own list.',\n })\n }\n\n const fields = drawnFields(nodes, formNodeId as NodeId, formFieldComponentId)\n\n const seen = new Map<string, NodeId>()\n for (const field of fields) {\n if (!field.fieldName) {\n violations.push({\n code: 'field-unnamed',\n nodeId: field.nodeId,\n message:\n 'This field has no name, so whatever a visitor types into it ' +\n 'arrives under a shared key and cannot be told apart from any ' +\n 'other unnamed field.',\n })\n continue\n }\n const first = seen.get(field.fieldName)\n if (first !== undefined) {\n violations.push({\n code: 'field-name-duplicated',\n nodeId: field.nodeId,\n fieldName: field.fieldName,\n message:\n `Another field is already called “${field.fieldName}”. ` +\n 'Two fields sharing a name arrive as one answer, and this one’s ' +\n 'value would be lost.',\n })\n continue\n }\n seen.set(field.fieldName, field.nodeId)\n }\n\n const consentFieldName = String(form?.consentFieldName ?? '').trim()\n\n /*\n * The two gates on a lead, reported one at a time in the order the route\n * decides them: no address means no lead at all, which makes consent moot\n * until a field exists to key one on.\n */\n if (form?.routing?.lead === true) {\n if (!canYieldAnEmail(fields)) {\n violations.push({\n code: 'lead-routing-has-no-email-field',\n nodeId: formNodeId as NodeId,\n message:\n 'This form creates a lead from the address someone gives it, and the ' +\n 'design has no email field. It would collect submissions and never ' +\n 'create a lead.',\n })\n } else if (!formFieldsCaptureConsent(fields, consentFieldName)) {\n violations.push({\n code: 'lead-routing-has-no-consent-field',\n nodeId: formNodeId as NodeId,\n message:\n 'This form creates a lead from the address someone gives it, and ' +\n 'nothing on it records their consent. Every lead it filed would be ' +\n 'one the team cannot email — name a marketing consent field on the ' +\n 'form’s page.',\n })\n }\n }\n\n if (consentFieldName && !seen.has(consentFieldName)) {\n violations.push({\n code: 'consent-field-missing',\n fieldName: consentFieldName,\n message:\n `This form records marketing consent from a field called ` +\n `“${consentFieldName}”, which the design no longer has. ` +\n 'Opt-ins would stop being recorded without any other sign.',\n })\n }\n\n return violations\n}\n\n/**\n * Whether this design may be published.\n *\n * Every violation is blocking. There is no advisory tier because there is no\n * violation here an author would be right to ship: each one names data that\n * silently fails to arrive, and a warning that can be clicked past is how a\n * silent failure ships anyway.\n */\nexport function formContractIsSatisfied(\n violations: FormContractViolation[],\n): boolean {\n return violations.length === 0\n}\n"],"names":["collectFormFieldNodeIds","FORM_COMPONENT_ID","isMarketingConsentFieldName","EMAIL_KEY_PATTERN","drawnFields","nodes","formNodeId","formFieldComponentId","map","nodeId","props","fieldName","String","trim","fieldType","formFieldsCanYieldAnEmail","fields","some","field","test","canYieldAnEmail","formFieldsCaptureConsent","consentFieldName","FORMS_OFF_FOR_SITE_VIOLATION","code","message","nodesCarryForm","Object","values","node","componentId","FORMS_OFF_FOR_SITE_PAGE_VIOLATION","formsOffPublishViolation","formsOnForSite","checkFormContract","options","formNode","form","formId","formComponentId","componentIds","formField","violations","undefined","boundId","push","seen","Map","first","get","set","routing","lead","has","formContractIsSatisfied","length"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/form-contract.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What a form's DESIGN must still satisfy for its submissions to arrive.\n *\n * A reusable component is only ever drawn: the worst an author can do to one\n * is make it ugly, and the page still renders. A form is drawn AND it is a\n * contract — `/api/forms/submit` keys submissions on its id, reads consent out\n * of a field it names, and creates a lead from an address it expects to find.\n * Every one of those couplings is resolved by NAME at submit time, so the\n * design can break them, and breaking them is silent in every case: the form\n * still renders, the visitor still submits, the row still lands, and the thing\n * the merchant believed they were collecting is simply not there.\n *\n * That asymmetry is the whole reason this module exists. Publishing a form is\n * not \"save the drawing\"; it is \"promise the server can still read this\".\n *\n * ## The six silent failures, and why each one is checked here\n *\n * Every check below corresponds to something a design can do that produces NO\n * error at submit time. A design that fails loudly needs no guard — the author\n * finds out. These are the ones where nobody finds out.\n *\n * 1. **The form node stops naming this form.** Submissions carry `formId` and\n * the route stamps it only after verifying it against this host's forms;\n * the Inbox's per-form list is an equality on it. Clear or rebind it and\n * every later submission is unattributable — while still landing in the\n * Inbox, so nothing looks broken and the form's own list just stops\n * growing.\n * 2. **A field with no name.** `formFieldDeclsFromNodes` drops it, and the\n * runtime falls back to `name = fieldName || 'field'`, so several unnamed\n * inputs collapse onto ONE submission key. The visitor types, the value is\n * posted, and it is not retrievable under any name the author knows.\n * 3. **Two fields sharing a name.** `FormData` joins entries under one key,\n * so the second input contributes no separate value and the declaration\n * keeps only the first. Two questions, one answer.\n * 4. **Lead routing with nothing to extract an address from.**\n * `routing.lead` only ever fires when `extractEmailFromFields` finds one,\n * and that prefers a key matching `/email/i` before falling back to any\n * email-shaped value. A form with no such field can still route to leads\n * on paper and never create one.\n * 5. **Lead routing with nothing that records consent.** The route files\n * the lead either way, and stamps an opt-in on it only from the field the\n * form declares or from an undeclared field on the closed name list it\n * falls back to. A form with neither files leads nobody may email — the\n * send-time consent join refuses every one — so the sales team works a\n * list the campaigns cannot reach, and nothing at submit time says so.\n * 6. **A consent field that no longer exists.** `readFormDeclaredConsent`\n * looks up exactly `consentFieldName`. Rename or delete that field and it\n * returns `false` forever — the form keeps collecting the tick and the\n * opt-in stops being recorded. This is the one with consequences outside\n * the product, since an advertising basis that quietly stops is\n * indistinguishable downstream from one that was never given.\n *\n * ## What is NOT checked\n *\n * Anything the author can see. A missing label, an empty select, a field in\n * the wrong order — those are visible on the canvas and are the author's to\n * judge. This module is only for couplings that are invisible at design time\n * and asymptomatic at run time.\n *\n * Pure and dependency-free, like `forms.ts` beside it: it reaches client\n * bundles through `app-utils/index`, so nothing here may import a Node\n * builtin.\n */\n\nimport type {\n AglynNodeSchema,\n NodeId,\n} from '../foundation/definitions/components.types'\nimport {\n collectFormFieldNodeIds,\n FORM_INPUT_ELEMENT_IDS,\n formInputName,\n formInputType,\n FORM_COMPONENT_ID,\n type FormDocument,\n isMarketingConsentFieldName,\n} from './forms'\n\n/**\n * Which coupling a violation is about.\n *\n * A code rather than a message so a caller can decide presentation — the\n * besigner shows these beside the offending node, the API route puts them in\n * a 422 body — without either one parsing prose.\n */\nexport type FormContractViolationCode =\n | 'forms-off-for-site'\n | 'form-node-missing'\n | 'form-id-unbound'\n | 'field-unnamed'\n | 'field-name-duplicated'\n | 'lead-routing-has-no-email-field'\n | 'lead-routing-has-no-consent-field'\n | 'consent-field-missing'\n\n/** One way this design would fail to keep the server's side of the bargain. */\nexport interface FormContractViolation {\n code: FormContractViolationCode\n /** The node the author has to fix, when a single node is at fault. */\n nodeId?: NodeId\n /** The name at issue, for the two checks that are about names. */\n fieldName?: string\n /** Author-facing, and stated as the CONSEQUENCE rather than the rule. */\n message: string\n}\n\n/** A field as the design declares it, before any de-duplication. */\ninterface DrawnField {\n nodeId: NodeId\n fieldName: string\n fieldType: string\n /** One of MUI's separate Checkbox elements, which may share a name. */\n isCheckboxElement: boolean\n}\n\n/**\n * `extractEmailFromFields` prefers a key matching this before falling back to\n * any email-shaped value. Restated rather than imported so this module stays\n * free of `contacts.ts`; {@link EMAIL_KEY_PATTERN} and that function agreeing\n * is asserted in the spec rather than assumed.\n */\nconst EMAIL_KEY_PATTERN = /email/i\n\n/**\n * Reads the fields a design draws, keeping every one of them.\n *\n * Deliberately NOT `formFieldDeclsFromNodes`. That function drops an unnamed\n * field and the second of a duplicate pair, which is right for adoption — it\n * describes what a page already submits — and useless here, because the\n * dropped ones are precisely what this module has to report. Asking it would\n * be asking a question whose answer has the evidence removed from it.\n */\nfunction drawnFields(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formNodeId: NodeId,\n formFieldComponentId: string,\n): DrawnField[] {\n return collectFormFieldNodeIds(nodes, formNodeId, formFieldComponentId).map(\n (nodeId) => {\n const node = nodes?.[nodeId]\n return {\n nodeId,\n fieldName: formInputName(node),\n fieldType: formInputType(node),\n isCheckboxElement:\n node?.componentId === FORM_INPUT_ELEMENT_IDS.checkbox,\n }\n },\n )\n}\n\n/**\n * Whether a submission carrying these fields could yield an address to key\n * a lead on — the one precondition of lead routing (AGL-2612).\n *\n * Exported because the check is asked in two places that never see a node\n * tree: the form's own page decides whether to offer the routing switch, and\n * the CRM's Leads section offers to turn routing on for a form from the\n * declared field list alone. Both must agree with the publish check here or\n * a switch flipped on one surface is refused on the next.\n */\nexport function formFieldsCanYieldAnEmail(\n fields: ReadonlyArray<{ fieldName: string; fieldType: string }>,\n): boolean {\n return fields.some(\n (field) =>\n field.fieldType === 'email' || EMAIL_KEY_PATTERN.test(field.fieldName),\n )\n}\n\n/** The same question, of the fields a design DRAWS. */\nfunction canYieldAnEmail(fields: DrawnField[]): boolean {\n return formFieldsCanYieldAnEmail(fields)\n}\n\n/**\n * Whether a submission carrying these fields could record an opt-in — the\n * other precondition of lead routing, and the one that decides whether a\n * lead is worth filing: a lead nobody consented with is one the campaigns\n * refuse at send time.\n *\n * Consent is recorded from the field the form DECLARES, or, when it declares\n * none, from an undeclared field the route recognizes by name. Whether a\n * declared field still exists is `consent-field-missing`'s question, asked\n * separately so a lost field is reported once, as the loss it is.\n *\n * Exported for the same two surfaces as {@link formFieldsCanYieldAnEmail}:\n * the switch on the form's page and the Leads section's offer must refuse\n * what the publish check would refuse.\n */\nexport function formFieldsCaptureConsent(\n fields: ReadonlyArray<{ fieldName: string }>,\n consentFieldName: string | null | undefined,\n): boolean {\n if (String(consentFieldName ?? '').trim()) return true\n return fields.some((field) => isMarketingConsentFieldName(field.fieldName))\n}\n\n/**\n * A site that switched Forms off (AGL-3029) publishes no form — neither a form\n * design nor a page, layout or component that carries one.\n *\n * Not one of the six silent failures above, and checked ahead of all of them:\n * on such a site the published page does not draw a form and `/api/forms/submit`\n * refuses every submission, so publishing one would put a hole on a live page\n * while the author believed they had shipped a form. Refusing it is what makes\n * the empty space on a page published BEFORE the switch the only kind there is.\n */\nexport const FORMS_OFF_FOR_SITE_VIOLATION: FormContractViolation = {\n code: 'forms-off-for-site',\n message:\n 'Forms is switched off for this site, so a form published here would not ' +\n 'show on its pages or accept submissions. Switch Forms back on for this ' +\n 'site in Admin › Plugins to publish it.',\n}\n\n/**\n * Whether a node map carries a form: a `form` node anywhere in it, bound to\n * an entity or drawn inline. Read by `componentId`, which every form node has\n * carried from the start, rather than by the `pluginId` a node was stamped\n * with when it was placed.\n */\nexport function nodesCarryForm(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n): boolean {\n return Object.values(nodes ?? {}).some(\n (node) => node?.componentId === FORM_COMPONENT_ID,\n )\n}\n\n/**\n * The same refusal, for a page, layout or component that CARRIES a form: the\n * sentence names the two ways forward, because removing the form publishes\n * the rest of the page.\n */\nexport const FORMS_OFF_FOR_SITE_PAGE_VIOLATION: FormContractViolation = {\n code: 'forms-off-for-site',\n message:\n 'Forms is switched off for this site, so the form on this page would not ' +\n 'show or accept submissions. Remove the form, or switch Forms back on for ' +\n 'this site in Admin › Plugins, to publish it.',\n}\n\n/**\n * The refusal for publishing a page, layout or component that carries a form\n * on a site that switched Forms off, or `null` (AGL-3029). `formsOnForSite`\n * absent means the caller could not tell, which refuses nothing.\n */\nexport function formsOffPublishViolation(\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null,\n formsOnForSite: boolean | undefined,\n): FormContractViolation | null {\n return formsOnForSite === false && nodesCarryForm(nodes)\n ? FORMS_OFF_FOR_SITE_PAGE_VIOLATION\n : null\n}\n\n/**\n * Everything this design would break, or an empty array.\n *\n * @param options.form the stored document, for what it ROUTES and what it\n * names as its consent field. `routing` and\n * `consentFieldName` are the only two parts read: the\n * stored `fields` are the previous design's output,\n * not a contract the new one owes anything to.\n * @param options.formId the id this design is the design OF.\n * @param options.nodes the flat node map, canvas or published.\n * @param options.formNodeId the `form` node inside it.\n * @param options.formsOnForSite whether Forms runs on the site the form is\n * published to; `false` refuses the design outright.\n * Absent, the caller could not tell, and nothing is\n * refused for it.\n */\nexport function checkFormContract(options: {\n form: Pick<FormDocument, 'routing' | 'consentFieldName'> | null | undefined\n formId: string\n nodes: Record<NodeId, AglynNodeSchema | undefined> | undefined | null\n formNodeId: NodeId | undefined | null\n /** Overridable for a host whose plugin registers these under other ids. */\n componentIds?: { form?: string; formField?: string }\n formsOnForSite?: boolean\n}): FormContractViolation[] {\n const { form, formId, nodes, formNodeId } = options\n // Nothing about the design matters on a site that will not draw it.\n if (options.formsOnForSite === false) return [FORMS_OFF_FOR_SITE_VIOLATION]\n const formComponentId = options.componentIds?.form ?? 'form'\n const formFieldComponentId = options.componentIds?.formField ?? 'formField'\n const violations: FormContractViolation[] = []\n\n const formNode = formNodeId ? nodes?.[formNodeId] : undefined\n if (!formNode || formNode.componentId !== formComponentId) {\n // Nothing else is answerable without it, and reporting a cascade of\n // consequences would bury the one thing the author has to do.\n return [\n {\n code: 'form-node-missing',\n message:\n 'This design no longer contains a form. Submissions have nowhere ' +\n 'to come from, so nothing would reach the Inbox.',\n },\n ]\n }\n\n const boundId = String(\n (formNode.props as Record<string, unknown> | undefined)?.['formId'] ?? '',\n ).trim()\n if (boundId !== formId) {\n violations.push({\n code: 'form-id-unbound',\n nodeId: formNodeId as NodeId,\n message: boundId\n ? `This form's design is bound to a different form (${boundId}). ` +\n 'Submissions would be filed under that one instead of this one.'\n : 'This form lost its binding. Submissions would still arrive in the ' +\n 'Inbox but would not appear in this form’s own list.',\n })\n }\n\n const fields = drawnFields(nodes, formNodeId as NodeId, formFieldComponentId)\n\n const seen = new Map<string, NodeId>()\n const checkboxNames = new Set<string>()\n for (const field of fields) {\n if (!field.fieldName) {\n violations.push({\n code: 'field-unnamed',\n nodeId: field.nodeId,\n message:\n 'This field has no name, so whatever a visitor types into it ' +\n 'arrives under a shared key and cannot be told apart from any ' +\n 'other unnamed field.',\n })\n continue\n }\n const first = seen.get(field.fieldName)\n // Checkboxes sharing a name are ONE question with several boxes — how a\n // multi-choice question is built from MUI's separate Checkbox elements.\n // Their ticks are joined into one answer, so nothing is lost.\n if (\n first !== undefined &&\n field.isCheckboxElement &&\n checkboxNames.has(field.fieldName)\n ) {\n continue\n }\n if (first !== undefined) {\n violations.push({\n code: 'field-name-duplicated',\n nodeId: field.nodeId,\n fieldName: field.fieldName,\n message:\n `Another field is already called “${field.fieldName}”. ` +\n 'Two fields sharing a name arrive as one answer, and this one’s ' +\n 'value would be lost.',\n })\n continue\n }\n seen.set(field.fieldName, field.nodeId)\n if (field.isCheckboxElement) checkboxNames.add(field.fieldName)\n }\n\n const consentFieldName = String(form?.consentFieldName ?? '').trim()\n\n /*\n * The two gates on a lead, reported one at a time in the order the route\n * decides them: no address means no lead at all, which makes consent moot\n * until a field exists to key one on.\n */\n if (form?.routing?.lead === true) {\n if (!canYieldAnEmail(fields)) {\n violations.push({\n code: 'lead-routing-has-no-email-field',\n nodeId: formNodeId as NodeId,\n message:\n 'This form creates a lead from the address someone gives it, and the ' +\n 'design has no email field. It would collect submissions and never ' +\n 'create a lead.',\n })\n } else if (!formFieldsCaptureConsent(fields, consentFieldName)) {\n violations.push({\n code: 'lead-routing-has-no-consent-field',\n nodeId: formNodeId as NodeId,\n message:\n 'This form creates a lead from the address someone gives it, and ' +\n 'nothing on it records their consent. Every lead it filed would be ' +\n 'one the team cannot email — name a marketing consent field on the ' +\n 'form’s page.',\n })\n }\n }\n\n if (consentFieldName && !seen.has(consentFieldName)) {\n violations.push({\n code: 'consent-field-missing',\n fieldName: consentFieldName,\n message:\n `This form records marketing consent from a field called ` +\n `“${consentFieldName}”, which the design no longer has. ` +\n 'Opt-ins would stop being recorded without any other sign.',\n })\n }\n\n return violations\n}\n\n/**\n * Whether this design may be published.\n *\n * Every violation is blocking. There is no advisory tier because there is no\n * violation here an author would be right to ship: each one names data that\n * silently fails to arrive, and a warning that can be clicked past is how a\n * silent failure ships anyway.\n */\nexport function formContractIsSatisfied(\n violations: FormContractViolation[],\n): boolean {\n return violations.length === 0\n}\n"],"names":["collectFormFieldNodeIds","FORM_INPUT_ELEMENT_IDS","formInputName","formInputType","FORM_COMPONENT_ID","isMarketingConsentFieldName","EMAIL_KEY_PATTERN","drawnFields","nodes","formNodeId","formFieldComponentId","map","nodeId","node","fieldName","fieldType","isCheckboxElement","componentId","checkbox","formFieldsCanYieldAnEmail","fields","some","field","test","canYieldAnEmail","formFieldsCaptureConsent","consentFieldName","String","trim","FORMS_OFF_FOR_SITE_VIOLATION","code","message","nodesCarryForm","Object","values","FORMS_OFF_FOR_SITE_PAGE_VIOLATION","formsOffPublishViolation","formsOnForSite","checkFormContract","options","formNode","form","formId","formComponentId","componentIds","formField","violations","undefined","boundId","props","push","seen","Map","checkboxNames","Set","first","get","has","set","add","routing","lead","formContractIsSatisfied","length"],"mappings":"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"}
@@ -610,6 +610,37 @@ export declare const FORM_DISPLAY_NAME_MAX_LENGTH = 100;
610
610
  * must fall back to the document id rather than store an empty slug.
611
611
  */
612
612
  export declare function normalizeFormSlug(input: unknown): string;
613
+ /**
614
+ * MUI's form elements that carry an answer (`libs/plugins/mui`
615
+ * form-controls.tsx), restated by id because the foundation layer may not
616
+ * import a plugin. A form built from these pieces — a Form Control holding a
617
+ * Form Label and an Input, a Select of Select Options, a Radio Group of
618
+ * Radios, a Checkbox in a Choice Label, a Rating — declares its fields from
619
+ * them exactly as it does from the all-in-one `formField`.
620
+ *
621
+ * Persisted ids; never rename.
622
+ */
623
+ export declare const FORM_INPUT_ELEMENT_IDS: {
624
+ readonly input: "muiInput";
625
+ readonly select: "muiSelect";
626
+ readonly selectOption: "muiSelectOption";
627
+ readonly radioGroup: "muiRadioGroup";
628
+ readonly radio: "muiRadio";
629
+ readonly checkbox: "muiCheckbox";
630
+ readonly rating: "muiRating";
631
+ readonly formControl: "muiFormControl";
632
+ readonly formLabel: "muiFormLabel";
633
+ readonly choiceLabel: "muiFormControlLabel";
634
+ };
635
+ /** Whether a node carries an answer the form submits. */
636
+ export declare function isFormInputNode(node: AglynNodeSchema | undefined | null, formFieldComponentId?: string): boolean;
637
+ /**
638
+ * The key an input submits under: a `formField`'s `fieldName`, or the `name`
639
+ * of one of MUI's form elements — the HTML attribute it renders.
640
+ */
641
+ export declare function formInputName(node: AglynNodeSchema | undefined | null): string;
642
+ /** The declared type of an input node, in `FormFieldType` terms. */
643
+ export declare function formInputType(node: AglynNodeSchema | undefined | null): FormFieldType;
613
644
  /**
614
645
  * Every `formField` descendant of `formNodeId`, in the order the author
615
646
  * placed them.
@@ -547,6 +547,189 @@ const FORM_FIELD_TYPES = new Set([
547
547
  */ function parseDeclaredOptions(options) {
548
548
  return String(options != null ? options : '').split(/[\n,]/).map((entry)=>entry.trim()).filter(Boolean);
549
549
  }
550
+ /**
551
+ * MUI's form elements that carry an answer (`libs/plugins/mui`
552
+ * form-controls.tsx), restated by id because the foundation layer may not
553
+ * import a plugin. A form built from these pieces — a Form Control holding a
554
+ * Form Label and an Input, a Select of Select Options, a Radio Group of
555
+ * Radios, a Checkbox in a Choice Label, a Rating — declares its fields from
556
+ * them exactly as it does from the all-in-one `formField`.
557
+ *
558
+ * Persisted ids; never rename.
559
+ */ export const FORM_INPUT_ELEMENT_IDS = {
560
+ input: 'muiInput',
561
+ select: 'muiSelect',
562
+ selectOption: 'muiSelectOption',
563
+ radioGroup: 'muiRadioGroup',
564
+ radio: 'muiRadio',
565
+ checkbox: 'muiCheckbox',
566
+ rating: 'muiRating',
567
+ formControl: 'muiFormControl',
568
+ formLabel: 'muiFormLabel',
569
+ choiceLabel: 'muiFormControlLabel'
570
+ };
571
+ const ANSWER_ELEMENT_IDS = new Set([
572
+ FORM_INPUT_ELEMENT_IDS.input,
573
+ FORM_INPUT_ELEMENT_IDS.select,
574
+ FORM_INPUT_ELEMENT_IDS.radioGroup,
575
+ FORM_INPUT_ELEMENT_IDS.checkbox,
576
+ FORM_INPUT_ELEMENT_IDS.rating
577
+ ]);
578
+ /** Whether a node carries an answer the form submits. */ export function isFormInputNode(node, formFieldComponentId = FORM_FIELD_COMPONENT_ID) {
579
+ var _ref;
580
+ const componentId = String((_ref = node == null ? void 0 : node.componentId) != null ? _ref : '');
581
+ return componentId === formFieldComponentId || ANSWER_ELEMENT_IDS.has(componentId);
582
+ }
583
+ /**
584
+ * The key an input submits under: a `formField`'s `fieldName`, or the `name`
585
+ * of one of MUI's form elements — the HTML attribute it renders.
586
+ */ export function formInputName(node) {
587
+ var _ref, _ref1, _props_fieldName;
588
+ const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
589
+ return String((_ref1 = (_props_fieldName = props['fieldName']) != null ? _props_fieldName : props['name']) != null ? _ref1 : '').trim();
590
+ }
591
+ /** The declared type of an input node, in `FormFieldType` terms. */ export function formInputType(node) {
592
+ var _ref;
593
+ const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
594
+ switch(node == null ? void 0 : node.componentId){
595
+ case FORM_INPUT_ELEMENT_IDS.input:
596
+ if (props['multiline'] === true) return 'textarea';
597
+ return props['type'] === 'email' ? 'email' : 'text';
598
+ case FORM_INPUT_ELEMENT_IDS.select:
599
+ return 'select';
600
+ case FORM_INPUT_ELEMENT_IDS.radioGroup:
601
+ return 'radio';
602
+ case FORM_INPUT_ELEMENT_IDS.checkbox:
603
+ return 'checkbox';
604
+ case FORM_INPUT_ELEMENT_IDS.rating:
605
+ return 'rating';
606
+ default:
607
+ {
608
+ var _props_fieldType;
609
+ const raw = String((_props_fieldType = props['fieldType']) != null ? _props_fieldType : 'text');
610
+ return FORM_FIELD_TYPES.has(raw) ? raw : 'text';
611
+ }
612
+ }
613
+ }
614
+ /** The plain text a text-bearing node shows (`children` as a string). */ function nodeText(node) {
615
+ var _node_props;
616
+ const children = node == null ? void 0 : (_node_props = node.props) == null ? void 0 : _node_props['children'];
617
+ return typeof children === 'string' ? children.trim() : '';
618
+ }
619
+ /**
620
+ * The words that name one of MUI's input elements: the Form Label in the Form
621
+ * Control around it (the question, for a group of boxes), else the Choice
622
+ * Label around a lone checkbox, else its accessible label or placeholder.
623
+ */ function answerElementLabel(nodes, id) {
624
+ var _ref, _ref1, _props_ariaLabel;
625
+ const node = nodes[id];
626
+ const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
627
+ // A Choice Label names its own box; a Form Label in the Form Control
628
+ // around a GROUP of boxes names the question, and wins.
629
+ let choiceWords = '';
630
+ let parentId = node == null ? void 0 : node.parentId;
631
+ for(let depth = 0; parentId && depth < 8; depth += 1){
632
+ const parent = nodes[parentId];
633
+ if (!parent) break;
634
+ if (parent.componentId === FORM_INPUT_ELEMENT_IDS.choiceLabel && !choiceWords) {
635
+ var _ref2;
636
+ var _parent_props;
637
+ choiceWords = String((_ref2 = (_parent_props = parent.props) == null ? void 0 : _parent_props['label']) != null ? _ref2 : '').trim();
638
+ }
639
+ if (parent.componentId === FORM_INPUT_ELEMENT_IDS.formControl) {
640
+ for (const childId of childIdsOf(parent)){
641
+ const child = nodes[childId];
642
+ if ((child == null ? void 0 : child.componentId) === FORM_INPUT_ELEMENT_IDS.formLabel && nodeText(child)) {
643
+ return nodeText(child);
644
+ }
645
+ }
646
+ break;
647
+ }
648
+ parentId = parent.parentId;
649
+ }
650
+ if (choiceWords) return choiceWords;
651
+ return String((_ref1 = (_props_ariaLabel = props['ariaLabel']) != null ? _props_ariaLabel : props['placeholder']) != null ? _ref1 : '').trim();
652
+ }
653
+ /**
654
+ * Whether one of MUI's input elements must be answered: its own `required`,
655
+ * a Radio Group any of whose Radios is required (the browser enforces it per
656
+ * radio), or the Form Control around it marked required — the only place a
657
+ * group of Checkboxes can say "at least one".
658
+ */ function answerElementRequired(nodes, id) {
659
+ var _node_props;
660
+ const node = nodes[id];
661
+ if ((node == null ? void 0 : (_node_props = node.props) == null ? void 0 : _node_props['required']) === true) return true;
662
+ if ((node == null ? void 0 : node.componentId) === FORM_INPUT_ELEMENT_IDS.radioGroup) {
663
+ const stack = [
664
+ ...childIdsOf(node)
665
+ ];
666
+ const seen = new Set([
667
+ id
668
+ ]);
669
+ while(stack.length){
670
+ var _child_props;
671
+ const childId = stack.pop();
672
+ if (seen.has(childId)) continue;
673
+ seen.add(childId);
674
+ const child = nodes[childId];
675
+ if (!child) continue;
676
+ if (child.componentId === FORM_INPUT_ELEMENT_IDS.radio && ((_child_props = child.props) == null ? void 0 : _child_props['required']) === true) {
677
+ return true;
678
+ }
679
+ stack.push(...childIdsOf(child));
680
+ }
681
+ }
682
+ let parentId = node == null ? void 0 : node.parentId;
683
+ for(let depth = 0; parentId && depth < 8; depth += 1){
684
+ const parent = nodes[parentId];
685
+ if (!parent) break;
686
+ if (parent.componentId === FORM_INPUT_ELEMENT_IDS.formControl) {
687
+ var _parent_props;
688
+ return ((_parent_props = parent.props) == null ? void 0 : _parent_props['required']) === true;
689
+ }
690
+ parentId = parent.parentId;
691
+ }
692
+ return false;
693
+ }
694
+ /** The choices one of MUI's choice elements offers, in order. */ function answerElementOptions(nodes, id) {
695
+ var _ref;
696
+ const node = nodes[id];
697
+ const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
698
+ if ((node == null ? void 0 : node.componentId) === FORM_INPUT_ELEMENT_IDS.checkbox) {
699
+ var _props_value;
700
+ return [
701
+ String((_props_value = props['value']) != null ? _props_value : '').trim() || 'on'
702
+ ];
703
+ }
704
+ const wanted = (node == null ? void 0 : node.componentId) === FORM_INPUT_ELEMENT_IDS.select ? FORM_INPUT_ELEMENT_IDS.selectOption : (node == null ? void 0 : node.componentId) === FORM_INPUT_ELEMENT_IDS.radioGroup ? FORM_INPUT_ELEMENT_IDS.radio : null;
705
+ if (!wanted) return [];
706
+ const options = [];
707
+ const stack = [
708
+ ...childIdsOf(node)
709
+ ].reverse();
710
+ const seen = new Set([
711
+ id
712
+ ]);
713
+ while(stack.length){
714
+ const childId = stack.pop();
715
+ if (seen.has(childId)) continue;
716
+ seen.add(childId);
717
+ const child = nodes[childId];
718
+ if (!child) continue;
719
+ if (child.componentId === wanted) {
720
+ var _ref1;
721
+ var _child_props;
722
+ const value = String((_ref1 = (_child_props = child.props) == null ? void 0 : _child_props['value']) != null ? _ref1 : '').trim();
723
+ const option = value || nodeText(child);
724
+ if (option) options.push(option);
725
+ }
726
+ const children = childIdsOf(child);
727
+ for(let index = children.length - 1; index >= 0; index -= 1){
728
+ stack.push(children[index]);
729
+ }
730
+ }
731
+ return options;
732
+ }
550
733
  /** Child ids of a node in the stored map form, in the author's order. */ function childIdsOf(node) {
551
734
  return Array.isArray(node == null ? void 0 : node.nodes) ? node.nodes : [];
552
735
  }
@@ -573,11 +756,10 @@ const FORM_FIELD_TYPES = new Set([
573
756
  ...childIdsOf(nodes[formNodeId])
574
757
  ].reverse();
575
758
  while(stack.length){
576
- var _nodes_id;
577
759
  const id = stack.pop();
578
760
  if (seen.has(id) || !nodes[id]) continue;
579
761
  seen.add(id);
580
- if (((_nodes_id = nodes[id]) == null ? void 0 : _nodes_id.componentId) === formFieldComponentId) found.push(id);
762
+ if (isFormInputNode(nodes[id], formFieldComponentId)) found.push(id);
581
763
  // Pushed reversed so the first child is popped first — the walk is
582
764
  // pre-order, and a nested field must not overtake its own siblings.
583
765
  const children = childIdsOf(nodes[id]);
@@ -606,30 +788,45 @@ const FORM_FIELD_TYPES = new Set([
606
788
  * one key, so the second node contributes no separate value.
607
789
  */ export function formFieldDeclsFromNodes(nodes, formNodeId, formFieldComponentId = FORM_FIELD_COMPONENT_ID) {
608
790
  const declarations = [];
609
- const claimed = new Set();
791
+ const byName = new Map();
610
792
  for (const id of collectFormFieldNodeIds(nodes, formNodeId, formFieldComponentId)){
611
- var _ref, _props_fieldName, _props_fieldType, _props_label, _props_datasetFieldId;
612
- var _nodes_id;
613
- const props = (_ref = nodes == null ? void 0 : (_nodes_id = nodes[id]) == null ? void 0 : _nodes_id.props) != null ? _ref : {};
614
- const fieldName = String((_props_fieldName = props['fieldName']) != null ? _props_fieldName : '').trim();
615
- if (!fieldName || claimed.has(fieldName)) continue;
616
- claimed.add(fieldName);
617
- const rawType = String((_props_fieldType = props['fieldType']) != null ? _props_fieldType : 'text');
618
- const options = parseDeclaredOptions(props['options']);
619
- const label = String((_props_label = props['label']) != null ? _props_label : '').trim();
793
+ var _ref, _props_label, _props_datasetFieldId;
794
+ const node = nodes == null ? void 0 : nodes[id];
795
+ const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
796
+ const fieldName = formInputName(node);
797
+ if (!fieldName) continue;
798
+ const isFormField = (node == null ? void 0 : node.componentId) === formFieldComponentId;
799
+ const options = isFormField ? parseDeclaredOptions(props['options']) : answerElementOptions(nodes, id);
800
+ const claimed = byName.get(fieldName);
801
+ if (claimed) {
802
+ // Several Checkboxes sharing a name are one question with several
803
+ // boxes: each adds its value as an option. Anything else keeps the
804
+ // first, as the submission does.
805
+ if (claimed.fieldType === 'checkbox' && formInputType(node) === 'checkbox') {
806
+ var _claimed_options;
807
+ claimed.options = [
808
+ ...(_claimed_options = claimed.options) != null ? _claimed_options : [],
809
+ ...options
810
+ ];
811
+ }
812
+ continue;
813
+ }
814
+ const label = isFormField ? String((_props_label = props['label']) != null ? _props_label : '').trim() : answerElementLabel(nodes, id);
620
815
  const datasetFieldId = String((_props_datasetFieldId = props['datasetFieldId']) != null ? _props_datasetFieldId : '').trim();
621
- declarations.push(_extends({
816
+ const declaration = _extends({
622
817
  fieldName,
623
- fieldType: FORM_FIELD_TYPES.has(rawType) ? rawType : 'text'
818
+ fieldType: formInputType(node)
624
819
  }, label ? {
625
820
  label
626
- } : {}, props['required'] === true ? {
821
+ } : {}, (isFormField ? props['required'] === true : answerElementRequired(nodes, id)) ? {
627
822
  required: true
628
823
  } : {}, options.length ? {
629
824
  options
630
825
  } : {}, datasetFieldId ? {
631
826
  datasetFieldId
632
- } : {}));
827
+ } : {});
828
+ byName.set(fieldName, declaration);
829
+ declarations.push(declaration);
633
830
  }
634
831
  return declarations;
635
832
  }