@elementor/editor-canvas 4.3.3 → 4.3.5

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.
@@ -0,0 +1,153 @@
1
+ import { type MCPRegistryEntry } from '@elementor/editor-mcp';
2
+ import { type HttpResponse, httpService } from '@elementor/http-client';
3
+ import { z } from '@elementor/schema';
4
+
5
+ import { getMcpErrorMessage } from '../../utils/get-mcp-error-message';
6
+ import { findIconsToolPrompt } from './prompt';
7
+
8
+ const MCP_PROXY_URL = 'elementor/v1/mcp-proxy';
9
+
10
+ const MAX_QUERIES = 10;
11
+
12
+ const MAX_PER_PAGE = 25;
13
+
14
+ type IconMatch = {
15
+ value: string;
16
+ library: string;
17
+ name: string;
18
+ label: string;
19
+ matched_on: 'name' | 'alias' | 'term' | 'category' | 'library';
20
+ license: 'free' | 'pro';
21
+ };
22
+
23
+ type IconQueryResult = {
24
+ query: string;
25
+ matches: IconMatch[];
26
+ total: number;
27
+ page: number;
28
+ per_page: number;
29
+ truncated: boolean;
30
+ };
31
+
32
+ // NOTE: shape defined by Find_Icons_Ability::execute() in modules/mcp/abilities/find-icons-ability.php
33
+ type FindIconsResponse = {
34
+ results: IconQueryResult[];
35
+ libraries?: string[];
36
+ custom_libraries?: Record< string, string >;
37
+ categories?: string[];
38
+ truncated_custom_libraries?: string[];
39
+ font_awesome_version: string | null;
40
+ llm_instructions?: string;
41
+ };
42
+
43
+ export const initFindIconsTool = ( reg: MCPRegistryEntry ) => {
44
+ const { addTool } = reg;
45
+
46
+ addTool( {
47
+ name: 'find-icons',
48
+ description: findIconsToolPrompt.prompt(),
49
+ schema: {
50
+ queries: z
51
+ .array( z.string() )
52
+ .max( MAX_QUERIES )
53
+ .optional()
54
+ .describe(
55
+ 'One plain-language phrase per icon you need, for example ["add to cart", "free shipping"]. Batch every icon a page needs into a single call.'
56
+ ),
57
+ library: z
58
+ .string()
59
+ .optional()
60
+ .describe(
61
+ 'Restrict results to one library key reported in "libraries", for example "fa-solid". Omit to search every library installed on the site.'
62
+ ),
63
+ category: z
64
+ .string()
65
+ .optional()
66
+ .describe(
67
+ 'Restrict results to one Font Awesome category reported in "categories", for example "shopping". May be used without a query to browse that category.'
68
+ ),
69
+ page: z.number().int().min( 1 ).optional(),
70
+ perPage: z.number().int().min( 1 ).max( MAX_PER_PAGE ).optional(),
71
+ },
72
+ outputSchema: {
73
+ results: z
74
+ .array(
75
+ z.object( {
76
+ query: z.string(),
77
+ matches: z.array(
78
+ z.object( {
79
+ value: z.string(),
80
+ library: z.string(),
81
+ name: z.string(),
82
+ label: z.string(),
83
+ matched_on: z.enum( [ 'name', 'alias', 'term', 'category', 'library' ] ),
84
+ license: z.enum( [ 'free', 'pro' ] ),
85
+ } )
86
+ ),
87
+ total: z.number(),
88
+ page: z.number(),
89
+ per_page: z.number(),
90
+ truncated: z.boolean(),
91
+ } )
92
+ )
93
+ .describe(
94
+ 'One entry per query, each with ranked "matches". Every match carries the "value" and "library" pair to write on the e-svg icon prop, plus "matched_on" and "license".'
95
+ ),
96
+ libraries: z
97
+ .array( z.string() )
98
+ .optional()
99
+ .describe(
100
+ 'Icon library keys installed on this site. Only sent when nothing matched or when browsing.'
101
+ ),
102
+ customLibraries: z
103
+ .record( z.string(), z.string() )
104
+ .optional()
105
+ .describe(
106
+ 'Custom (uploaded) icon libraries keyed by library. Only sent when nothing matched or when browsing.'
107
+ ),
108
+ categories: z
109
+ .array( z.string() )
110
+ .optional()
111
+ .describe(
112
+ 'Icon categories for the "category" filter. Only sent when nothing matched or when browsing.'
113
+ ),
114
+ truncatedCustomLibraries: z
115
+ .array( z.string() )
116
+ .optional()
117
+ .describe( 'Custom libraries with more than 250 icons; only their first 250 icons are searchable.' ),
118
+ fontAwesomeVersion: z.string().nullable().describe( 'Font Awesome version the site renders icons from.' ),
119
+ llmInstructions: z
120
+ .string()
121
+ .optional()
122
+ .describe( 'Present only when nothing matched, explaining what to do next.' ),
123
+ },
124
+ handler: async ( { queries, library, category, page, perPage } ) => {
125
+ try {
126
+ const { data } = await httpService().post< HttpResponse< FindIconsResponse > >( MCP_PROXY_URL, {
127
+ tool: 'find-icons',
128
+ input: {
129
+ ...( queries ? { queries } : {} ),
130
+ ...( library ? { library } : {} ),
131
+ ...( category ? { category } : {} ),
132
+ ...( page ? { page } : {} ),
133
+ ...( perPage ? { per_page: perPage } : {} ),
134
+ },
135
+ } );
136
+
137
+ return {
138
+ results: data.data.results,
139
+ ...( data.data.libraries ? { libraries: data.data.libraries } : {} ),
140
+ ...( data.data.custom_libraries ? { customLibraries: data.data.custom_libraries } : {} ),
141
+ ...( data.data.categories ? { categories: data.data.categories } : {} ),
142
+ ...( data.data.truncated_custom_libraries
143
+ ? { truncatedCustomLibraries: data.data.truncated_custom_libraries }
144
+ : {} ),
145
+ fontAwesomeVersion: data.data.font_awesome_version,
146
+ ...( data.data.llm_instructions ? { llmInstructions: data.data.llm_instructions } : {} ),
147
+ };
148
+ } catch ( error ) {
149
+ throw new Error( getMcpErrorMessage( error, 'find-icons' ) );
150
+ }
151
+ },
152
+ } );
153
+ };
@@ -1,3 +1,9 @@
1
+ jest.mock( '@elementor/http-client', () => ( {
2
+ httpService: () => ( {
3
+ get: jest.fn().mockRejectedValue( new Error( 'no custom svg' ) ),
4
+ } ),
5
+ } ) );
6
+
1
7
  import { resetFontAwesome7IconsCache } from '@elementor/editor-controls';
