@elementor/editor-canvas 4.3.0-999 → 4.3.0-beta2

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 (52) hide show
  1. package/dist/index.d.mts +3 -1
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.js +535 -393
  4. package/dist/index.mjs +516 -370
  5. package/package.json +20 -20
  6. package/src/__tests__/flex-transformer.test.ts +11 -11
  7. package/src/__tests__/prop-types.ts +11 -0
  8. package/src/init-settings-transformers.ts +4 -0
  9. package/src/init-style-transformers.ts +2 -2
  10. package/src/init.tsx +5 -4
  11. package/src/legacy/__tests__/create-templated-element-type.test.ts +50 -0
  12. package/src/legacy/__tests__/list-type.test.ts +89 -0
  13. package/src/legacy/create-nested-templated-element-type.ts +3 -1
  14. package/src/legacy/create-templated-element-type.ts +5 -2
  15. package/src/legacy/list-type.ts +63 -0
  16. package/src/legacy/replacements/inline-editing/__tests__/inline-editing-eligibility.test.ts +7 -7
  17. package/src/legacy/replacements/inline-editing/inline-editing-elements.tsx +22 -9
  18. package/src/legacy/replacements/inline-editing/inline-editing-eligibility.ts +12 -3
  19. package/src/legacy/tabs-model-extensions.ts +2 -5
  20. package/src/legacy/twig-rendering-utils.ts +11 -2
  21. package/src/legacy/types.ts +4 -0
  22. package/src/mcp/canvas-mcp.ts +2 -4
  23. package/src/mcp/mcp-description.ts +18 -30
  24. package/src/mcp/resources/__tests__/available-widgets-resource.test.ts +22 -13
  25. package/src/mcp/resources/__tests__/dynamic-tags-resource.test.ts +22 -30
  26. package/src/mcp/resources/__tests__/widgets-schema-resource.test.ts +11 -6
  27. package/src/mcp/resources/available-widgets-resource.ts +13 -10
  28. package/src/mcp/resources/dynamic-tags-resource.ts +6 -15
  29. package/src/mcp/resources/widgets-schema-resource.ts +8 -5
  30. package/src/mcp/tools/configure-element/__tests__/tool.test.ts +130 -0
  31. package/src/mcp/tools/configure-element/prompt.ts +3 -6
  32. package/src/mcp/tools/configure-element/schema.ts +6 -0
  33. package/src/mcp/tools/configure-element/tool.ts +11 -1
  34. package/src/mcp/tools/get-page-structure/tool.ts +73 -0
  35. package/src/mcp/utils/__tests__/do-update-element-property.test.ts +27 -1
  36. package/src/mcp/utils/do-update-element-property.ts +18 -6
  37. package/src/mcp/utils/get-mcp-error-message.ts +16 -0
  38. package/src/renderers/__tests__/compute-html-tag.test.ts +55 -0
  39. package/src/renderers/__tests__/create-dom-renderer.test.ts +60 -0
  40. package/src/renderers/__tests__/fixtures/html-tag-computer-cases.json +87 -0
  41. package/src/renderers/compute-html-tag.ts +73 -0
  42. package/src/renderers/create-dom-renderer.ts +13 -22
  43. package/src/transformers/settings/escaped-html-transformer.ts +6 -0
  44. package/src/transformers/shared/__tests__/icon-transformer.test.ts +199 -0
  45. package/src/transformers/shared/icon-transformer.ts +231 -0
  46. package/src/transformers/shared/process-svg-content.ts +26 -0
  47. package/src/transformers/shared/svg-src-transformer.ts +1 -26
  48. package/src/transformers/styles/flex-transformer.ts +11 -32
  49. package/src/utils/__tests__/sanitize-escaped-html.test.ts +105 -0
  50. package/src/utils/sanitize-escaped-html.ts +32 -0
  51. package/src/mcp/tools/build-composition/tool.ts +0 -133
  52. package/src/mcp/tools/get-element-config/tool.ts +0 -114
