Build Extensions for Salesforce CMS
Reference: Component Properties and JSON Structures
Sample Marketing Extension: Tone Checker
Display Salesforce CMS Content
Display CMS Content in Experience Builder Sites
In Marketing Cloud Next, build extensions that are compatible with marketing content types and components to boost productivity. Marketers can use extensions to draft, revise, or customize the entire content body or individual components within a marketing asset.
You can build extensions that work with any third-party tool. This topic explains how to build an extension that integrates with an external AI service to generate or refine marketing copy. This process involves developing an Apex controller with a secure callout and a Lightning web component (LWC) that uses specific metadata to target the CMS content editor UI.
You can connect this type of extension to any third-party generative AI API, such as Gemini or ChatGPT. In this topic, the extension is connected to Gemini.
In the content builder, extensions work best with non-personalized content—content that doesn’t include merge fields or dynamic content variations. We recommend that content authors add merge fields or create dynamic variations after they use an extension.
Most marketing content types support extensions except for expressions and form handlers. Forms support extensions only for components within the content body, and not for fields.
In this documentation, “components” and “blocks” refer to the same concept. What are called “components” in the marketing content builder UI are “blocks” in the code.
Note
For this extension, we use named credentials for secure external API callouts to make sure that your API key isn’t exposed in code. The name of the credential in the Apex Controller is GeminiNC. For information and instructions about configuring a named credential, see Named Credentials in the Apex Developer Guide.
Create an Apex Controller to securely connect the extension to the Gemini API. Set up the callout by using the named credential. The callout abstracts the API interaction to ensure security. See Apex Server-Side Controller Overview in the Lightning Aura Components Developer Guide.
In the Apex controller, you can transform the generative AI output to make sure that it’s compatible with structured marketing content. For example, to build a fully styled Paragraph component with your extension, use the Apex controller to map the AI response directly into the JSON schema of a paragraph component. This allows the extension to return a complete, editable component rather than text only. For information about component JSON schemas, see Reference: Component Properties and JSON Structures.
This sample Apex controller contains the secure callout, and it connects the extension to a generative AI service, in this case, Gemini. It can return a fully structured marketing email including a subject line, preheader, HTML, and structured components or blocks.
1public with sharing class EmailAIAssistant {
2
3 // System prompt for generating email blocks in CNAVS structure format
4 private static final String EMAIL_BLOCKS_SYSTEM_PROMPT =
5 'You are an expert email content generator. Generate structured email blocks based on the user\'s request. ' +
6 'You MUST respond with a valid JSON object containing a subjectLine and an array of blocks.\n\n' +
7 'Response format:\n' +
8 '{\n' +
9 ' "subjectLine": "A compelling email subject line (max 60 characters)",\n' +
10 ' "blocks": [ array of block objects ]\n' +
11 '}\n\n' +
12 'Available block types and their exact structure:\n\n' +
13 ' add text color ,textDecorationLine, fontsize; and padding as required so that the email blocks look good and professional\n'+
14 '1. HEADING block (for h1, h2, h3, etc.):\n' +
15 '{\n' +
16 ' "attributes": {\n' +
17 ' "align": "left",\n' +
18 ' "lightning:colorGroup": {\n' +
19 ' "textColor": "add hex code for color to look good and professional"\n' +
20 ' },\n' +
21 ' "lightning:padding": {\n'+
22 ' "bottom": {\n' +
23 ' "unit": "px",\n' +
24 ' "value": 16.0 \n' +
25 ' },\n' +
26 ' "left": {\n' +
27 ' "unit": "px",\n'+
28 ' "value": 16.0\n' +
29 ' },\n' +
30 ' "right": {\n' +
31 ' "unit": "px",\n' +
32 ' "value": 16.0\n' +
33 ' },\n' +
34 ' "top": {\n'+
35 ' "unit": "px",\n' +
36 ' "value": 16.0\n' +
37 ' }\n' +
38 ' }\n' +
39
40 ' "lightning:typography": {\n' +
41 ' "fontFamily": "{!$brand.fontFamily.arial}",\n' +
42 ' "fontSize": {\n' +
43 ' "unit": "px",\n' +
44 ' "value": 18.0\n' +
45 ' },\n' +
46 ' "fontWeight": "{!$brand.fontWeight.bold}",\n' +
47 ' "letterSpacing": "normal",\n' +
48 ' "lineHeight": 1.5,\n' +
49 ' "textDecoration": {\n' +
50 ' "textDecorationLine": [\n' +
51 ' "underline"\n' +
52 ' ]\n' +
53 ' },\n' +
54 ' "textTransform": "none"\n' +
55 ' }\n'+
56 ' "text": "YOUR HEADING TEXT HERE"\n' +
57 ' },\n' +
58 ' "definition": "lightning/heading",\n' +
59 ' "type": "block"\n' +
60 '}\n\n' +
61 '2. PARAGRAPH block:\n' +
62 '{\n' +
63 ' "attributes": {\n' +
64 ' "align": "left",\n' +
65 ' "lightning:colorGroup": {\n' +
66 ' "textColor": "add hex code for color to look good and professional"\n' +
67 ' },\n' +
68 ' "lightning:padding": {\n'+
69 ' "bottom": {\n' +
70 ' "unit": "px",\n' +
71 ' "value": 16.0 \n' +
72 ' },\n' +
73 ' "left": {\n' +
74 ' "unit": "px",\n'+
75 ' "value": 16.0\n' +
76 ' },\n' +
77 ' "right": {\n' +
78 ' "unit": "px",\n' +
79 ' "value": 16.0\n' +
80 ' },\n' +
81 ' "top": {\n'+
82 ' "unit": "px",\n' +
83 ' "value": 16.0\n' +
84 ' }\n' +
85 ' },\n' +
86
87 ' "lightning:typography": {\n' +
88 ' "fontFamily": "{!$brand.fontFamily.arial}",\n' +
89 ' "fontSize": {\n' +
90 ' "unit": "px",\n' +
91 ' "value": 18.0\n' +
92 ' },\n' +
93 ' "fontWeight": "{!$brand.fontWeight.bold}",\n' +
94 ' "letterSpacing": "normal",\n' +
95 ' "lineHeight": 1.5,\n' +
96 ' "textDecoration": {\n' +
97 ' "textDecorationLine": [\n' +
98 ' "underline"\n' +
99 ' ]\n' +
100 ' },\n' +
101 ' "textTransform": "none"\n' +
102 ' },\n'+
103 ' "text": "YOUR PARAGRAPH TEXT HERE"\n' +
104 ' },\n' +
105 ' "definition": "lightning/paragraph",\n' +
106 ' "type": "block"\n' +
107 '}\n\n' +
108'3. HTML block (lightning__html) for custom images, links, and stylized text:\n' +
109 '{\n' +
110 ' "type": "block",\n' +
111 ' "definition": "lightning/html",\n' +
112 ' "attributes": {\n' +
113 ' "rawHtml": "The text body of the HTML component containing HTML code",\n' +
114 ' "lightning:colorGroup": {\n' +
115 ' "backgroundColor": "{!$brand.colorScheme.root}",\n' +
116 ' "textColor": "{!$brand.colorScheme.contrast}",\n' +
117 ' "linkColor": "{!$brand.colorScheme.primaryAccent}",\n' +
118 ' "borderColor": "{!$brand.colorScheme.neutral}"\n' +
119 ' },\n' +
120 ' "lightning:padding": "{!$brand.spacing.none}",\n' +
121 ' "lightning:margin": "{!$brand.spacing.none}",\n' +
122 ' "lightning:borderRadius": "{!$brand.borderRadius.square}",\n' +
123 ' "lightning:borderWidth": "{!$brand.borderWeight.none}"\n' +
124 ' }\n' +
125 '}\n\n' +
126 'Rules:\n' +
127 '1. Use "rawHtml" for the content of the HTML block. \n' +
128 '2. Ensure rawHtml contains valid, email-safe HTML code (images, links, styled spans). \n' +
129 '3. Keep text content engaging and professional.\n' +
130 '4. IMPORTANT: Return ONLY the JSON object, no markdown code blocks, no explanations.\n\n';
131
132 /**
133 * Generate email blocks (heading, paragraph, list) using Gemini AI
134 * Returns structured blocks that can be injected into CNAVS email structure
135 * @param prompt - The user's prompt describing what email content to generate
136 * @return String - JSON containing subjectLine and blocks array
137 */
138 @AuraEnabled
139 public static String generateEmailBlocks(String prompt) {
140 try {
141 if (String.isBlank(prompt)) {
142 throw new AuraHandledException('Prompt is required');
143 }
144
145 // Combine system prompt with user prompt
146 String fullPrompt = EMAIL_BLOCKS_SYSTEM_PROMPT + 'User Request:\n' + prompt;
147
148 String result = callGeminiAPI(fullPrompt, 8192);
149 // Clean up any markdown code blocks if present
150 return cleanHtmlResponse(result);
151
152 } catch (Exception e) {
153 System.debug('Error in generateEmailBlocks: ' + e.getMessage());
154 throw new AuraHandledException('Error generating email blocks: ' + e.getMessage());
155 }
156 }
157
158 /**
159 * Call Gemini AI API to generate content
160 * @param prompt - The prompt text to send
161 * @param maxOutputTokens - Maximum number of tokens for the response
162 * @return String - The generated text from Gemini AI
163 */
164 private static String callGeminiAPI(String prompt, Integer maxOutputTokens) {
165 try {
166 // Build Gemini API request structure
167 Map<String, Object> geminiRequest = new Map<String, Object>{
168 'contents' => new List<Object>{
169 new Map<String, Object>{
170 'parts' => new List<Object>{
171 new Map<String, Object>{
172 'text' => prompt
173 }
174 }
175 }
176 },
177 'generationConfig' => new Map<String, Object>{
178 'temperature' => 0.7,
179 'maxOutputTokens' => maxOutputTokens
180 }
181 };
182
183 // Create HTTP request
184 HttpRequest req = new HttpRequest();
185 req.setEndpoint('callout:GeminiNC');
186 req.setHeader('Content-Type', 'application/json');
187 req.setMethod('POST');
188 // Increase timeout to 60 or 120 seconds
189 req.setTimeout(120000);
190
191 // Set request body
192 String requestBody = JSON.serialize(geminiRequest);
193 req.setBody(requestBody);
194
195 System.debug('Gemini API Request Body: ' + requestBody);
196
197 // Make the callout
198 Http http = new Http();
199 HttpResponse res = http.send(req);
200
201 System.debug('Response Status Code: ' + res.getStatusCode());
202 System.debug('Response Body: ' + res.getBody());
203
204 // Handle response
205 Integer statusCode = res.getStatusCode();
206
207 if (statusCode == 200) {
208 return parseGeminiResponse(res.getBody());
209 } else if (statusCode == 429) {
210 // Rate limit exceeded - parse error for details
211 String errorMessage = parseErrorResponse(res.getBody());
212 throw new CalloutException('Rate limit exceeded. ' + errorMessage + ' Please wait a moment and try again.');
213 } else if (statusCode == 400) {
214 String errorMessage = parseErrorResponse(res.getBody());
215 throw new CalloutException('Bad request: ' + errorMessage);
216 } else if (statusCode == 401 || statusCode == 403) {
217 throw new CalloutException('Authentication failed. Please check your Gemini API key configuration.');
218 } else {
219 String errorMessage = parseErrorResponse(res.getBody());
220 throw new CalloutException('API error (Status ' + statusCode + '): ' + errorMessage);
221 }
222
223 } catch (Exception e) {
224 System.debug('Error calling Gemini API: ' + e.getMessage());
225 throw new CalloutException('Gemini API Error: ' + e.getMessage());
226 }
227 }
228
229 /**
230 * Parse the Gemini API response and extract the generated text
231 * Expected response structure:
232 * {
233 * "candidates": [{
234 * "content": {
235 * "parts": [{ "text": "generated text" }]
236 * }
237 * }]
238 * }
239 * @param responseBody - The raw JSON response from Gemini API
240 * @return String - The extracted generated text
241 */
242 private static String parseGeminiResponse(String responseBody) {
243 Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
244
245 if (responseMap.containsKey('candidates')) {
246 List<Object> candidates = (List<Object>) responseMap.get('candidates');
247
248 if (candidates != null && !candidates.isEmpty()) {
249 Map<String, Object> firstCandidate = (Map<String, Object>) candidates[0];
250
251 if (firstCandidate.containsKey('content')) {
252 Map<String, Object> content = (Map<String, Object>) firstCandidate.get('content');
253
254 if (content.containsKey('parts')) {
255 List<Object> parts = (List<Object>) content.get('parts');
256
257 if (parts != null && !parts.isEmpty()) {
258 Map<String, Object> firstPart = (Map<String, Object>) parts[0];
259
260 if (firstPart.containsKey('text')) {
261 String generatedText = (String) firstPart.get('text');
262 System.debug('Successfully extracted text from Gemini response');
263 return generatedText;
264 }
265 }
266 }
267 }
268 }
269 }
270
271 throw new CalloutException('Unable to extract text from Gemini response. Unexpected structure.');
272 }
273
274 /**
275 * Clean up the response by removing markdown code blocks if present
276 * @param response - The raw response text
277 * @return String - Cleaned content
278 */
279 private static String cleanHtmlResponse(String response) {
280 if (String.isBlank(response)) {
281 return response;
282 }
283
284 String cleaned = response.trim();
285
286 // Remove markdown code blocks (```json ... ```, ```html ... ```, or ``` ... ```)
287 if (cleaned.startsWith('```json')) {
288 cleaned = cleaned.removeStart('```json').trim();
289 } else if (cleaned.startsWith('```html')) {
290 cleaned = cleaned.removeStart('```html').trim();
291 } else if (cleaned.startsWith('```')) {
292 cleaned = cleaned.removeStart('```').trim();
293 }
294
295 // Also handle case where just "json" prefix exists (without backticks)
296 if (cleaned.startsWith('json\n')) {
297 cleaned = cleaned.removeStart('json').trim();
298 }
299
300 if (cleaned.endsWith('```')) {
301 cleaned = cleaned.removeEnd('```').trim();
302 }
303
304 return cleaned;
305 }
306
307 /**
308 * Parse error response from Gemini API
309 * Expected error structure:
310 * {
311 * "error": {
312 * "code": 429,
313 * "message": "Resource has been exhausted...",
314 * "status": "RESOURCE_EXHAUSTED"
315 * }
316 * }
317 * @param responseBody - The raw JSON error response
318 * @return String - The extracted error message
319 */
320 private static String parseErrorResponse(String responseBody) {
321 try {
322 Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(responseBody);
323
324 if (responseMap.containsKey('error')) {
325 Map<String, Object> errorObj = (Map<String, Object>) responseMap.get('error');
326
327 if (errorObj.containsKey('message')) {
328 return (String) errorObj.get('message');
329 }
330
331 if (errorObj.containsKey('status')) {
332 return (String) errorObj.get('status');
333 }
334 }
335
336 return responseBody;
337 } catch (Exception ex) {
338 return responseBody;
339 }
340 }
341}To make your extension visible in the marketing content builder, set targets and targetConfigs in the extension’s configuration or .js-meta.xml file. The primary target is lightning__CmsEditorExtension, which makes the extension appear in the extensions menu in the CMS content builder.
Set the targetConfig to lightning__CmsEditorExtension and set the height and width attributes for the extension’s floating panel. This table shows possible values for the height and width attributes.
Height and Width Attributes
| Attribute | Type | Description | Default Value |
|---|---|---|---|
| width | enum | Enter small, medium, large, or x-large. The semantic values correspond to these pixel values. small = 240 px medium = 320 px large = 400 px x-large = 640 px | medium |
| height | number | Enter a value between 200 px and 600 px. | 400 px |
An extension with this target configuration is visible to all CMS and marketing content types that support extensions. For a basic code sample, see Build Extensions for Salesforce CMS.
To make your extension available only to a specific marketing content type, such as an email or landing page, specify the contentTypes under targetconfig for lightning__CmsEditorExtension. This allows your extension to interact with the entire canvas and any other properties of the content type that you’re targeting, such as an email’s subject line and preheader. Set the contentType fullyQualifiedName to the fully qualified name (FQN) of the content type that you want to target. To target multiple content types, list multiple content types under targetconfig. You can target both marketing and non-marketing content types.
Marketing Content Fully Qualified Names
This table contains the FQNs of only the marketing content types that support extensions.
| Content Type | Fully Qualified Name (FQN) |
|---|---|
| Audio | sfdc_cms__audio |
| Brand | sfdc_cms__brand |
| Content Block: Email | sfdc_cms__emailFragment |
| Content Block: Landing Page | sfdc_cms__webFragment |
| Document | sfdc_cms__document |
sfdc_cms__email | |
| Email Template | sfdc_cms__emailTemplate |
| Form | sfdc_cms__form |
| Image | sfdc_cms__image |
| In-App Message | sfdc_cms__inApp |
| Landing Page | sfdc_cms__landingPage |
| Landing Page Template | sfdc_cms__landingPageTemplate |
| SMS Message | sfdc_cms__sms |
| Tracked Link | sfdc_cms__trackedLink |
| WhatsApp Session | sfdc_cms__whatsappSession |
| Video | sfdc_cms__video |
This example shows the configuration file of a generative AI extension that’s available only to email content. When opened, the extension appears in a 640x600 px floating panel within the email builder.
1<?xml version="1.0" encoding="UTF-8"?>
2<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
3 <apiVersion>66.0</apiVersion>
4 <isExposed>true</isExposed>
5 <masterLabel>Email AI Assistant</masterLabel>
6 <description>Generate structured email blocks (h1, paragraph, ul/li) using Gemini AI that can be injected to the canvas.</description>
7 <targets>
8 <target>lightning__CmsEditorExtension</target>
9 </targets>
10 <targetConfigs>
11 <targetConfig targets="lightning__CmsEditorExtension">
12 <size width="x-large" height="600"></size>
13 <contentTypes>
14 <contentType fullyQualifiedName="sfdc_cms__email">
15 </contentType>
16 </contentTypes>
17 </targetConfig>
18 </targetConfigs>
19</LightningComponentBundle>To make your extension available only to a specific component, specify the blockTypes under targetconfig for lightning__CmsEditorExtension. To make an extension available to all supported component types, don’t target the extension to any specific components.
To target the extension to a component type, set the blockType fullyQualifiedName to the fully qualified name (FQN) of the component you want to target. For example, to target the AI content generator to a paragraph component, set the blockType fullyQualifiedName to lightning__paragraph. To target multiple component types, list multiple components under the targetconfig or, if you’re also targeting a specific content type, list the components under the content type.
Not all components are available in all content types. If you target the extension to both a component type and a content type, make sure that the component is available in the specified content type. For example, you can’t target a paragraph component in an image content type.
Note
If you specify a component type, but not a content type, your extension is available to the component in all content types that support it. For example, if you target a heading component without specifying any content types, your extension is available to heading components in emails, landing pages, content blocks, templates, and forms.
Fully Qualified Names of Components in Marketing Content
This table contains the FQNs only of the component types that support extensions.
| Component | Fully Qualified Name (FQN) |
|---|---|
| Heading | lightning__heading |
| HTML | lightning__html |
| Image | lightning__image |
| List | lightning__list |
| Paragraph | lightning__paragraph |
| Button | lightning__button |
| Divider | lightning__divider |
| Section | lightning__section |
| Column | lightning__column |
This example shows the configuration file of a generative AI extension that’s available only to paragraph components in the email and landing page content types.
1<?xml version="1.0" encoding="UTF-8"?>
2<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
3 <apiVersion>66.0</apiVersion>
4 <isExposed>true</isExposed>
5 <masterLabel>Email AI Assistant</masterLabel>
6 <targets>
7 <target>lightning__CmsEditorExtension</target>
8 </targets>
9 <targetConfigs>
10 <targetConfig targets="lightning__CmsEditorExtension">
11 <size height="400" width="large"></size>
12 <contentTypes>
13 <contentType fullyQualifiedName="sfdc_cms__email">
14 <blockTypes>
15 <blockType fullyQualifiedName="lightning__paragraph"></blockType>
16 </blockTypes>
17 </contentType>
18 <contentType fullyQualifiedName="sfdc_cms__landingPage">
19 <blockTypes>
20 <blockType fullyQualifiedName="lightning__paragraph"></blockType>
21 </blockTypes>
22 </contentType>
23 </contentTypes>
24 </targetConfig>
25 </targetConfigs>
26</LightningComponentBundle>The HTML file uses standard LWC markup to display the extension’s user interface (UI) in the floating panel. This UI is where the marketer interacts with the extension to generate content.
In this sample, the Email AI Assistant extension UI includes an input area for an AI prompt, a button to generate content, a loading spinner, interactive controls, and two displays of the AI output.
1<template>
2 <div class="slds-card">
3 <div class="slds-form">
4 <div class="header-section">
5 <h2 class="slds-text-heading_medium">Email AI Assistant</h2>
6 <span class="gemini-badge">Powered by Google</span>
7 </div>
8
9 <p class="description">
10 Generate structured email blocks (headings, paragraphs, lists) using AI.
11 </p>
12
13 <!-- Prompt input -->
14 <div class="slds-m-top_medium">
15 <div class="section-label slds-m-bottom_x-small">Your Prompt</div>
16 <lightning-textarea
17 name="promptInput"
18 label=""
19 value="{promptText}"
20 placeholder="Describe the email content you want. E.g., 'Create a welcome email with a heading, introduction paragraph, and a list of 3 key features.'"
21 max-length="2000"
22 onchange="{handlePromptChange}"
23 >
24 </lightning-textarea>
25 </div>
26
27 <!-- Generate button -->
28 <div class="slds-m-top_medium slds-align_absolute-center">
29 <lightning-button
30 variant="brand"
31 label="Generate Email"
32 icon-name="utility:sparkles"
33 onclick="{handleGenerate}"
34 disabled="{isGenerateDisabled}"
35 >
36 </lightning-button>
37 </div>
38
39 <!-- Loading spinner -->
40 <template lwc:if="{isLoading}">
41 <div class="slds-m-top_medium slds-align_absolute-center">
42 <lightning-spinner
43 alternative-text="Generating email..."
44 size="medium"
45 ></lightning-spinner>
46 </div>
47 </template>
48
49 <!-- Generated content container -->
50 <template lwc:if="{hasGeneratedContent}">
51 <!-- Subject Line -->
52 <div class="email-field slds-m-top_medium">
53 <div class="section-label">Subject Line</div>
54 <div class="field-value">{subjectLine}</div>
55 </div>
56
57 <!-- Email Blocks -->
58 <div class="content-container slds-m-top_medium">
59 <div class="content-header">
60 <div class="section-label">Email Content</div>
61 <lightning-button
62 variant="base"
63 label="{previewButtonLabel}"
64 icon-name="{previewButtonIcon}"
65 onclick="{handleTogglePreview}"
66 >
67 </lightning-button>
68 </div>
69
70 <!-- Preview mode -->
71 <template lwc:if="{showPreview}">
72 <div class="preview-area">
73 <template for:each="{previewBlocks}" for:item="block">
74 <div key="{block.key}" class="preview-block">
75 <template lwc:if="{block.isHeading}">
76 <div class="preview-heading">{block.text}</div>
77 </template>
78 <template lwc:if="{block.isParagraph}">
79 <div class="preview-paragraph">{block.text}</div>
80 </template>
81 <template lwc:if="{block.isList}">
82 <ul class="preview-list">
83 <template for:each="{block.items}" for:item="item">
84 <li key="{item.key}">{item.text}</li>
85 </template>
86 </ul>
87 </template>
88 </div>
89 </template>
90 </div>
91 </template>
92
93 <!-- JSON mode -->
94 <template lwc:else>
95 <div class="code-area">
96 <pre>{generatedBlocksJson}</pre>
97 </div>
98 </template>
99 </div>
100
101 <!-- Action buttons -->
102 <div class="slds-m-top_medium button-group">
103 <lightning-button
104 label="Try again"
105 icon-name="utility:refresh"
106 onclick="{handleTryAgain}"
107 class="slds-m-right_small"
108 >
109 </lightning-button>
110 <lightning-button
111 variant="brand"
112 label="Place on Canvas"
113 icon-name="utility:check"
114 onclick="{handleReplace}"
115 disabled="{isReplaceDisabled}"
116 >
117 </lightning-button>
118 </div>
119 </template>
120 </div>
121 </div>
122</template>The CSS file is optional. Lightning web components automatically inherit Salesforce Lightning Design System (SLDS) styles, but you can use a CSS file if you want custom styling within your extension.
Here’s an example CSS file for the Email AI Assistant extension.
1.slds-card {
2 padding: 1rem;
3 background: linear-gradient(135deg, #f8f9fa 0%, #e8f0fe 100%);
4 border-radius: 0.5rem;
5}
6
7.header-section {
8 display: flex;
9 align-items: center;
10 justify-content: space-between;
11 margin-bottom: 0.5rem;
12}
13
14.slds-text-heading_medium {
15 font-size: 1.25rem;
16 font-weight: 700;
17 color: #1a73e8;
18 margin: 0;
19}
20
21.gemini-badge {
22 font-size: 0.7rem;
23 font-weight: 500;
24 color: #5f6368;
25 background: linear-gradient(90deg, #4285f4, #ea4335, #fbbc04, #34a853);
26 background-clip: text;
27 -webkit-background-clip: text;
28 -webkit-text-fill-color: transparent;
29 padding: 0.25rem 0.5rem;
30 border: 1px solid #dadce0;
31 border-radius: 1rem;
32}
33
34.description {
35 font-size: 0.85rem;
36 color: #5f6368;
37 margin: 0.5rem 0 0 0;
38}
39
40.section-label {
41 font-size: 0.75rem;
42 font-weight: 600;
43 color: #1a73e8;
44 text-transform: uppercase;
45 letter-spacing: 0.05em;
46 margin-bottom: 0.5rem;
47}
48
49.email-field {
50 border: 1px solid #dadce0;
51 border-radius: 0.5rem;
52 padding: 0.75rem;
53 background-color: #ffffff;
54}
55
56.email-field .section-label {
57 margin-bottom: 0.25rem;
58}
59
60.field-value {
61 font-size: 0.9rem;
62 color: #3c4043;
63 line-height: 1.4;
64}
65
66.content-container {
67 border: 1px solid #1a73e8;
68 border-radius: 0.5rem;
69 padding: 1rem;
70 background-color: #ffffff;
71 box-shadow: 0 2px 8px rgba(26, 115, 232, 0.15);
72}
73
74.content-header {
75 display: flex;
76 justify-content: space-between;
77 align-items: center;
78 margin-bottom: 0.75rem;
79}
80
81.content-header .section-label {
82 margin-bottom: 0;
83}
84
85.preview-area {
86 font-size: 0.875rem;
87 line-height: 1.6;
88 color: #3c4043;
89 max-height: 300px;
90 overflow-y: auto;
91 padding: 0.75rem;
92 background-color: #fafafa;
93 border: 1px solid #e0e0e0;
94 border-radius: 0.25rem;
95}
96
97.preview-block {
98 margin-bottom: 0.75rem;
99}
100
101.preview-block:last-child {
102 margin-bottom: 0;
103}
104
105.preview-heading {
106 font-size: 1.25rem;
107 font-weight: 700;
108 color: #1a1a1a;
109 margin-bottom: 0.5rem;
110 padding-bottom: 0.5rem;
111 border-bottom: 2px solid #1a73e8;
112}
113
114.preview-paragraph {
115 font-size: 0.9rem;
116 color: #3c4043;
117 line-height: 1.6;
118}
119
120.preview-list {
121 margin: 0;
122 padding-left: 1.5rem;
123}
124
125.preview-list li {
126 font-size: 0.9rem;
127 color: #3c4043;
128 line-height: 1.8;
129 margin-bottom: 0.25rem;
130}
131
132.preview-list li:last-child {
133 margin-bottom: 0;
134}
135
136.code-area {
137 max-height: 300px;
138 overflow: auto;
139 background-color: #1e1e1e;
140 border-radius: 0.25rem;
141 padding: 0.75rem;
142}
143
144.code-area pre {
145 margin: 0;
146 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
147 font-size: 0.75rem;
148 line-height: 1.5;
149 color: #d4d4d4;
150 white-space: pre-wrap;
151 word-wrap: break-word;
152}
153
154.button-group {
155 display: flex;
156 justify-content: center;
157 gap: 0.5rem;
158}
159
160/* Style the textarea for better UX */
161lightning-textarea {
162 --slds-c-textarea-sizing-min-height: 100px;
163}The JavaScript file defines the business logic of the extension and event handling. It can use experience/cmsEditorApi methods to read and write content in an entire marketing asset, or it can use experience/blockBuilderApi methods to read and write content within a selected component on the canvas.
The JavaScript file also manages the integration with the Apex controller to send the user’s prompt to the generative AI model and maps the resulting data into a format compatible with marketing content.
This is a sample JavaScript of the Email AI Assistant extension. It integrates with experience/cmsEditorApi methods and methods from the Apex controller. It takes the user input from the extension UI, sends it to the generative AI model, and programmatically updates the email to insert styled components onto the email canvas.
This sample also includes code that manages the state of the extension by using the actionstart and actiondone standard Salesforce CMS events. These events are useful to make sure that marketers don’t accidentally exit the extension before they’re finished using it.
For an example of JavaScript that integrates with experience/blockBuilderApi methods to edit content within components, see Sample Marketing Extension: Tone Checker.
1import { LightningElement, wire } from "lwc";
2import { getContent, updateContent } from "experience/cmsEditorApi";
3import generateEmailBlocks from "@salesforce/apex/EmailAIAssistant.generateEmailBlocks";
4
5/**
6 * Editor Extension component that generates email block tree using Gemini AI
7 * Generates h1, paragraph, ul/li blocks that can be injected to the canvas
8 */
9export default class EmailAIAssistant extends LightningElement {
10 contentBody = {};
11 rootBlockId;
12 sectionId;
13 columnId;
14
15 @wire(getContent, {})
16 onContent({ data }) {
17 if (data) {
18 console.log("Content Data", data);
19 this.contentBody = data.contentBody;
20 const rootBlock = data.contentBody["sfdc_cms:block"];
21 this.rootBlockId = rootBlock.id;
22
23 // Extract section and column IDs for block injection
24 if (rootBlock.children && rootBlock.children.length > 0) {
25 const section = rootBlock.children[0];
26 this.sectionId = section.id;
27 if (section.children && section.children.length > 0) {
28 this.columnId = section.children[0].id;
29 }
30 }
31 }
32 }
33
34 promptText = "";
35 generatedBlocks = null;
36 generatedBlocksJson = "";
37 subjectLine = "";
38 isLoading;
39 showPreview;
40
41 get isGenerateDisabled() {
42 return !this.promptText || this.promptText.trim() === "" || this.isLoading;
43 }
44
45 get isReplaceDisabled() {
46 return !this.generatedBlocks || this.isLoading;
47 }
48
49 get hasGeneratedContent() {
50 return this.generatedBlocks !== null;
51 }
52
53 get previewButtonLabel() {
54 return this.showPreview ? "Show JSON" : "Show Preview";
55 }
56
57 get previewButtonIcon() {
58 return this.showPreview ? "utility:code" : "utility:preview";
59 }
60
61 get previewBlocks() {
62 if (!this.generatedBlocks) return [];
63 return this.generatedBlocks.map((block, index) => this.mapBlockToPreview(block, index));
64 }
65
66 mapBlockToPreview(block, index) {
67 const baseKey = `block-${index}`;
68
69 if (block.definition === "lightning/heading") {
70 return {
71 key: baseKey,
72 isHeading: true,
73 text: block.attributes.text || "",
74 level: block.attributes.level || 1,
75 };
76 } else if (block.definition === "lightning/paragraph") {
77 return {
78 key: baseKey,
79 isParagraph: true,
80 text: block.attributes.text || "",
81 };
82 } else if (block.definition === "lightning/list") {
83 return {
84 key: baseKey,
85 isList: true,
86 items: (block.attributes.items || []).map((item, i) => ({
87 key: `${baseKey}-item-${i}`,
88 text: item,
89 })),
90 };
91 }
92 return { key: baseKey };
93 }
94
95 handlePromptChange(event) {
96 this.promptText = event.target.value;
97 }
98
99 handleGenerate() {
100 this.generateContent();
101 }
102
103 handleTryAgain() {
104 this.generateContent();
105 }
106
107 handleTogglePreview() {
108 this.showPreview = !this.showPreview;
109 }
110
111 handleReplace() {
112 let _contentBody = JSON.parse(JSON.stringify(this.contentBody));
113
114 // Get the column where we'll inject the blocks
115 const rootBlock = _contentBody["sfdc_cms:block"];
116 if (rootBlock.children && rootBlock.children.length > 0) {
117 const section = rootBlock.children[0];
118 if (section.children && section.children.length > 0) {
119 const column = section.children[0];
120 // Replace existing children with generated blocks
121 column.children = this.generatedBlocks; //this.generatedBlocksJson;
122 }
123 }
124 _contentBody["subjectLine"] = this.subjectLine;
125 updateContent({
126 contentBody: _contentBody,
127 })
128 .then(() => {
129 console.log("Email blocks injected successfully");
130 // It is safe to exit the extension now, hence dispatch the actiondone event
131 this.dispatchEvent(
132 new CustomEvent("actiondone", {
133 detail: { closeExtension: true },
134 }),
135 );
136 })
137 .catch((error) => {
138 console.error("Error updating content:", error);
139 });
140 }
141
142 async generateContent() {
143 if (!this.promptText || this.promptText.trim() === "") {
144 return;
145 }
146
147 this.isLoading = true;
148 this.generatedBlocks = null;
149 this.generatedBlocksJson = "";
150 this.subjectLine = "";
151 this.showPreview = true;
152
153 try {
154 // Call Apex method to generate email blocks
155 const generatedContent = await generateEmailBlocks({
156 prompt: this.promptText,
157 });
158
159 // Parse the JSON response
160 const parsedContent = JSON.parse(generatedContent);
161 this.subjectLine = parsedContent.subjectLine || "";
162 this.generatedBlocks = parsedContent.blocks || [];
163 this.generatedBlocksJson = JSON.stringify(this.generatedBlocks, null, 2);
164
165 console.log("Email blocks generated successfully");
166 } catch (error) {
167 console.error("Error generating email blocks:", error.message);
168 this.generatedBlocks = null;
169 this.generatedBlocksJson = "";
170 this.subjectLine = "";
171 } finally {
172 this.isLoading = false;
173 // The user has made changes that are not yet saved or finalized.
174 // Send actionstart event to prevent accidental loss of in-progress work
175 this.dispatchEvent(new CustomEvent("actionstart"));
176 }
177 }
178}