2
8
  import { iconPropTypeUtil, stringPropTypeUtil } from '@elementor/editor-props';
3
9
 
@@ -41,6 +47,7 @@ async function resolveSavedIcon( iconClass: string, library: string ) {
41
47
 
42
48
  describe( 'iconTransformer', () => {
43
49
  const originalElementorCommon = window.elementorCommon;
50
+ const originalElementor = window.elementor;
44
51
 
45
52
  beforeEach( () => {
46
53
  jest.clearAllMocks();
@@ -63,6 +70,7 @@ describe( 'iconTransformer', () => {
63
70
 
64
71
  afterEach( () => {
65
72
  window.elementorCommon = originalElementorCommon;
73
+ window.elementor = originalElementor;
66
74
  jest.restoreAllMocks();
67
75
  } );
68
76
 
@@ -178,6 +186,37 @@ describe( 'iconTransformer', () => {
178
186
  expect( result ).toEqual( { html: null, url: null } );
179
187
  } );
180
188
 
189
+ it( 'falls back to the default svg when a custom library has been deleted', async () => {
190
+ // Arrange.
191
+ window.elementor = {
192
+ config: {
193
+ icons: {
194
+ libraries: [ { name: 'fa-solid', prefix: 'fa-', native: true } ],
195
+ },
196
+ },
197
+ } as typeof window.elementor;
198
+ const defaultSvg =
199
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50"><path d="M24.9999 4.31543"></path></svg>';
200
+ global.fetch = jest.fn().mockResolvedValue( {
201
+ ok: true,
202
+ headers: new Headers( { 'content-type': 'image/svg+xml' } ),
203
+ text: () => Promise.resolve( defaultSvg ),
204
+ } );
205
+
206
+ // Act.
207
+ const result = await resolveSavedIcon( 'missing-set missing-set-ghost', 'missing-set' );
208
+
209
+ // Assert.
210
+ expect( global.fetch ).toHaveBeenCalledWith(
211
+ 'https://example.com/assets/images/default-svg.svg',
212
+ expect.anything()
213
+ );
214
+ expect( result ).toEqual( {
215
+ html: expect.stringContaining( 'M24.9999 4.31543' ),
216
+ url: 'https://example.com/assets/images/default-svg.svg',
217
+ } );
218
+ } );
219
+
181
220
  it( 'returns null html when value or library is missing', async () => {
182
221
  // Act.
183
222
  const resolve = createPropsResolver( {
@@ -1,16 +1,20 @@
1
1
  import {
2
2
  type FontAwesome7IconDefinition,
3
3
  getFontAwesome7IconName,
4
+ isDeletedCustomIconLibrary,
5
+ resolveCustomIconSvg,
4
6
  resolveFontAwesome7Icon,
5
7
  } from '@elementor/editor-controls';
6
8
 
7
9
  import { createTransformer } from '../create-transformer';
8
10
  import type { TransformerOptions } from '../types';
9
11
  import { processSvgContent } from './process-svg-content';
12
+ import { svgSrcTransformer } from './svg-src-transformer';
10
13
 
11
14
  const EMPTY_ICON_RESULT = { html: null, url: null };
12
15
  const ICON_SVG_SIZE = '100%';
13
16
  const ICON_SVG_OVERFLOW = 'visible';
17
+ const DEFAULT_SVG_RELATIVE_PATH = 'images/default-svg.svg';
14
18
 
15
19
  type IconValue = {
16
20
  value?: unknown;
@@ -25,30 +29,70 @@ export const iconTransformer = createTransformer( async ( value: IconValue, { si
25
29
  return EMPTY_ICON_RESULT;
26
30
  }
27
31
 
28
- const iconName = getFontAwesome7IconName( iconValue );
32
+ const fontAwesomeHtml = await resolveFontAwesomeSvg( library, iconValue, signal );
29
33
 
30
- if ( ! iconName ) {
31
- return EMPTY_ICON_RESULT;
34
+ if ( fontAwesomeHtml ) {
35
+ return { html: fontAwesomeHtml, url: null };
32
36
  }
33
37
 
34
- const iconData = await resolveFontAwesome7Icon( library, iconName, signal );
38
+ if ( isDeletedCustomIconLibrary( library, iconValue ) ) {
39
+ return resolveDefaultSvg( signal );
40
+ }
35
41
 
36
- if ( ! iconData ) {
37
- return EMPTY_ICON_RESULT;
42
+ const customHtml = await resolveCustomSvg( library, iconValue, signal );
43
+
44
+ if ( customHtml ) {
45
+ return { html: customHtml, url: null };
38
46
  }
39
47
 
40
- const svgText = buildFontAwesomeSvg( iconData );
48
+ return EMPTY_ICON_RESULT;
49
+ } );
50
+
51
+ async function resolveDefaultSvg( signal?: AbortSignal ) {
52
+ const assetsUrl = window.elementorCommon?.config?.urls?.assets;
41
53
 
42
- if ( ! svgText ) {
54
+ if ( typeof assetsUrl !== 'string' || assetsUrl === '' ) {
43
55
  return EMPTY_ICON_RESULT;
44
56
  }
45
57
 
46
- const html = processIconSvgContent( svgText );
58
+ return svgSrcTransformer(
59
+ {
60
+ id: null,
61
+ url: `${ assetsUrl }${ DEFAULT_SVG_RELATIVE_PATH }`,
62
+ },
63
+ { key: 'svg', signal }
64
+ );
65
+ }
47
66
 
48
- return { html, url: null };
49
- } );
67
+ async function resolveCustomSvg( library: string, iconValue: string, signal?: AbortSignal ): Promise< string | null > {
68
+ const markup = await resolveCustomIconSvg( library, iconValue, signal );
69
+
70
+ return markup ? processIconSvgContent( markup ) : null;
71
+ }
72
+
73
+ async function resolveFontAwesomeSvg(
74
+ library: string,
75
+ iconValue: string,
76
+ signal?: AbortSignal
77
+ ): Promise< string | null > {
78
+ const iconName = getFontAwesome7IconName( iconValue );
79
+
80
+ if ( ! iconName ) {
81
+ return null;
82
+ }
83
+
84
+ const iconData = await resolveFontAwesome7Icon( library, iconName, signal );
85
+
86
+ if ( ! iconData ) {
87
+ return null;
88
+ }
89
+
90
+ const svgText = buildPathSvg( iconData );
91
+
92
+ return svgText ? processIconSvgContent( svgText ) : null;
93
+ }
50
94
 
51
- function buildFontAwesomeSvg( iconData: FontAwesome7IconDefinition ): string | null {
95
+ function buildPathSvg( iconData: FontAwesome7IconDefinition ): string | null {
52
96
  if ( iconData.paths.length === 0 ) {
53
97
  return null;
54
98
  }