@elementor/editor-canvas 4.3.0-984 → 4.3.0-986

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -495,8 +495,8 @@ function toGridTracks(computedStyle) {
495
495
  return {
496
496
  columns: parseTrackList(computedStyle.gridTemplateColumns),
497
497
  rows: parseTrackList(computedStyle.gridTemplateRows),
498
- columnGap: toPx(computedStyle.columnGap),
499
- rowGap: toPx(computedStyle.rowGap),
498
+ columnGap: resolveGapPx(computedStyle.columnGap, computedStyle.width),
499
+ rowGap: resolveGapPx(computedStyle.rowGap, computedStyle.height),
500
500
  padding: {
501
501
  top: toPx(computedStyle.paddingTop),
502
502
  right: toPx(computedStyle.paddingRight),
@@ -571,6 +571,14 @@ function toPx(value) {
571
571
  const parsed = parseFloat(value);
572
572
  return Number.isFinite(parsed) ? parsed : 0;
573
573
  }
574
+ function resolveGapPx(value, referenceSize) {
575
+ if (value.trim().endsWith("%")) {
576
+ const percent = parseFloat(value);
577
+ const reference = parseFloat(referenceSize);
578
+ return Number.isFinite(percent) && Number.isFinite(reference) ? percent / 100 * reference : 0;
579
+ }
580
+ return toPx(value);
581
+ }
574
582
 
575
583
  // src/hooks/use-grid-children.ts
576
584
  var import_react3 = require("react");
@@ -2021,6 +2029,46 @@ function clipboardRootsAreAtomicForms(elements) {
2021
2029
  }
2022
2030
  return elements.every((el) => getClipboardElementType(el) === FORM_ELEMENT_TYPE);
2023
2031
  }
2032
+ function hasFormAncestor(node) {
2033
+ return node.closest(FORM_ELEMENT_TYPE) !== null;
2034
+ }
2035
+ function collectFormAncestorErrors(xml) {
2036
+ const errors = [];
2037
+ for (const node of xml.querySelectorAll("*")) {
2038
+ if (!FORM_FIELD_ELEMENT_TYPES.has(node.tagName.toLowerCase())) {
2039
+ continue;
2040
+ }
2041
+ if (hasFormAncestor(node)) {
2042
+ continue;
2043
+ }
2044
+ const id = node.getAttribute("configuration-id");
2045
+ errors.push(
2046
+ `<${node.tagName}${id ? ` configuration-id="${id}"` : ""}> must be nested inside <e-form> (any ancestor depth is allowed).`
2047
+ );
2048
+ }
2049
+ return errors;
2050
+ }
2051
+ function collectSubmitButtonErrors(xml) {
2052
+ const errors = [];
2053
+ for (const form of xml.querySelectorAll("e-form")) {
2054
+ const submitButtons = form.querySelectorAll("e-form-submit-button");
2055
+ if (submitButtons.length === 0) {
2056
+ errors.push(`<e-form> has no <e-form-submit-button>.`);
2057
+ } else if (submitButtons.length > 1) {
2058
+ errors.push(`<e-form> has ${submitButtons.length} submit buttons \u2014 only 1 is allowed.`);
2059
+ }
2060
+ }
2061
+ return errors;
2062
+ }
2063
+ function collectEmptyMessageErrors(xml) {
2064
+ const errors = [];
2065
+ for (const node of xml.querySelectorAll("e-form-success-message, e-form-error-message")) {
2066
+ if (node.children.length === 0) {
2067
+ errors.push(`<${node.tagName}> must have at least one child element (e.g. <e-atomic-paragraph>).`);
2068
+ }
2069
+ }
2070
+ return errors;
2071
+ }
2024
2072
 
2025
2073
  // src/form-structure/enforce-form-ancestor-commands.ts
2026
2074
  var FORM_FIELDS_OUTSIDE_ALERT = {
@@ -5195,6 +5243,11 @@ var CompositionBuilder = class _CompositionBuilder {
5195
5243
  throw new Error(`Invalid element structure:
5196
5244
  ${childTypeErrors.join("\n")}`);
5197
5245
  }
5246
+ const formErrors = [
5247
+ ...collectFormAncestorErrors(this.xml),
5248
+ ...collectSubmitButtonErrors(this.xml),
5249
+ ...collectEmptyMessageErrors(this.xml)
5250
+ ];
5198
5251
  const children = Array.from(this.xml.children);
5199
5252
  for (const childNode of children) {
5200
5253
  const modelTree = this.buildModelTree(childNode, widgetsCache);
@@ -5229,6 +5282,7 @@ ${childTypeErrors.join("\n")}`);
5229
5282
  return {
5230
5283
  configErrors,
5231
5284
  styleErrors,
5285
+ formErrors,
5232
5286
  rootContainers: [...this.rootContainers]
5233
5287
  };
5234
5288
  }
@@ -5539,7 +5593,11 @@ var initBuildCompositionsTool = (reg) => {
5539
5593
  compositionBuilder.setElementConfig(elementConfig);
5540
5594
  compositionBuilder.setStylesConfig(stylesConfig);
5541
5595
  compositionBuilder.setCustomCSS(customCSS);
5542
- const { configErrors, rootContainers: generatedRootContainers } = await compositionBuilder.build(targetContainer);
5596
+ const {
5597
+ configErrors,
5598
+ formErrors,
5599
+ rootContainers: generatedRootContainers
5600
+ } = await compositionBuilder.build(targetContainer);
5543
5601
  rootContainers.push(...generatedRootContainers);
5544
5602
  generatedXML = new XMLSerializer().serializeToString(compositionBuilder.getXML());
5545
5603
  rootContainers.forEach((container) => {
@@ -5554,6 +5612,9 @@ var initBuildCompositionsTool = (reg) => {
5554
5612
  if (configErrors.length) {
5555
5613
  errors.push(...configErrors.map((msg) => new Error(msg)));
5556
5614
  }
5615
+ if (formErrors.length) {
5616
+ errors.push(...formErrors.map((msg) => new Error(msg)));
5617
+ }
5557
5618
  } catch (error) {
5558
5619
  errors.push(error);
5559
5620
  }
package/dist/index.mjs CHANGED
@@ -433,8 +433,8 @@ function toGridTracks(computedStyle) {
433
433
  return {
434
434
  columns: parseTrackList(computedStyle.gridTemplateColumns),
435
435
  rows: parseTrackList(computedStyle.gridTemplateRows),
436
- columnGap: toPx(computedStyle.columnGap),
437
- rowGap: toPx(computedStyle.rowGap),
436
+ columnGap: resolveGapPx(computedStyle.columnGap, computedStyle.width),
437
+ rowGap: resolveGapPx(computedStyle.rowGap, computedStyle.height),
438
438
  padding: {
439
439
  top: toPx(computedStyle.paddingTop),
440
440
  right: toPx(computedStyle.paddingRight),
@@ -509,6 +509,14 @@ function toPx(value) {
509
509
  const parsed = parseFloat(value);
510
510
  return Number.isFinite(parsed) ? parsed : 0;
511
511
  }
512
+ function resolveGapPx(value, referenceSize) {
513
+ if (value.trim().endsWith("%")) {
514
+ const percent = parseFloat(value);
515
+ const reference = parseFloat(referenceSize);
516
+ return Number.isFinite(percent) && Number.isFinite(reference) ? percent / 100 * reference : 0;
517
+ }
518
+ return toPx(value);
519
+ }
512
520
 
513
521
  // src/hooks/use-grid-children.ts
514
522
  import { useEffect as useEffect3, useState as useState2 } from "react";
@@ -1975,6 +1983,46 @@ function clipboardRootsAreAtomicForms(elements) {
1975
1983
  }
1976
1984
  return elements.every((el) => getClipboardElementType(el) === FORM_ELEMENT_TYPE);
1977
1985
  }
1986
+ function hasFormAncestor(node) {
1987
+ return node.closest(FORM_ELEMENT_TYPE) !== null;
1988
+ }
1989
+ function collectFormAncestorErrors(xml) {
1990
+ const errors = [];
1991
+ for (const node of xml.querySelectorAll("*")) {
1992
+ if (!FORM_FIELD_ELEMENT_TYPES.has(node.tagName.toLowerCase())) {
1993
+ continue;
1994
+ }
1995
+ if (hasFormAncestor(node)) {
1996
+ continue;
1997
+ }
1998
+ const id = node.getAttribute("configuration-id");
1999
+ errors.push(
2000
+ `<${node.tagName}${id ? ` configuration-id="${id}"` : ""}> must be nested inside <e-form> (any ancestor depth is allowed).`
2001
+ );
2002
+ }
2003
+ return errors;
2004
+ }
2005
+ function collectSubmitButtonErrors(xml) {
2006
+ const errors = [];
2007
+ for (const form of xml.querySelectorAll("e-form")) {
2008
+ const submitButtons = form.querySelectorAll("e-form-submit-button");
2009
+ if (submitButtons.length === 0) {
2010
+ errors.push(`<e-form> has no <e-form-submit-button>.`);
2011
+ } else if (submitButtons.length > 1) {
2012
+ errors.push(`<e-form> has ${submitButtons.length} submit buttons \u2014 only 1 is allowed.`);
2013
+ }
2014
+ }
2015
+ return errors;
2016
+ }
2017
+ function collectEmptyMessageErrors(xml) {
2018
+ const errors = [];
2019
+ for (const node of xml.querySelectorAll("e-form-success-message, e-form-error-message")) {
2020
+ if (node.children.length === 0) {
2021
+ errors.push(`<${node.tagName}> must have at least one child element (e.g. <e-atomic-paragraph>).`);
2022
+ }
2023
+ }
2024
+ return errors;
2025
+ }
1978
2026
 
1979
2027
  // src/form-structure/enforce-form-ancestor-commands.ts
1980
2028
  var FORM_FIELDS_OUTSIDE_ALERT = {
@@ -5180,6 +5228,11 @@ var CompositionBuilder = class _CompositionBuilder {
5180
5228
  throw new Error(`Invalid element structure:
5181
5229
  ${childTypeErrors.join("\n")}`);
5182
5230
  }
5231
+ const formErrors = [
5232
+ ...collectFormAncestorErrors(this.xml),
5233
+ ...collectSubmitButtonErrors(this.xml),
5234
+ ...collectEmptyMessageErrors(this.xml)
5235
+ ];
5183
5236
  const children = Array.from(this.xml.children);
5184
5237
  for (const childNode of children) {
5185
5238
  const modelTree = this.buildModelTree(childNode, widgetsCache);
@@ -5214,6 +5267,7 @@ ${childTypeErrors.join("\n")}`);
5214
5267
  return {
5215
5268
  configErrors,
5216
5269
  styleErrors,
5270
+ formErrors,
5217
5271
  rootContainers: [...this.rootContainers]
5218
5272
  };
5219
5273
  }
@@ -5524,7 +5578,11 @@ var initBuildCompositionsTool = (reg) => {
5524
5578
  compositionBuilder.setElementConfig(elementConfig);
5525
5579
  compositionBuilder.setStylesConfig(stylesConfig);
5526
5580
  compositionBuilder.setCustomCSS(customCSS);
5527
- const { configErrors, rootContainers: generatedRootContainers } = await compositionBuilder.build(targetContainer);
5581
+ const {
5582
+ configErrors,
5583
+ formErrors,
5584
+ rootContainers: generatedRootContainers
5585
+ } = await compositionBuilder.build(targetContainer);
5528
5586
  rootContainers.push(...generatedRootContainers);
5529
5587
  generatedXML = new XMLSerializer().serializeToString(compositionBuilder.getXML());
5530
5588
  rootContainers.forEach((container) => {
@@ -5539,6 +5597,9 @@ var initBuildCompositionsTool = (reg) => {
5539
5597
  if (configErrors.length) {
5540
5598
  errors.push(...configErrors.map((msg) => new Error(msg)));
5541
5599
  }
5600
+ if (formErrors.length) {
5601
+ errors.push(...formErrors.map((msg) => new Error(msg)));
5602
+ }
5542
5603
  } catch (error) {
5543
5604
  errors.push(error);
5544
5605
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-canvas",
3
3
  "description": "Elementor Editor Canvas",
4
- "version": "4.3.0-984",
4
+ "version": "4.3.0-986",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -37,26 +37,26 @@
37
37
  "react-dom": "^18.3.1"
38
38
  },
39
39
  "dependencies": {
40
- "@elementor/editor": "4.3.0-984",
41
- "@elementor/editor-controls": "4.3.0-984",
42
- "@elementor/editor-documents": "4.3.0-984",
43
- "@elementor/editor-elements": "4.3.0-984",
44
- "@elementor/editor-interactions": "4.3.0-984",
45
- "@elementor/editor-mcp": "4.3.0-984",
46
- "@elementor/editor-notifications": "4.3.0-984",
47
- "@elementor/editor-props": "4.3.0-984",
48
- "@elementor/editor-responsive": "4.3.0-984",
49
- "@elementor/editor-styles": "4.3.0-984",
50
- "@elementor/editor-styles-repository": "4.3.0-984",
51
- "@elementor/editor-ui": "4.3.0-984",
52
- "@elementor/editor-v1-adapters": "4.3.0-984",
53
- "@elementor/events": "4.3.0-984",
54
- "@elementor/http-client": "4.3.0-984",
55
- "@elementor/schema": "4.3.0-984",
56
- "@elementor/twing": "4.3.0-984",
40
+ "@elementor/editor": "4.3.0-986",
41
+ "@elementor/editor-controls": "4.3.0-986",
42
+ "@elementor/editor-documents": "4.3.0-986",
43
+ "@elementor/editor-elements": "4.3.0-986",
44
+ "@elementor/editor-interactions": "4.3.0-986",
45
+ "@elementor/editor-mcp": "4.3.0-986",
46
+ "@elementor/editor-notifications": "4.3.0-986",
47
+ "@elementor/editor-props": "4.3.0-986",
48
+ "@elementor/editor-responsive": "4.3.0-986",
49
+ "@elementor/editor-styles": "4.3.0-986",
50
+ "@elementor/editor-styles-repository": "4.3.0-986",
51
+ "@elementor/editor-ui": "4.3.0-986",
52
+ "@elementor/editor-v1-adapters": "4.3.0-986",
53
+ "@elementor/events": "4.3.0-986",
54
+ "@elementor/http-client": "4.3.0-986",
55
+ "@elementor/schema": "4.3.0-986",
56
+ "@elementor/twing": "4.3.0-986",
57
57
  "@elementor/ui": "1.37.5",
58
- "@elementor/utils": "4.3.0-984",
59
- "@elementor/wp-media": "4.3.0-984",
58
+ "@elementor/utils": "4.3.0-986",
59
+ "@elementor/wp-media": "4.3.0-986",
60
60
  "@floating-ui/react": "^0.27.5",
61
61
  "@wordpress/i18n": "^5.13.0",
62
62
  "dompurify": "^3.2.6"
@@ -0,0 +1,2 @@
1
+ export const parseXml = ( xml: string ): Document =>
2
+ new DOMParser().parseFromString( `<root>${ xml }</root>`, 'application/xml' );
@@ -11,6 +11,11 @@ import {
11
11
  } from '@elementor/editor-elements';
12
12
  import { type z } from '@elementor/schema';
13
13
 
14
+ import {
15
+ collectEmptyMessageErrors,
16
+ collectFormAncestorErrors,
17
+ collectSubmitButtonErrors,
18
+ } from '../form-structure/utils';
14
19
  import { doUpdateElementProperty } from '../mcp/utils/do-update-element-property';
15
20
  import { mergeCustomCssText } from '../mcp/utils/merge-custom-css';
16
21
  import { RequiredChildrenEnforcer } from './utils/required-children-enforcer';
@@ -294,6 +299,12 @@ export class CompositionBuilder {
294
299
  throw new Error( `Invalid element structure:\n${ childTypeErrors.join( '\n' ) }` );
295
300
  }
296
301
 
302
+ const formErrors = [
303
+ ...collectFormAncestorErrors( this.xml ),
304
+ ...collectSubmitButtonErrors( this.xml ),
305
+ ...collectEmptyMessageErrors( this.xml ),
306
+ ];
307
+
297
308
  const children = Array.from( this.xml.children );
298
309
  for ( const childNode of children ) {
299
310
  const modelTree = this.buildModelTree( childNode, widgetsCache );
@@ -332,6 +343,7 @@ export class CompositionBuilder {
332
343
  return {
333
344
  configErrors,
334
345
  styleErrors,
346
+ formErrors,
335
347
  rootContainers: [ ...this.rootContainers ],
336
348
  };
337
349
  }
@@ -1,7 +1,11 @@
1
1
  import type { V1Element } from '@elementor/editor-elements';
2
2
 
3
+ import { parseXml } from '../../__tests__/parse-xml';
3
4
  import {
4
5
  clipboardRootsAreAtomicForms,
6
+ collectEmptyMessageErrors,
7
+ collectFormAncestorErrors,
8
+ collectSubmitButtonErrors,
5
9
  FORM_ELEMENT_TYPE,
6
10
  getClipboardElementType,
7
11
  movedContainersIncludeAtomicFormRoot,
@@ -67,4 +71,218 @@ describe( 'form-structure utils', () => {
67
71
  expect( clipboardRootsAreAtomicForms( [] ) ).toBe( false );
68
72
  } );
69
73
  } );
74
+
75
+ describe( 'collectFormAncestorErrors', () => {
76
+ it( 'returns no errors when form field is a direct child of e-form', () => {
77
+ const xml = parseXml( `
78
+ <e-form>
79
+ <e-form-input />
80
+ </e-form>
81
+ ` );
82
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
83
+ } );
84
+ it( 'returns no errors when form field is a deep descendant of e-form (valid)', () => {
85
+ const xml = parseXml( `
86
+ <e-form>
87
+ <e-flexbox>
88
+ <e-form-input />
89
+ </e-flexbox>
90
+ </e-form>
91
+ ` );
92
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
93
+ } );
94
+ it( 'returns no errors when e-form wraps multiple containers with fields (valid)', () => {
95
+ const xml = parseXml( `
96
+ <e-flexbox>
97
+ <e-form>
98
+ <e-form-input />
99
+ </e-form>
100
+ </e-flexbox>
101
+ ` );
102
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
103
+ } );
104
+ it( 'returns an error when form field has no e-form ancestor (invalid)', () => {
105
+ const xml = parseXml( `
106
+ <e-flexbox>
107
+ <e-flexbox>
108
+ <e-form-input />
109
+ </e-flexbox>
110
+ </e-flexbox>
111
+ ` );
112
+ const errors = collectFormAncestorErrors( xml );
113
+ expect( errors ).toHaveLength( 1 );
114
+ expect( errors[ 0 ] ).toContain( 'e-form-input' );
115
+ expect( errors[ 0 ] ).toContain( 'e-form' );
116
+ } );
117
+ it( 'returns an error for each orphaned form field', () => {
118
+ const xml = parseXml( `
119
+ <e-flexbox>
120
+ <e-form-input />
121
+ <e-form-label />
122
+ </e-flexbox>
123
+ ` );
124
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 2 );
125
+ } );
126
+ it( 'includes configuration-id in the error message when present', () => {
127
+ const xml = parseXml( `
128
+ <e-flexbox>
129
+ <e-form-input configuration-id="my-input" />
130
+ </e-flexbox>
131
+ ` );
132
+ const errors = collectFormAncestorErrors( xml );
133
+ expect( errors[ 0 ] ).toContain( 'configuration-id="my-input"' );
134
+ } );
135
+ it( 'returns no errors when there are no form fields at all', () => {
136
+ const xml = parseXml( `
137
+ <e-flexbox>
138
+ <e-heading />
139
+ </e-flexbox>
140
+ ` );
141
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
142
+ } );
143
+ } );
144
+
145
+ describe( 'collectSubmitButtonErrors', () => {
146
+ it( 'returns no errors when e-form has exactly one submit button', () => {
147
+ const xml = parseXml( `
148
+ <e-form>
149
+ <e-form-input />
150
+ <e-form-submit-button />
151
+ </e-form>
152
+ ` );
153
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
154
+ } );
155
+
156
+ it( 'returns no errors when submit button is nested inside a container within e-form', () => {
157
+ const xml = parseXml( `
158
+ <e-form>
159
+ <e-flexbox>
160
+ <e-form-submit-button />
161
+ </e-flexbox>
162
+ </e-form>
163
+ ` );
164
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
165
+ } );
166
+
167
+ it( 'returns an error when e-form has no submit button', () => {
168
+ const xml = parseXml( `
169
+ <e-form>
170
+ <e-form-input />
171
+ </e-form>
172
+ ` );
173
+ const errors = collectSubmitButtonErrors( xml );
174
+ expect( errors ).toHaveLength( 1 );
175
+ expect( errors[ 0 ] ).toContain( 'e-form-submit-button' );
176
+ } );
177
+
178
+ it( 'returns an error when e-form has more than one submit button', () => {
179
+ const xml = parseXml( `
180
+ <e-form>
181
+ <e-form-submit-button />
182
+ <e-form-submit-button />
183
+ </e-form>
184
+ ` );
185
+ const errors = collectSubmitButtonErrors( xml );
186
+ expect( errors ).toHaveLength( 1 );
187
+ expect( errors[ 0 ] ).toContain( '2' );
188
+ } );
189
+
190
+ it( 'returns one error per e-form that is missing a submit button', () => {
191
+ const xml = parseXml( `
192
+ <e-form>
193
+ <e-form-input />
194
+ </e-form>
195
+ <e-form>
196
+ <e-form-input />
197
+ </e-form>
198
+ ` );
199
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 2 );
200
+ } );
201
+
202
+ it( 'returns no errors when there are no e-form elements', () => {
203
+ const xml = parseXml( `
204
+ <e-flexbox>
205
+ <e-heading />
206
+ </e-flexbox>
207
+ ` );
208
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
209
+ } );
210
+ } );
211
+
212
+ describe( 'collectEmptyMessageErrors', () => {
213
+ it( 'returns no errors when success message has children', () => {
214
+ const xml = parseXml( `
215
+ <e-form>
216
+ <e-form-success-message>
217
+ <e-atomic-paragraph />
218
+ </e-form-success-message>
219
+ </e-form>
220
+ ` );
221
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
222
+ } );
223
+
224
+ it( 'returns no errors when error message has children', () => {
225
+ const xml = parseXml( `
226
+ <e-form>
227
+ <e-form-error-message>
228
+ <e-atomic-paragraph />
229
+ </e-form-error-message>
230
+ </e-form>
231
+ ` );
232
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
233
+ } );
234
+
235
+ it( 'returns an error when success message is empty', () => {
236
+ const xml = parseXml( `
237
+ <e-form>
238
+ <e-form-success-message />
239
+ </e-form>
240
+ ` );
241
+ const errors = collectEmptyMessageErrors( xml );
242
+ expect( errors ).toHaveLength( 1 );
243
+ expect( errors[ 0 ] ).toContain( 'e-form-success-message' );
244
+ } );
245
+
246
+ it( 'returns an error when error message is empty', () => {
247
+ const xml = parseXml( `
248
+ <e-form>
249
+ <e-form-error-message />
250
+ </e-form>
251
+ ` );
252
+ const errors = collectEmptyMessageErrors( xml );
253
+ expect( errors ).toHaveLength( 1 );
254
+ expect( errors[ 0 ] ).toContain( 'e-form-error-message' );
255
+ } );
256
+
257
+ it( 'returns two errors when both success and error messages are empty', () => {
258
+ const xml = parseXml( `
259
+ <e-form>
260
+ <e-form-success-message />
261
+ <e-form-error-message />
262
+ </e-form>
263
+ ` );
264
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 2 );
265
+ } );
266
+
267
+ it( 'returns no errors when there are no message elements', () => {
268
+ const xml = parseXml( `
269
+ <e-form>
270
+ <e-form-input />
271
+ <e-form-submit-button />
272
+ </e-form>
273
+ ` );
274
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
275
+ } );
276
+
277
+ it( 'returns no errors when message element is outside e-form but has children', () => {
278
+ const xml = parseXml( `
279
+ <e-flexbox>
280
+ <e-form-success-message>
281
+ <e-atomic-paragraph />
282
+ </e-form-success-message>
283
+ </e-flexbox>
284
+ ` );
285
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
286
+ } );
287
+ } );
70
288
  } );
@@ -115,3 +115,49 @@ export function clipboardRootsAreAtomicForms( elements: ClipboardElement[] ): bo
115
115
 
116
116
  return elements.every( ( el ) => getClipboardElementType( el ) === FORM_ELEMENT_TYPE );
117
117
  }