@@ -0,0 +1,199 @@
1
+ import { iconPropTypeUtil, stringPropTypeUtil } from '@elementor/editor-props';
2
+
3
+ import { iconPropType } from '../../../__tests__/prop-types';
4
+ import { initSettingsTransformers } from '../../../init-settings-transformers';
5
+ import { createPropsResolver } from '../../../renderers/create-props-resolver';
6
+ import { settingsTransformersRegistry } from '../../../settings-transformers-registry';
7
+ import { resetFontAwesomeIconsCache } from '../icon-transformer';
8
+
9
+ const STAR_PATH = 'M0 0h100v100H0z';
10
+ const STAR_WIDTH = 576;
11
+ const STAR_HEIGHT = 512;
12
+
13
+ const mockFetch = ( body: unknown, ok = true ) => {
14
+ global.fetch = jest.fn().mockResolvedValue( {
15
+ ok,
16
+ json: () => Promise.resolve( body ),
17
+ } );
18
+ };
19
+
20
+ function createSavedIcon( iconClass: string, library: string ) {
21
+ return iconPropTypeUtil.create( {
22
+ value: stringPropTypeUtil.create( iconClass ),
23
+ library: stringPropTypeUtil.create( library ),
24
+ } );
25
+ }
26
+
27
+ async function resolveSavedIcon( iconClass: string, library: string ) {
28
+ const resolve = createPropsResolver( {
29
+ transformers: settingsTransformersRegistry,
30
+ schema: { svg: iconPropType() },
31
+ } );
32
+
33
+ const result = await resolve( {
34
+ props: {
35
+ svg: createSavedIcon( iconClass, library ),
36
+ },
37
+ } );
38
+
39
+ return result.svg;
40
+ }
41
+
42
+ describe( 'iconTransformer', () => {
43
+ const originalElementorCommon = window.elementorCommon;
44
+
45
+ beforeEach( () => {
46
+ jest.clearAllMocks();
47
+ resetFontAwesomeIconsCache();
48
+ initSettingsTransformers();
49
+ window.elementorCommon = {
50
+ config: {
51
+ urls: {
52
+ assets: 'https://example.com/assets/',
53
+ },
54
+ fontAwesome: {
55
+ v7: {
56
+ jsonFiles: [ 'solid', 'regular', 'brands' ],
57
+ jsonBaseUrl: 'https://example.com/assets/lib/font-awesome-7/json/',
58
+ },
59
+ },
60
+ },
61
+ };
62
+ } );
63
+
64
+ afterEach( () => {
65
+ window.elementorCommon = originalElementorCommon;
66
+ jest.restoreAllMocks();
67
+ } );
68
+
69
+ it( 'returns processed inline svg for a font awesome icon', async () => {
70
+ // Arrange.
71
+ mockFetch( {
72
+ icons: {
73
+ star: [ STAR_WIDTH, STAR_HEIGHT, [], 'f005', STAR_PATH ],
74
+ },
75
+ } );
76
+
77
+ // Act.
78
+ const result = await resolveSavedIcon( 'fas fa-star', 'fa-solid' );
79
+
80
+ // Assert.
81
+ expect( global.fetch ).toHaveBeenCalledWith( 'https://example.com/assets/lib/font-awesome-7/json/solid.json', {
82
+ signal: undefined,
83
+ } );
84
+ expect( result ).toEqual( {
85
+ html: expect.stringContaining( STAR_PATH ),
86
+ url: null,
87
+ } );
88
+ expect( ( result as { html: string } ).html ).toContain( 'fill="currentColor"' );
89
+ expect( ( result as { html: string } ).html ).toContain( `viewBox="0 0 ${ STAR_WIDTH } ${ STAR_HEIGHT }"` );
90
+ expect( ( result as { html: string } ).html ).toContain( 'aria-hidden="true"' );
91
+ expect( ( result as { html: string } ).html ).toContain( 'overflow: visible' );
92
+ } );
93
+
94
+ it( 'resolves icons by alias name', async () => {
95
+ // Arrange.
96
+ mockFetch( {
97
+ icons: {
98
+ headphones: [ 448, 512, [ 'headphones-simple' ], 'f025', 'M0 0' ],
99
+ },
100
+ } );
101
+
102
+ // Act.
103
+ const result = await resolveSavedIcon( 'fas fa-headphones-simple', 'fa-solid' );
104
+
105
+ // Assert.
106
+ expect( result ).toEqual( {
107
+ html: expect.stringContaining( 'M0 0' ),
108
+ url: null,
109
+ } );
110
+ } );
111
+
112
+ it( 'renders multiple paths when the icon definition contains an array', async () => {
113
+ // Arrange.
114
+ mockFetch( {
115
+ icons: {
116
+ slash: [ 640, 640, [], 'f715', [ 'M0 0', 'M10 10' ] ],
117
+ },
118
+ } );
119
+
120
+ // Act.
121
+ const result = await resolveSavedIcon( 'fas fa-slash', 'fa-solid' );
122
+
123
+ // Assert.
124
+ const html = ( result as { html: string } ).html;
125
+ expect( html.match( /<path/g ) ).toHaveLength( 2 );
126
+ expect( html ).toContain( 'M0 0' );
127
+ expect( html ).toContain( 'M10 10' );
128
+ } );
129
+
130
+ it( 'returns null html when the icon cannot be resolved', async () => {
131
+ // Arrange.
132
+ mockFetch( { icons: {} } );
133
+
134
+ // Act.
135
+ const result = await resolveSavedIcon( 'fas fa-missing', 'fa-solid' );
136
+
137
+ // Assert.
138
+ expect( result ).toEqual( { html: null, url: null } );
139
+ } );
140
+
141
+ it( 'fetches json from the localized font awesome 7 base url', async () => {
142
+ // Arrange.
143
+ const jsonBaseUrl = 'https://cdn.example.com/fa7/json/';
144
+ window.elementorCommon = {
145
+ config: {
146
+ fontAwesome: {
147
+ v7: {
148
+ jsonFiles: [ 'solid', 'regular', 'brands' ],
149
+ jsonBaseUrl,
150
+ },
151
+ },
152
+ },
153
+ };
154
+ mockFetch( {
155
+ icons: {
156
+ star: [ STAR_WIDTH, STAR_HEIGHT, [], 'f005', STAR_PATH ],
157
+ },
158
+ } );
159
+
160
+ // Act.
161
+ await resolveSavedIcon( 'fas fa-star', 'fa-solid' );
162
+
163
+ // Assert.
164
+ expect( global.fetch ).toHaveBeenCalledWith( `${ jsonBaseUrl }solid.json`, {
165
+ signal: undefined,
166
+ } );
167
+ } );
168
+
169
+ it( 'does not fetch json when library is not an allowed fa7 file', async () => {
170
+ // Arrange.
171
+ mockFetch( { icons: {} } );
172
+
173
+ // Act.
174
+ const result = await resolveSavedIcon( 'fas fa-star', 'fa-../../wp-config' );
175
+
176
+ // Assert.
177
+ expect( global.fetch ).not.toHaveBeenCalled();
178
+ expect( result ).toEqual( { html: null, url: null } );
179
+ } );
180
+
181
+ it( 'returns null html when value or library is missing', async () => {
182
+ // Act.
183
+ const resolve = createPropsResolver( {
184
+ transformers: settingsTransformersRegistry,
185
+ schema: { svg: iconPropType() },
186
+ } );
187
+ const result = await resolve( {
188
+ props: {
189
+ svg: iconPropTypeUtil.create( {
190
+ value: stringPropTypeUtil.create( 'fas fa-star' ),
191
+ library: stringPropTypeUtil.create( '' ),
192
+ } ),
193
+ },
194
+ } );
195
+
196
+ // Assert.
197
+ expect( result.svg ).toEqual( { html: null, url: null } );
198
+ } );
199
+ } );
@@ -0,0 +1,231 @@
1
+ import { createTransformer } from '../create-transformer';
2
+ import type { TransformerOptions } from '../types';
3
+ import { processSvgContent } from './process-svg-content';
4
+
5
+ type IconValue = {
6
+ value?: unknown;
7
+ library?: unknown;
8
+ };
9
+
10
+ type FontAwesomeIconJson = [ number, number, unknown[], unknown, string | string[] ];
11
+
12
+ type FontAwesome7EditorConfig = {
13
+ jsonFiles: string[];
14
+ jsonBaseUrl: string;
15
+ };
16
+
17
+ const FONT_AWESOME_JSON = {
18
+ width: 0,
19
+ height: 1,
20
+ aliases: 2,
21
+ unicode: 3,
22
+ path: 4,
23
+ } as const;
24
+
25
+ const fontAwesomeJsonCache = new Map< string, Record< string, FontAwesomeIconJson > >();
26
+
27
+ export const iconTransformer = createTransformer( async ( value: IconValue, { signal }: TransformerOptions ) => {
28
+ const iconValue = typeof value.value === 'string' ? value.value : null;
29
+ const library = typeof value.library === 'string' ? value.library : null;
30
+
31
+ if ( ! iconValue || ! library ) {
32
+ return { html: null, url: null };
33
+ }
34
+
35
+ const iconName = getFontAwesomeIconName( iconValue );
36
+ const jsonFileName = getFontAwesomeJsonFileName( library );
37
+
38
+ if ( ! iconName || ! jsonFileName ) {
39
+ return { html: null, url: null };
40
+ }
41
+
42
+ const icons = await fetchFontAwesomeIcons( jsonFileName, signal );
43
+ const iconData = icons?.[ iconName ];
44
+
45
+ if ( ! iconData ) {
46
+ return { html: null, url: null };
47
+ }
48
+
49
+ const svgText = buildFontAwesomeSvg( iconData );
50
+
51
+ if ( ! svgText ) {
52
+ return { html: null, url: null };
53
+ }
54
+
55
+ const html = processIconSvgContent( svgText );
56
+
57
+ return { html, url: null };
58
+ } );
59
+
60
+ function getFontAwesomeIconName( iconValue: string ): string | null {
61
+ const match = iconValue.match( /^fa\S*\s+fa-(.+)$/ );
62
+
63
+ return match?.[ 1 ] ?? null;
64
+ }
65
+
66
+ function getFontAwesomeJsonFileName( library: string ): string | null {
67
+ const fileName = library.replace( /^fa-/, '' );
68
+ const config = getFontAwesome7EditorConfig();
69
+
70
+ if ( ! config?.jsonFiles.includes( fileName ) ) {
71
+ return null;
72
+ }
73
+
74
+ return fileName;
75
+ }
76
+
77
+ function getFontAwesome7EditorConfig(): FontAwesome7EditorConfig | null {
78
+ const config = window.elementorCommon?.config?.fontAwesome?.v7;
79
+
80
+ if (
81
+ ! config ||
82
+ ! Array.isArray( config.jsonFiles ) ||
83
+ typeof config.jsonBaseUrl !== 'string' ||
84
+ config.jsonBaseUrl === ''
85
+ ) {
86
+ return null;
87
+ }
88
+
89
+ return {
90
+ jsonFiles: config.jsonFiles,
91
+ jsonBaseUrl: config.jsonBaseUrl,
92
+ };
93
+ }
94
+
95
+ async function fetchFontAwesomeIcons(
96
+ jsonFileName: string,
97
+ signal?: AbortSignal
98
+ ): Promise< Record< string, FontAwesomeIconJson > | null > {
99
+ const cached = fontAwesomeJsonCache.get( jsonFileName );
100
+
101
+ if ( cached ) {
102
+ return cached;
103
+ }
104
+
105
+ const icons = await loadFontAwesomeIcons( jsonFileName, signal );
106
+
107
+ if ( icons ) {
108
+ fontAwesomeJsonCache.set( jsonFileName, icons );
109
+ }
110
+
111
+ return icons;
112
+ }
113
+
114
+ async function loadFontAwesomeIcons(
115
+ jsonFileName: string,
116
+ signal?: AbortSignal
117
+ ): Promise< Record< string, FontAwesomeIconJson > | null > {
118
+ const config = getFontAwesome7EditorConfig();
119
+
120
+ if ( ! config?.jsonFiles.includes( jsonFileName ) ) {
121
+ return null;
122
+ }
123
+
124
+ try {
125
+ const response = await fetch( `${ config.jsonBaseUrl }${ jsonFileName }.json`, {
126
+ signal,
127
+ } );
128
+
129
+ if ( ! response.ok ) {
130
+ return null;
131
+ }
132
+
133
+ const data = ( await response.json() ) as { icons?: Record< string, FontAwesomeIconJson > };
134
+ const icons = data.icons;
135
+
136
+ if ( ! icons || typeof icons !== 'object' ) {
137
+ return null;
138
+ }
139
+
140
+ return indexFontAwesomeIcons( icons );
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function indexFontAwesomeIcons( icons: Record< string, FontAwesomeIconJson > ): Record< string, FontAwesomeIconJson > {
147
+ const index: Record< string, FontAwesomeIconJson > = {};
148
+
149
+ for ( const [ name, iconData ] of Object.entries( icons ) ) {
150
+ if ( ! isValidIconTuple( iconData ) ) {
151
+ continue;
152
+ }
153
+
154
+ index[ name ] = iconData;
155
+
156
+ for ( const alias of iconData[ FONT_AWESOME_JSON.aliases ] ) {
157
+ if ( typeof alias === 'string' && alias !== '' && ! index[ alias ] ) {
158
+ index[ alias ] = iconData;
159
+ }
160
+ }
161
+ }
162
+
163
+ return index;
164
+ }
165
+
166
+ function isValidIconTuple( iconData: unknown ): iconData is FontAwesomeIconJson {
167
+ return (
168
+ Array.isArray( iconData ) &&
169
+ iconData.length >= 5 &&
170
+ typeof iconData[ FONT_AWESOME_JSON.width ] === 'number' &&
171
+ typeof iconData[ FONT_AWESOME_JSON.height ] === 'number' &&
172
+ Array.isArray( iconData[ FONT_AWESOME_JSON.aliases ] )
173
+ );
174
+ }
175
+
176
+ function buildFontAwesomeSvg( iconData: FontAwesomeIconJson ): string | null {
177
+ const width = iconData[ FONT_AWESOME_JSON.width ];
178
+ const height = iconData[ FONT_AWESOME_JSON.height ];
179
+ const paths = normalizePaths( iconData[ FONT_AWESOME_JSON.path ] );
180
+
181
+ if ( paths.length === 0 ) {
182
+ return null;
183
+ }
184
+
185
+ const pathMarkup = paths.map( ( path ) => `<path d="${ escapeSvgPath( path ) }"></path>` ).join( '' );
186
+
187
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${ width } ${ height }">${ pathMarkup }</svg>`;
188
+ }
189
+
190
+ function normalizePaths( pathData: string | string[] ): string[] {
191
+ if ( typeof pathData === 'string' && pathData !== '' ) {
192
+ return [ pathData ];
193
+ }
194
+
195
+ if ( ! Array.isArray( pathData ) ) {
196
+ return [];
197
+ }
198
+
199
+ return pathData.filter( ( path ): path is string => typeof path === 'string' && path !== '' );
200
+ }
201
+
202
+ function escapeSvgPath( path: string ): string {
203
+ return path.replace( /&/g, '&amp;' ).replace( /"/g, '&quot;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
204
+ }
205
+
206
+ function processIconSvgContent( svgText: string ): string | null {
207
+ const html = processSvgContent( svgText );
208
+
209
+ if ( ! html ) {
210
+ return null;
211
+ }
212
+
213
+ const parser = new DOMParser();
214
+ const doc = parser.parseFromString( html, 'image/svg+xml' );
215
+ const svgElement = doc.querySelector( 'svg' );
216
+
217
+ if ( ! svgElement ) {
218
+ return null;
219
+ }
220
+
221
+ svgElement.setAttribute( 'aria-hidden', 'true' );
222
+ svgElement.style.setProperty( 'width', '100%' );
223
+ svgElement.style.setProperty( 'height', '100%' );
224
+ svgElement.style.setProperty( 'overflow', 'visible' );
225
+
226
+ return svgElement.outerHTML;
227
+ }
228
+
229
+ export function resetFontAwesomeIconsCache() {
230
+ fontAwesomeJsonCache.clear();
231
+ }
@@ -0,0 +1,26 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ const SVG_INLINE_STYLES = 'width: 100%; height: 100%; overflow: unset;';
4
+
5
+ export function processSvgContent( svgText: string ): string | null {
6
+ const sanitized = DOMPurify.sanitize( svgText, {
7
+ USE_PROFILES: { svg: true, svgFilters: true },
8
+ } );
9
+
10
+ const parser = new DOMParser();
11
+ const doc = parser.parseFromString( sanitized, 'image/svg+xml' );
12
+ const svgElement = doc.querySelector( 'svg' );
13
+
14
+ if ( ! svgElement ) {
15
+ return null;
16
+ }
17
+
18
+ svgElement.setAttribute( 'fill', 'currentColor' );
19
+
20
+ const existingStyle = svgElement.getAttribute( 'style' ) ?? '';
21
+ const trimmed = existingStyle.trim();
22
+ const merged = trimmed ? `${ trimmed.replace( /;$/, '' ) }; ${ SVG_INLINE_STYLES }` : SVG_INLINE_STYLES;
23
+ svgElement.setAttribute( 'style', merged );
24
+
25
+ return svgElement.outerHTML;
26
+ }
@@ -1,39 +1,14 @@
1
- import DOMPurify from 'dompurify';
2
1
  import { getMediaAttachment } from '@elementor/wp-media';
3
2
 
4
3
  import { createTransformer } from '../create-transformer';
5
4
  import type { TransformerOptions } from '../types';
5
+ import { processSvgContent } from './process-svg-content';
6
6
 
7
7
  type SvgSrc = {
8
8
  id?: unknown;
9
9
  url?: unknown;
10
10
  };
11
11
 
12
- const SVG_INLINE_STYLES = 'width: 100%; height: 100%; overflow: unset;';
13
-
14
- function processSvgContent( svgText: string ): string | null {
15
- const sanitized = DOMPurify.sanitize( svgText, {
16
- USE_PROFILES: { svg: true, svgFilters: true },
17
- } );
18
-
19
- const parser = new DOMParser();
20
- const doc = parser.parseFromString( sanitized, 'image/svg+xml' );
21
- const svgElement = doc.querySelector( 'svg' );
22
-
23
- if ( ! svgElement ) {
24
- return null;
25
- }
26
-
27
- svgElement.setAttribute( 'fill', 'currentColor' );
28
-
29
- const existingStyle = svgElement.getAttribute( 'style' ) ?? '';
30
- const trimmed = existingStyle.trim();
31
- const merged = trimmed ? `${ trimmed.replace( /;$/, '' ) }; ${ SVG_INLINE_STYLES }` : SVG_INLINE_STYLES;
32
- svgElement.setAttribute( 'style', merged );
33
-
34
- return svgElement.outerHTML;
35
- }
36
-
37
12
  async function fetchSvgContent( url: string, signal?: AbortSignal ): Promise< string | null > {
38
13
  try {
39
14
  const response = await fetch( url, { signal } );
@@ -6,6 +6,13 @@ type Flex = {
6
6
  flexBasis?: { size: number; unit: string } | string | null;
7
7
  };
8
8
 
9
+ const DEFAULT_FLEX_GROW = 0;
10
+ const DEFAULT_FLEX_SHRINK = 1;
11
+ const DEFAULT_FLEX_BASIS = 'auto';
12
+
13
+ const formatBasis = ( basis: NonNullable< Flex[ 'flexBasis' ] > ) =>
14
+ typeof basis === 'object' && basis.size !== undefined ? `${ basis.size }${ basis.unit || '' }` : basis;
15
+
9
16
  export const flexTransformer = createTransformer( ( value: Flex ) => {
10
17
  const grow = value.flexGrow;
11
18
  const shrink = value.flexShrink;
@@ -19,37 +26,9 @@ export const flexTransformer = createTransformer( ( value: Flex ) => {
19
26
  return null;
20
27
  }
21
28
 
22
- if ( hasGrow && hasShrink && hasBasis ) {
23
- return `${ grow } ${ shrink } ${
24
- typeof basis === 'object' && basis.size !== undefined ? `${ basis.size }${ basis.unit || '' }` : basis
25
- }`;
26
- }
27
-
28
- if ( hasGrow && hasShrink && ! hasBasis ) {
29
- return `${ grow } ${ shrink }`;
30
- }
31
- if ( hasGrow && ! hasShrink && hasBasis ) {
32
- return `${ grow } 1 ${
33
- typeof basis === 'object' && basis.size !== undefined ? `${ basis.size }${ basis.unit || '' }` : basis
34
- }`;
35
- }
36
- if ( ! hasGrow && hasShrink && hasBasis ) {
37
- return `0 ${ shrink } ${
38
- typeof basis === 'object' && basis.size !== undefined ? `${ basis.size }${ basis.unit || '' }` : basis
39
- }`;
40
- }
41
-
42
- if ( hasGrow && ! hasShrink && ! hasBasis ) {
43
- return `${ grow }`;
44
- }
45
- if ( ! hasGrow && hasShrink && ! hasBasis ) {
46
- return `0 ${ shrink }`;
47
- }
48
- if ( ! hasGrow && ! hasShrink && hasBasis ) {
49
- return `0 1 ${
50
- typeof basis === 'object' && basis.size !== undefined ? `${ basis.size }${ basis.unit || '' }` : basis
51
- }`;
52
- }
29
+ const growOut = hasGrow ? grow : DEFAULT_FLEX_GROW;
30
+ const shrinkOut = hasShrink ? shrink : DEFAULT_FLEX_SHRINK;
31
+ const basisOut = hasBasis ? formatBasis( basis ) : DEFAULT_FLEX_BASIS;
53
32
 
54
- return null;
33
+ return `${ growOut } ${ shrinkOut } ${ basisOut }`;
55
34
  } );
@@ -0,0 +1,105 @@
1
+ import { sanitizeEscapedHtml } from '../sanitize-escaped-html';
2
+
3
+ const TEST_ALLOWED_HTML_WRAPPER_TAGS = [ 'a', 'div', 'span', 'strong' ];
4
+
5
+ describe( 'sanitizeEscapedHtml', () => {
6
+ beforeEach( () => {
7
+ window.elementorCommon = {
8
+ config: {
9
+ allowedHTMLWrapperTags: TEST_ALLOWED_HTML_WRAPPER_TAGS,
10
+ },
11
+ };
12
+ } );
13
+
14
+ afterEach( () => {
15
+ delete window.elementorCommon;
16
+ } );
17
+
18
+ it( 'returns an empty string for nullish values', () => {
19
+ // Arrange & Act & Assert.
20
+ expect( sanitizeEscapedHtml( null ) ).toBe( '' );
21
+ expect( sanitizeEscapedHtml( '' ) ).toBe( '' );
22
+ } );
23
+
24
+ it( 'keeps allowed tags and strips disallowed tags', () => {
25
+ // Arrange.
26
+ const value = 'Hello <script>alert(1)</script><strong>world</strong>';
27
+
28
+ // Act.
29
+ const result = sanitizeEscapedHtml( value );
30
+
31
+ // Assert.
32
+ expect( result ).toBe( 'Hello <strong>world</strong>' );
33
+ } );
34
+
35
+ it( 'fails closed when the localized allowlist is missing', () => {
36
+ // Arrange.
37
+ delete window.elementorCommon;
38
+ const value = '<strong>world</strong>';
39
+
40
+ // Act.
41
+ const result = sanitizeEscapedHtml( value );
42
+
43
+ // Assert.
44
+ expect( result ).toBe( 'world' );
45
+ } );
46
+
47
+ it( 'strips javascript href from links', () => {
48
+ // Arrange.
49
+ const value = '<a href="javascript:alert(1)">click</a>';
50
+
51
+ // Act.
52
+ const result = sanitizeEscapedHtml( value );
53
+
54
+ // Assert.
55
+ expect( result ).not.toContain( 'javascript:' );
56
+ expect( result ).toContain( 'click' );
57
+ } );
58
+
59
+ it( 'strips data href from links', () => {
60
+ // Arrange.
61
+ const value = '<a href="data:text/html,<script>alert(1)</script>">click</a>';
62
+
63
+ // Act.
64
+ const result = sanitizeEscapedHtml( value );
65
+
66
+ // Assert.
67
+ expect( result ).not.toMatch( /<a\s+href=/i );
68
+ expect( result ).toContain( 'click' );
69
+ } );
70
+
71
+ it( 'preserves non-operational attributes', () => {
72
+ // Arrange.
73
+ const value =
74
+ '<span id="e-1" class="foo bar" style="color: red;" title="t" lang="en" dir="ltr" role="text" data-x="1" aria-label="label">world</span>';
75
+
76
+ // Act.
77
+ const result = sanitizeEscapedHtml( value );
78
+
79
+ // Assert.
80
+ expect( result ).toContain( 'id="e-1"' );
81
+ expect( result ).toContain( 'class="foo bar"' );
82
+ expect( result ).toContain( 'style="color: red;"' );
83
+ expect( result ).toContain( 'title="t"' );
84
+ expect( result ).toContain( 'lang="en"' );
85
+ expect( result ).toContain( 'dir="ltr"' );
86
+ expect( result ).toContain( 'role="text"' );
87
+ expect( result ).toContain( 'data-x="1"' );
88
+ expect( result ).toContain( 'aria-label="label"' );
89
+ expect( result ).toContain( 'world' );
90
+ } );
91
+
92
+ it( 'strips functional event-handler attributes', () => {
93
+ // Arrange.
94
+ const value = '<span id="e-1" onclick="evil()" onmouseover="evil()">world</span>';
95
+
96
+ // Act.
97
+ const result = sanitizeEscapedHtml( value );
98
+
99
+ // Assert.
100
+ expect( result ).toContain( 'id="e-1"' );
101
+ expect( result ).not.toContain( 'onclick' );
102
+ expect( result ).not.toContain( 'onmouseover' );
103
+ expect( result ).toContain( 'world' );
104
+ } );
105
+ } );
@@ -0,0 +1,32 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ const ALLOWED_NON_OPERATIONAL_ATTRS = [
4
+ 'href',
5
+ 'target',
6
+ 'class',
7
+ 'id',
8
+ 'style',
9
+ 'title',
10
+ 'lang',
11
+ 'dir',
12
+ 'role',
13
+ ] as const;
14
+
15
+ function getAllowedHtmlWrapperTags(): readonly string[] {
16
+ return window.elementorCommon?.config?.allowedHTMLWrapperTags ?? [];
17
+ }
18
+
19
+ export function sanitizeEscapedHtml( value: string | null ): string {
20
+ if ( ! value ) {
21
+ return '';
22
+ }
23
+
24
+ const allowedTags = [ ...getAllowedHtmlWrapperTags() ];
25
+
26
+ return DOMPurify.sanitize( value, {
27
+ ALLOWED_TAGS: allowedTags,
28
+ ALLOWED_ATTR: [ ...ALLOWED_NON_OPERATIONAL_ATTRS ],
29
+ ALLOW_DATA_ATTR: true,
30
+ ALLOW_ARIA_ATTR: true,
31
+ } );
32
+ }