@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.
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +310 -114
- package/dist/index.mjs +268 -69
- package/package.json +21 -21
- package/src/legacy/__tests__/build-editor-attributes.test.ts +41 -0
- package/src/legacy/__tests__/create-pending-element.test.ts +128 -0
- package/src/legacy/__tests__/create-templated-element-type.test.ts +50 -14
- package/src/legacy/create-nested-templated-element-type.ts +4 -2
- package/src/legacy/create-pending-element.ts +9 -5
- package/src/legacy/create-templated-element-type.ts +2 -1
- package/src/mcp/canvas-mcp.ts +5 -0
- package/src/mcp/icon-library-experiment.ts +5 -0
- package/src/mcp/tools/configure-element/prompt.ts +47 -1
- package/src/mcp/tools/find-icons/__tests__/tool.test.ts +143 -0
- package/src/mcp/tools/find-icons/prompt.ts +43 -0
- package/src/mcp/tools/find-icons/tool.ts +153 -0
- package/src/transformers/shared/__tests__/icon-transformer.test.ts +39 -0
- package/src/transformers/shared/icon-transformer.ts +56 -12
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createMockElement } from 'test-utils';
|
|
2
|
+
import { addModelToParent, findModelInDocument, type V1ElementModelProps } from '@elementor/editor-elements';
|
|
3
|
+
|
|
4
|
+
import { createPendingElement } from '../create-pending-element';
|
|
5
|
+
import { type ElementView } from '../types';
|
|
6
|
+
|
|
7
|
+
jest.mock( '@elementor/editor-elements', () => ( {
|
|
8
|
+
...jest.requireActual( '@elementor/editor-elements' ),
|
|
9
|
+
addModelToParent: jest.fn(),
|
|
10
|
+
findModelInDocument: jest.fn(),
|
|
11
|
+
getContainer: jest.fn(),
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
14
|
+
const mockAddModelToParent = jest.mocked( addModelToParent );
|
|
15
|
+
const mockFindModelInDocument = jest.mocked( findModelInDocument );
|
|
16
|
+
|
|
17
|
+
describe( 'createPendingElement', () => {
|
|
18
|
+
beforeEach( () => {
|
|
19
|
+
(
|
|
20
|
+
window as typeof window & {
|
|
21
|
+
elementorCommon?: { helpers?: { getUniqueId?: jest.Mock } };
|
|
22
|
+
}
|
|
23
|
+
).elementorCommon = {
|
|
24
|
+
helpers: {
|
|
25
|
+
getUniqueId: jest
|
|
26
|
+
.fn()
|
|
27
|
+
.mockReturnValueOnce( 'cloned-parent-id' )
|
|
28
|
+
.mockReturnValueOnce( 'cloned-child-id' ),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
mockAddModelToParent.mockClear();
|
|
33
|
+
mockFindModelInDocument.mockClear();
|
|
34
|
+
mockAddModelToParent.mockReturnValue( true );
|
|
35
|
+
mockFindModelInDocument.mockReturnValue( createMockElement( {} ) as never );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
afterEach( () => {
|
|
39
|
+
delete ( window as typeof window & { elementorCommon?: unknown } ).elementorCommon;
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'regenerates ids before pending clone insertion', () => {
|
|
43
|
+
// Arrange.
|
|
44
|
+
const wrapperView = {
|
|
45
|
+
getContainer: () => ( { id: 'parent-id' } ),
|
|
46
|
+
once: jest.fn(),
|
|
47
|
+
model: {
|
|
48
|
+
trigger: jest.fn(),
|
|
49
|
+
},
|
|
50
|
+
} as unknown as ElementView;
|
|
51
|
+
|
|
52
|
+
const source = {
|
|
53
|
+
id: 'original-parent-id',
|
|
54
|
+
elType: 'container',
|
|
55
|
+
settings: { _element_id: 'original-parent-element-id' },
|
|
56
|
+
elements: [
|
|
57
|
+
{
|
|
58
|
+
id: 'original-child-id',
|
|
59
|
+
elType: 'widget',
|
|
60
|
+
widgetType: 'button',
|
|
61
|
+
settings: { _element_id: 'original-child-element-id' },
|
|
62
|
+
elements: [],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
} as unknown as Partial< V1ElementModelProps >;
|
|
66
|
+
|
|
67
|
+
// Act.
|
|
68
|
+
const pendingElement = createPendingElement( wrapperView, source, { clone: true } );
|
|
69
|
+
|
|
70
|
+
// Assert.
|
|
71
|
+
expect( pendingElement?.getContainer().id ).toBe( 'cloned-parent-id' );
|
|
72
|
+
expect( mockAddModelToParent ).toHaveBeenCalledWith(
|
|
73
|
+
'parent-id',
|
|
74
|
+
expect.objectContaining( {
|
|
75
|
+
id: 'cloned-parent-id',
|
|
76
|
+
settings: expect.objectContaining( { _element_id: '' } ),
|
|
77
|
+
elements: [
|
|
78
|
+
expect.objectContaining( {
|
|
79
|
+
id: 'cloned-child-id',
|
|
80
|
+
settings: expect.objectContaining( { _element_id: '' } ),
|
|
81
|
+
} ),
|
|
82
|
+
],
|
|
83
|
+
} ),
|
|
84
|
+
expect.objectContaining( {
|
|
85
|
+
clone: false,
|
|
86
|
+
} )
|
|
87
|
+
);
|
|
88
|
+
expect( mockFindModelInDocument ).toHaveBeenCalledWith( 'cloned-parent-id' );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
it( 'preserves the source id when clone is false', () => {
|
|
92
|
+
// Arrange.
|
|
93
|
+
const wrapperView = {
|
|
94
|
+
getContainer: () => ( { id: 'parent-id' } ),
|
|
95
|
+
once: jest.fn(),
|
|
96
|
+
model: {
|
|
97
|
+
trigger: jest.fn(),
|
|
98
|
+
},
|
|
99
|
+
} as unknown as ElementView;
|
|
100
|
+
|
|
101
|
+
const source = {
|
|
102
|
+
id: 'original-parent-id',
|
|
103
|
+
elType: 'widget',
|
|
104
|
+
widgetType: 'button',
|
|
105
|
+
settings: { _element_id: 'original-parent-element-id' },
|
|
106
|
+
elements: [],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Act.
|
|
110
|
+
const pendingElement = createPendingElement( wrapperView, source, { clone: false } );
|
|
111
|
+
|
|
112
|
+
// Assert.
|
|
113
|
+
expect( pendingElement?.getContainer().id ).toBe( 'original-parent-id' );
|
|
114
|
+
expect( mockAddModelToParent ).toHaveBeenCalledWith(
|
|
115
|
+
'parent-id',
|
|
116
|
+
expect.objectContaining( {
|
|
117
|
+
id: 'original-parent-id',
|
|
118
|
+
settings: expect.objectContaining( {
|
|
119
|
+
_element_id: 'original-parent-element-id',
|
|
120
|
+
} ),
|
|
121
|
+
} ),
|
|
122
|
+
expect.objectContaining( {
|
|
123
|
+
clone: false,
|
|
124
|
+
} )
|
|
125
|
+
);
|
|
126
|
+
expect( mockFindModelInDocument ).toHaveBeenCalledWith( 'original-parent-id' );
|
|
127
|
+
} );
|
|
128
|
+
} );
|
|
@@ -121,20 +121,7 @@ describe( 'createTemplatedElementView', () => {
|
|
|
121
121
|
} );
|
|
122
122
|
|
|
123
123
|
describe( 'render context', () => {
|
|
124
|
-
|
|
125
|
-
// Arrange
|
|
126
|
-
const utils = createMockRenderer();
|
|
127
|
-
const ViewClass = createTemplatedElementView( {
|
|
128
|
-
type: MOCK_ELEMENT_TYPE,
|
|
129
|
-
renderer: utils,
|
|
130
|
-
element: {
|
|
131
|
-
...createMockElementConfig(),
|
|
132
|
-
twig_templates: {
|
|
133
|
-
main: '<{{ tag | e("html_tag") }}></{{ tag | e("html_tag") }}>',
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
} );
|
|
137
|
-
|
|
124
|
+
const createRenderableView = ( ViewClass: ReturnType< typeof createTemplatedElementView > ) => {
|
|
138
125
|
const view = new ViewClass() as unknown as ElementView & {
|
|
139
126
|
_abortController: AbortController;
|
|
140
127
|
};
|
|
@@ -159,12 +146,61 @@ describe( 'createTemplatedElementView', () => {
|
|
|
159
146
|
view._ensureViewIsIntact = jest.fn();
|
|
160
147
|
view.resetChildViewContainer = jest.fn();
|
|
161
148
|
|
|
149
|
+
return view;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
it( 'should inject computed tag into the twig render context', async () => {
|
|
153
|
+
// Arrange
|
|
154
|
+
const utils = createMockRenderer();
|
|
155
|
+
const ViewClass = createTemplatedElementView( {
|
|
156
|
+
type: MOCK_ELEMENT_TYPE,
|
|
157
|
+
renderer: utils,
|
|
158
|
+
element: {
|
|
159
|
+
...createMockElementConfig(),
|
|
160
|
+
twig_templates: {
|
|
161
|
+
main: '<{{ tag | e("html_tag") }}></{{ tag | e("html_tag") }}>',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
const view = createRenderableView( ViewClass );
|
|
167
|
+
|
|
162
168
|
// Act
|
|
163
169
|
await view._renderTemplate();
|
|
164
170
|
|
|
165
171
|
// Assert
|
|
166
172
|
expect( utils.render ).toHaveBeenCalledWith( 'main', expect.objectContaining( { tag: 'div' } ) );
|
|
167
173
|
} );
|
|
174
|
+
|
|
175
|
+
it( 'should merge element template_context into the twig render context without overriding core keys', async () => {
|
|
176
|
+
// Arrange
|
|
177
|
+
const utils = createMockRenderer();
|
|
178
|
+
const ViewClass = createTemplatedElementView( {
|
|
179
|
+
type: MOCK_ELEMENT_TYPE,
|
|
180
|
+
renderer: utils,
|
|
181
|
+
element: {
|
|
182
|
+
...createMockElementConfig(),
|
|
183
|
+
template_context: {
|
|
184
|
+
provider: { key: 'google', api_key: 'test-key' },
|
|
185
|
+
id: 'should-not-override',
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
const view = createRenderableView( ViewClass );
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
await view._renderTemplate();
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expect( utils.render ).toHaveBeenCalledWith(
|
|
197
|
+
'main',
|
|
198
|
+
expect.objectContaining( {
|
|
199
|
+
id: 'test-id',
|
|
200
|
+
provider: { key: 'google', api_key: 'test-key' },
|
|
201
|
+
} )
|
|
202
|
+
);
|
|
203
|
+
} );
|
|
168
204
|
} );
|
|
169
205
|
} );
|
|
170
206
|
|
|
@@ -71,15 +71,17 @@ export function createNestedTemplatedElementType( {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function buildEditorAttributes( model: ElementView[ 'model' ] ): string {
|
|
74
|
+
export function buildEditorAttributes( model: ElementView[ 'model' ] ): string {
|
|
75
75
|
const id = model.get( 'id' );
|
|
76
76
|
const originId = model.get( 'originId' );
|
|
77
77
|
const cid = model.cid ?? '';
|
|
78
|
+
const isDecorative = model.get( 'editor_settings' )?.decorative === true;
|
|
78
79
|
|
|
79
80
|
const attrs: Record< string, string > = {
|
|
80
81
|
'data-model-cid': cid,
|
|
81
82
|
'data-interaction-id': originId ?? id,
|
|
82
83
|
'x-ignore': 'true',
|
|
84
|
+
...( isDecorative ? { 'data-e-decorative': 'true' } : {} ),
|
|
83
85
|
};
|
|
84
86
|
|
|
85
87
|
return Object.entries( attrs )
|
|
@@ -420,7 +422,7 @@ export function createNestedTemplatedElementView( {
|
|
|
420
422
|
this._doAfterRender( () => parentOpenEditingPanel.call( this, options ) );
|
|
421
423
|
},
|
|
422
424
|
|
|
423
|
-
addElement( data: Partial< V1ElementModelProps >, options?: { edit?: boolean; at?: number } ) {
|
|
425
|
+
addElement( data: Partial< V1ElementModelProps >, options?: { edit?: boolean; at?: number; clone?: boolean } ) {
|
|
424
426
|
if ( this.isRendered ) {
|
|
425
427
|
return parentAddElement.call( this, data, options );
|
|
426
428
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
addModelToParent,
|
|
3
|
+
cloneElementTree,
|
|
3
4
|
findModelInDocument,
|
|
4
5
|
generateElementId,
|
|
5
6
|
getContainer,
|
|
@@ -12,12 +13,12 @@ import { type ElementView } from './types';
|
|
|
12
13
|
export function createPendingElement(
|
|
13
14
|
wrapperView: ElementView,
|
|
14
15
|
data: Partial< V1ElementModelProps >,
|
|
15
|
-
options: { edit?: boolean; at?: number } = {}
|
|
16
|
+
options: { edit?: boolean; at?: number; clone?: boolean } = {}
|
|
16
17
|
): { getContainer: () => V1Element } | undefined {
|
|
17
18
|
const parentContainer = wrapperView.getContainer();
|
|
18
|
-
const model: Partial< V1ElementModelProps > = { ...data };
|
|
19
|
+
const model: Partial< V1ElementModelProps > = options.clone ? cloneElementTree( data ) : { ...data };
|
|
19
20
|
|
|
20
|
-
if ( ! model.id ) {
|
|
21
|
+
if ( ! options.clone && ! model.id ) {
|
|
21
22
|
model.id = generateElementId();
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -25,13 +26,16 @@ export function createPendingElement(
|
|
|
25
26
|
model.elements = [];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const added = addModelToParent( parentContainer.id, model as V1ElementModelProps,
|
|
29
|
+
const added = addModelToParent( parentContainer.id, model as V1ElementModelProps, {
|
|
30
|
+
...options,
|
|
31
|
+
clone: false,
|
|
32
|
+
} );
|
|
29
33
|
|
|
30
34
|
if ( ! added ) {
|
|
31
35
|
return undefined;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const childId = model.id;
|
|
38
|
+
const childId = model.id as string;
|
|
35
39
|
const childModel = findModelInDocument( childId );
|
|
36
40
|
|
|
37
41
|
if ( ! childModel ) {
|
|
@@ -29,7 +29,7 @@ export type CreateTemplatedElementTypeOptions = {
|
|
|
29
29
|
export type TemplatedElementConfig = Required<
|
|
30
30
|
Pick< V1ElementConfig, 'twig_templates' | 'twig_main_template' | 'atomic_props_schema' | 'base_styles_dictionary' >
|
|
31
31
|
> &
|
|
32
|
-
Pick< V1ElementConfig, 'default_html_tag' | 'html_tag_follows_link' >;
|
|
32
|
+
Pick< V1ElementConfig, 'default_html_tag' | 'html_tag_follows_link' | 'template_context' >;
|
|
33
33
|
|
|
34
34
|
export function createTemplatedElementType( {
|
|
35
35
|
type,
|
|
@@ -163,6 +163,7 @@ export function createTemplatedElementView( {
|
|
|
163
163
|
this._lastResolvedSettingsHash = settingsHash;
|
|
164
164
|
|
|
165
165
|
const context = {
|
|
166
|
+
...( element.template_context ?? {} ),
|
|
166
167
|
id: this.model.get( 'id' ),
|
|
167
168
|
interaction_id: this.getInteractionId(),
|
|
168
169
|
type,
|
package/src/mcp/canvas-mcp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type MCPRegistryEntry } from '@elementor/editor-mcp';
|
|
2
2
|
import { Schema } from '@elementor/editor-props';
|
|
3
3
|
|
|
4
|
+
import { isIconLibraryActive } from './icon-library-experiment';
|
|
4
5
|
import { initAvailableWidgetsResource } from './resources/available-widgets-resource';
|
|
5
6
|
import { initBestPracticesResource } from './resources/best-practices-resource';
|
|
6
7
|
import { initBreakpointsResource } from './resources/breakpoints-resource';
|
|
@@ -11,6 +12,7 @@ import { initGeneralContextResource } from './resources/general-context-resource
|
|
|
11
12
|
import { initSelectedElementResource } from './resources/selected-element-resource';
|
|
12
13
|
import { initWidgetsSchemaResource } from './resources/widgets-schema-resource';
|
|
13
14
|
import { initConfigureElementTool } from './tools/configure-element/tool';
|
|
15
|
+
import { initFindIconsTool } from './tools/find-icons/tool';
|
|
14
16
|
import { initGetPageStructureTool } from './tools/get-page-structure/tool';
|
|
15
17
|
import { getDynamicTagNamesByCategories } from './utils/resolve-dynamic-tag';
|
|
16
18
|
|
|
@@ -28,6 +30,9 @@ export const initCanvasMcp = ( reg: MCPRegistryEntry ) => {
|
|
|
28
30
|
initGeneralContextResource( reg );
|
|
29
31
|
initBestPracticesResource( reg );
|
|
30
32
|
initConfigureElementTool( reg );
|
|
33
|
+
if ( isIconLibraryActive() ) {
|
|
34
|
+
initFindIconsTool( reg );
|
|
35
|
+
}
|
|
31
36
|
initGetPageStructureTool( reg );
|
|
32
37
|
initBreakpointsResource( reg );
|
|
33
38
|
};
|
|
@@ -1,11 +1,52 @@
|
|
|
1
1
|
import { toolPrompts } from '@elementor/editor-mcp';
|
|
2
2
|
|
|
3
|
+
import { isIconLibraryActive } from '../../icon-library-experiment';
|
|
3
4
|
import { DYNAMIC_TAGS_URI } from '../../resources/dynamic-tags-resource';
|
|
4
5
|
import { WIDGET_SCHEMA_URI } from '../../resources/widgets-schema-resource';
|
|
5
6
|
|
|
7
|
+
const ICONS_SECTION = `# Icons (the \`svg\` property on e-svg)
|
|
8
|
+
The \`svg\` property accepts two variants. Prefer a library icon for iconography (carts, arrows, social marks, check marks) — it needs no upload and inherits \`color\` via \`currentColor\`. Use an uploaded file only for custom artwork such as a brand logo.
|
|
9
|
+
|
|
10
|
+
Resolve the icon with the \`find-icons\` tool FIRST, then copy its \`value\` and \`library\` verbatim. Never assemble those strings yourself: Font Awesome renamed many icons between major versions (\`shopping-cart\` became \`cart-shopping\`), so a plausible guess renders nothing. \`find-icons\` matches on names, aliases, human search terms, and categories, so describe the intent — "add to cart" finds \`cart-shopping\`. It takes a batch of \`queries\`, so resolve every icon you need in one call.
|
|
11
|
+
|
|
12
|
+
Library icon (note the nested PropValues — \`value\` and \`library\` are each a \`string\` PropValue):
|
|
13
|
+
{ "$$type": "icon", "value": { "value": { "$$type": "string", "value": "fa-solid fa-cart-shopping" }, "library": { "$$type": "string", "value": "fa-solid" } } }
|
|
14
|
+
|
|
15
|
+
Uploaded SVG file:
|
|
16
|
+
{ "$$type": "svg-src", "value": { "id": { "$$type": "image-attachment-id", "value": 123 } } }
|
|
17
|
+
|
|
18
|
+
Size and colour an e-svg through \`style\` (\`width\`, \`height\`, \`color\`), never through \`propertiesToChange\`.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const ICON_EXAMPLE = `
|
|
22
|
+
Setting a Font Awesome icon on an e-svg, after resolving it with \`find-icons\`:
|
|
23
|
+
|
|
24
|
+
\`\`\`json
|
|
25
|
+
{
|
|
26
|
+
propertiesToChange: {
|
|
27
|
+
svg: {
|
|
28
|
+
$$type: 'icon',
|
|
29
|
+
value: {
|
|
30
|
+
value: { $$type: 'string', value: 'fa-solid fa-cart-shopping' },
|
|
31
|
+
library: { $$type: 'string', value: 'fa-solid' }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
style: {
|
|
36
|
+
'width': '1.25rem',
|
|
37
|
+
'height': '1.25rem',
|
|
38
|
+
'color': '#1a1a1a'
|
|
39
|
+
},
|
|
40
|
+
elementId: 'element-id',
|
|
41
|
+
elementType: 'e-svg'
|
|
42
|
+
};
|
|
43
|
+
\`\`\`
|
|
44
|
+
`;
|
|
45
|
+
|
|
6
46
|
export const CONFIGURE_ELEMENT_GUIDE_URI = 'elementor://canvas/tools/configure-element-guide';
|
|
7
47
|
|
|
8
48
|
export const generatePrompt = () => {
|
|
49
|
+
const iconLibraryActive = isIconLibraryActive();
|
|
9
50
|
const configureElementToolPrompt = toolPrompts( 'configure-element' );
|
|
10
51
|
|
|
11
52
|
configureElementToolPrompt.description( `
|
|
@@ -64,7 +105,8 @@ Put the dynamic object EXACTLY at the node whose schema offers the "dynamic" var
|
|
|
64
105
|
}
|
|
65
106
|
Image example: { "$$type": "image", "value": { "src": { "$$type": "dynamic", "value": { "name": "<image tag>", "settings": { ... } } } } }
|
|
66
107
|
Do NOT send "group" (it is resolved automatically). Use { "settings": {} } only when the tag has no settings.
|
|
67
|
-
|
|
108
|
+
|
|
109
|
+
${ iconLibraryActive ? ICONS_SECTION : '' }` );
|
|
68
110
|
|
|
69
111
|
configureElementToolPrompt.parameter( 'elementId', 'The ID of the element to configure. MANDATORY.' );
|
|
70
112
|
|
|
@@ -107,6 +149,10 @@ Do NOT send "group" (it is resolved automatically). Use { "settings": {} } only
|
|
|
107
149
|
\`\`\`
|
|
108
150
|
` );
|
|
109
151
|
|
|
152
|
+
if ( iconLibraryActive ) {
|
|
153
|
+
configureElementToolPrompt.example( ICON_EXAMPLE );
|
|
154
|
+
}
|
|
155
|
+
|
|
110
156
|
configureElementToolPrompt.instruction(
|
|
111
157
|
'The $$type property is MANDATORY for every value in propertiesToChange; it is not used in the style parameter (raw CSS only).'
|
|
112
158
|
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { httpService } from '@elementor/http-client';
|
|
2
|
+
|
|
3
|
+
import { initFindIconsTool } from '../tool';
|
|
4
|
+
|
|
5
|
+
jest.mock( '@elementor/http-client', () => ( {
|
|
6
|
+
httpService: jest.fn(),
|
|
7
|
+
AxiosError: class AxiosError extends Error {},
|
|
8
|
+
} ) );
|
|
9
|
+
|
|
10
|
+
const mockedHttpService = httpService as jest.MockedFunction< typeof httpService >;
|
|
11
|
+
|
|
12
|
+
type ToolConfig = {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
schema: Record< string, unknown >;
|
|
16
|
+
handler: ( input: Record< string, unknown > ) => Promise< Record< string, unknown > >;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const captureTool = (): ToolConfig => {
|
|
20
|
+
const addTool = jest.fn();
|
|
21
|
+
initFindIconsTool( { addTool } as never );
|
|
22
|
+
return addTool.mock.calls[ 0 ][ 0 ] as ToolConfig;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const serverResponse = {
|
|
26
|
+
results: [
|
|
27
|
+
{
|
|
28
|
+
query: 'add to cart',
|
|
29
|
+
matches: [
|
|
30
|
+
{
|
|
31
|
+
value: 'fa-solid fa-cart-plus',
|
|
32
|
+
library: 'fa-solid',
|
|
33
|
+
name: 'cart-plus',
|
|
34
|
+
label: 'Cart Plus',
|
|
35
|
+
matched_on: 'term',
|
|
36
|
+
license: 'free',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
total: 1,
|
|
40
|
+
page: 1,
|
|
41
|
+
per_page: 10,
|
|
42
|
+
truncated: false,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
libraries: [ 'fa-brands', 'fa-regular', 'fa-solid' ],
|
|
46
|
+
categories: [ 'shopping' ],
|
|
47
|
+
font_awesome_version: '7.3.1',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const givenServerResponse = ( data: Record< string, unknown > ) => {
|
|
51
|
+
const post = jest.fn().mockResolvedValue( { data: { data } } );
|
|
52
|
+
mockedHttpService.mockReturnValue( { post } as never );
|
|
53
|
+
return post;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe( 'find-icons tool', () => {
|
|
57
|
+
beforeEach( () => {
|
|
58
|
+
jest.clearAllMocks();
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'proxies a batch of queries to the php ability', async () => {
|
|
62
|
+
// Arrange
|
|
63
|
+
const post = givenServerResponse( serverResponse );
|
|
64
|
+
const { handler } = captureTool();
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
await handler( { queries: [ 'add to cart', 'free shipping' ] } );
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect( post ).toHaveBeenCalledWith( 'elementor/v1/mcp-proxy', {
|
|
71
|
+
tool: 'find-icons',
|
|
72
|
+
input: { queries: [ 'add to cart', 'free shipping' ] },
|
|
73
|
+
} );
|
|
74
|
+
} );
|
|
75
|
+
|
|
76
|
+
it( 'maps per-page to the snake-cased ability input', async () => {
|
|
77
|
+
// Arrange
|
|
78
|
+
const post = givenServerResponse( serverResponse );
|
|
79
|
+
const { handler } = captureTool();
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
await handler( {
|
|
83
|
+
queries: [ 'cart' ],
|
|
84
|
+
library: 'fa-solid',
|
|
85
|
+
category: 'shopping',
|
|
86
|
+
page: 2,
|
|
87
|
+
perPage: 25,
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect( post ).toHaveBeenCalledWith( 'elementor/v1/mcp-proxy', {
|
|
92
|
+
tool: 'find-icons',
|
|
93
|
+
input: {
|
|
94
|
+
queries: [ 'cart' ],
|
|
95
|
+
library: 'fa-solid',
|
|
96
|
+
category: 'shopping',
|
|
97
|
+
page: 2,
|
|
98
|
+
per_page: 25,
|
|
99
|
+
},
|
|
100
|
+
} );
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
it( 'returns the writable icon pair and the site catalog facets', async () => {
|
|
104
|
+
// Arrange
|
|
105
|
+
givenServerResponse( serverResponse );
|
|
106
|
+
const { handler } = captureTool();
|
|
107
|
+
|
|
108
|
+
// Act
|
|
109
|
+
const result = await handler( { queries: [ 'add to cart' ] } );
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect( result.results ).toEqual( serverResponse.results );
|
|
113
|
+
expect( result.libraries ).toEqual( serverResponse.libraries );
|
|
114
|
+
expect( result.categories ).toEqual( serverResponse.categories );
|
|
115
|
+
expect( result.fontAwesomeVersion ).toBe( '7.3.1' );
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
it( 'omits facets the server did not send', async () => {
|
|
119
|
+
// Arrange
|
|
120
|
+
givenServerResponse( { results: serverResponse.results, font_awesome_version: '7.3.1' } );
|
|
121
|
+
const { handler } = captureTool();
|
|
122
|
+
|
|
123
|
+
// Act
|
|
124
|
+
const result = await handler( { queries: [ 'add to cart' ] } );
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect( result ).not.toHaveProperty( 'libraries' );
|
|
128
|
+
expect( result ).not.toHaveProperty( 'customLibraries' );
|
|
129
|
+
expect( result ).not.toHaveProperty( 'categories' );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
it( 'surfaces a server failure as a tool error', async () => {
|
|
133
|
+
// Arrange
|
|
134
|
+
const post = jest.fn().mockRejectedValue( new Error( 'The icon catalog is missing on this site.' ) );
|
|
135
|
+
mockedHttpService.mockReturnValue( { post } as never );
|
|
136
|
+
const { handler } = captureTool();
|
|
137
|
+
|
|
138
|
+
// Act & Assert
|
|
139
|
+
await expect( handler( { queries: [ 'cart' ] } ) ).rejects.toThrow(
|
|
140
|
+
'The icon catalog is missing on this site.'
|
|
141
|
+
);
|
|
142
|
+
} );
|
|
143
|
+
} );
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { toolPrompts } from '@elementor/editor-mcp';
|
|
2
|
+
|
|
3
|
+
export const findIconsToolPrompt = toolPrompts( 'find-icons' );
|
|
4
|
+
|
|
5
|
+
findIconsToolPrompt.description(
|
|
6
|
+
'Finds icons for the `svg` property of an `e-svg` element, across Font Awesome and any icon packs uploaded to this site. ' +
|
|
7
|
+
'Returns the exact `value` and `library` pair to write - never assemble those strings yourself, and never guess an icon name. ' +
|
|
8
|
+
'Call this before placing or configuring an `e-svg`. Nothing has to be uploaded to the Media Library for an icon to work.'
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
findIconsToolPrompt.instruction(
|
|
12
|
+
'Call it when the user names an icon ("use the cart-shopping icon") and take the first exact `name` match, ' +
|
|
13
|
+
'and also when the user describes a page and you decide icons belong in it ("create a product page") - ' +
|
|
14
|
+
'in that case pass one query per icon you intend to place, in a single call.'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
findIconsToolPrompt.instruction(
|
|
18
|
+
'Write queries the way a person would describe the icon ("shopping cart", "credit card", "phone"). Icons are matched on ' +
|
|
19
|
+
'their name, aliases, Font Awesome search terms and category, so plain language works better than guessed icon names.'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
findIconsToolPrompt.instruction(
|
|
23
|
+
'Results are ordered best first; `matched_on: "name"` is a stronger signal than `matched_on: "term"`. ' +
|
|
24
|
+
"Check each result's `license` and only use `free` icons; skip any `pro` entry. " +
|
|
25
|
+
'Prefer `fa-solid` for UI iconography and keep one style across a page; brand marks only exist in `fa-brands`. ' +
|
|
26
|
+
'An empty `matches` array means no match was found: broaden the phrase instead of inventing a value.'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
findIconsToolPrompt.instruction(
|
|
30
|
+
'Pass the chosen pair to `configure-element` as the `svg` property: ' +
|
|
31
|
+
'{ "$$type": "icon", "value": { "value": { "$$type": "string", "value": "fa-solid fa-cart-shopping" }, ' +
|
|
32
|
+
'"library": { "$$type": "string", "value": "fa-solid" } } }. ' +
|
|
33
|
+
'An `e-svg` has no colour or size of its own, so set `width`, `height` and `color` in the same `configure-element` call.'
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
findIconsToolPrompt.example( `Icon named by the user:
|
|
37
|
+
{ "queries": ["cart-shopping"] }` );
|
|
38
|
+
|
|
39
|
+
findIconsToolPrompt.example( `Every icon a product page needs, in one call:
|
|
40
|
+
{ "queries": ["add to cart", "free shipping", "secure payment", "instagram"] }` );
|
|
41
|
+
|
|
42
|
+
findIconsToolPrompt.example( `Browse a category, one style only:
|
|
43
|
+
{ "category": "shopping", "library": "fa-solid", "perPage": 25 }` );
|