118
+
119
+ export function hasFormAncestor( node: Element ): boolean {
120
+ return node.closest( FORM_ELEMENT_TYPE ) !== null;
121
+ }
122
+
123
+ export function collectFormAncestorErrors( xml: Document ): string[] {
124
+ const errors: string[] = [];
125
+ for ( const node of xml.querySelectorAll( '*' ) ) {
126
+ if ( ! FORM_FIELD_ELEMENT_TYPES.has( node.tagName.toLowerCase() ) ) {
127
+ continue;
128
+ }
129
+ if ( hasFormAncestor( node ) ) {
130
+ continue;
131
+ }
132
+ const id = node.getAttribute( 'configuration-id' );
133
+ errors.push(
134
+ `<${ node.tagName }${
135
+ id ? ` configuration-id="${ id }"` : ''
136
+ }> must be nested inside <e-form> (any ancestor depth is allowed).`
137
+ );
138
+ }
139
+ return errors;
140
+ }
141
+
142
+ export function collectSubmitButtonErrors( xml: Document ): string[] {
143
+ const errors: string[] = [];
144
+ for ( const form of xml.querySelectorAll( 'e-form' ) ) {
145
+ const submitButtons = form.querySelectorAll( 'e-form-submit-button' );
146
+ if ( submitButtons.length === 0 ) {
147
+ errors.push( `<e-form> has no <e-form-submit-button>.` );
148
+ } else if ( submitButtons.length > 1 ) {
149
+ errors.push( `<e-form> has ${ submitButtons.length } submit buttons — only 1 is allowed.` );
150
+ }
151
+ }
152
+ return errors;
153
+ }
154
+
155
+ export function collectEmptyMessageErrors( xml: Document ): string[] {
156
+ const errors: string[] = [];
157
+ for ( const node of xml.querySelectorAll( 'e-form-success-message, e-form-error-message' ) ) {
158
+ if ( node.children.length === 0 ) {
159
+ errors.push( `<${ node.tagName }> must have at least one child element (e.g. <e-atomic-paragraph>).` );
160
+ }
161
+ }
162
+ return errors;
163
+ }
@@ -84,8 +84,11 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
84
84
  compositionBuilder.setStylesConfig( stylesConfig );
85
85
  compositionBuilder.setCustomCSS( customCSS );
86
86
 
87
- const { configErrors, rootContainers: generatedRootContainers } =
88
- await compositionBuilder.build( targetContainer );
87
+ const {
88
+ configErrors,
89
+ formErrors,
90
+ rootContainers: generatedRootContainers,
91
+ } = await compositionBuilder.build( targetContainer );
89
92
 
90
93
  rootContainers.push( ...generatedRootContainers );
91
94
  generatedXML = new XMLSerializer().serializeToString( compositionBuilder.getXML() );
@@ -105,6 +108,10 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
105
108
  if ( configErrors.length ) {
106
109
  errors.push( ...configErrors.map( ( msg ) => new Error( msg ) ) );
107
110
  }
111
+
112
+ if ( formErrors.length ) {
113
+ errors.push( ...formErrors.map( ( msg ) => new Error( msg ) ) );
114
+ }
108
115
  } catch ( error ) {
109
116
  errors.push( error as Error );
110
117
  }
@@ -3,6 +3,7 @@ import {
3
3
  computeCellRects,
4
4
  computeGridLines,
5
5
  parseTrackList,
6
+ resolveGapPx,
6
7
  snapToHalfPixel,
7
8
  toGridTracks,
8
9
  toPx,
@@ -215,11 +216,29 @@ describe( 'toPx', () => {
215
216
  } );
216
217
  } );
217
218
 
219
+ describe( 'resolveGapPx', () => {
220
+ it( 'resolves a percentage gap against the reference size', () => {
221
+ expect( resolveGapPx( '20%', '1000px' ) ).toBe( 200 );
222
+ expect( resolveGapPx( '10%', '500px' ) ).toBe( 50 );
223
+ } );
224
+
225
+ it( 'falls back to px parsing for non-percentage gaps', () => {
226
+ expect( resolveGapPx( '16px', '1000px' ) ).toBe( 16 );
227
+ expect( resolveGapPx( 'normal', '1000px' ) ).toBe( 0 );
228
+ } );
229
+
230
+ it( 'returns 0 when the reference size is not a finite number', () => {
231
+ expect( resolveGapPx( '20%', 'auto' ) ).toBe( 0 );
232
+ } );
233
+ } );
234
+
218
235
  type ComputedStyleParts = {
219
236
  gridTemplateColumns: string;
220
237
  gridTemplateRows: string;
221
238
  columnGap: string;
222
239
  rowGap: string;
240
+ width: string;
241
+ height: string;
223
242
  paddingTop: string;
224
243
  paddingRight: string;
225
244
  paddingBottom: string;
@@ -233,6 +252,8 @@ function mockComputedStyle( parts: Partial< ComputedStyleParts > = {} ): CSSStyl
233
252
  gridTemplateRows: 'none',
234
253
  columnGap: 'normal',
235
254
  rowGap: 'normal',
255
+ width: '1000px',
256
+ height: '500px',
236
257
  paddingTop: '0px',
237
258
  paddingRight: '0px',
238
259
  paddingBottom: '0px',
@@ -246,6 +267,8 @@ function mockComputedStyle( parts: Partial< ComputedStyleParts > = {} ): CSSStyl
246
267
  gridTemplateRows: resolved.gridTemplateRows,
247
268
  columnGap: resolved.columnGap,
248
269
  rowGap: resolved.rowGap,
270
+ width: resolved.width,
271
+ height: resolved.height,
249
272
  paddingTop: resolved.paddingTop,
250
273
  paddingRight: resolved.paddingRight,
251
274
  paddingBottom: resolved.paddingBottom,
@@ -286,6 +309,22 @@ describe( 'toGridTracks', () => {
286
309
  expect( tracks.rows ).toEqual( [] );
287
310
  } );
288
311
 
312
+ it( 'resolves percentage gaps against the content-box width and height', () => {
313
+ const tracks = toGridTracks(
314
+ mockComputedStyle( {
315
+ gridTemplateColumns: '100px 100px',
316
+ gridTemplateRows: '80px 80px',
317
+ columnGap: '20%',
318
+ rowGap: '10%',
319
+ width: '1000px',
320
+ height: '500px',
321
+ } )
322
+ );
323
+
324
+ expect( tracks.columnGap ).toBe( 200 );
325
+ expect( tracks.rowGap ).toBe( 50 );
326
+ } );
327
+
289
328
  it( 'reports gap as 0 when computed style returns "normal"', () => {
290
329
  const tracks = toGridTracks(
291
330
  mockComputedStyle( { gridTemplateColumns: '100px 100px', columnGap: 'normal', rowGap: 'normal' } )
@@ -28,8 +28,8 @@ export function toGridTracks( computedStyle: CSSStyleDeclaration ): GridTracks {
28
28
  return {
29
29
  columns: parseTrackList( computedStyle.gridTemplateColumns ),
30
30
  rows: parseTrackList( computedStyle.gridTemplateRows ),
31
- columnGap: toPx( computedStyle.columnGap ),
32
- rowGap: toPx( computedStyle.rowGap ),
31
+ columnGap: resolveGapPx( computedStyle.columnGap, computedStyle.width ),
32
+ rowGap: resolveGapPx( computedStyle.rowGap, computedStyle.height ),
33
33
  padding: {
34
34
  top: toPx( computedStyle.paddingTop ),
35
35
  right: toPx( computedStyle.paddingRight ),
@@ -142,3 +142,14 @@ export function toPx( value: string ): number {
142
142
 
143
143
  return Number.isFinite( parsed ) ? parsed : 0;
144
144
  }
145
+
146
+ export function resolveGapPx( value: string, referenceSize: string ): number {
147
+ if ( value.trim().endsWith( '%' ) ) {
148
+ const percent = parseFloat( value );
149
+ const reference = parseFloat( referenceSize );
150
+
151
+ return Number.isFinite( percent ) && Number.isFinite( reference ) ? ( percent / 100 ) * reference : 0;
152
+ }
153
+
154
+ return toPx( value );
155
+ }