Skip to content

Commit 63d5d9f

Browse files
Merge pull request #6 from svycal/ap-521
Add conditional blocks (sc-if) with extensions system
2 parents e06266c + a2a9fca commit 63d5d9f

File tree

15 files changed

+406
-27
lines changed

15 files changed

+406
-27
lines changed

.changeset/late-ends-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@savvycal/mjml-editor': minor
3+
---
4+
5+
Add conditional blocks extension

packages/mjml-editor/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ function App() {
9292
| `className` | `string` | Optional CSS class for the container |
9393
| `defaultTheme` | `'light' \| 'dark' \| 'system'` | Theme preference (default: `'system'`) |
9494
| `liquidSchema` | `LiquidSchema` | Optional schema for Liquid template autocomplete |
95+
| `extensions` | `EditorExtensions` | Optional extensions for custom features beyond standard MJML |
9596
| `applyThemeToDocument` | `boolean` | Whether to apply theme class to `document.documentElement`. Needed for dropdown/popover theming. Set to `false` if your app manages document-level theme classes. (default: `true`) |
9697

9798
## Liquid Template Support
@@ -129,6 +130,65 @@ function App() {
129130

130131
When editing text content, typing `{{` will trigger variable autocomplete and `{%` will trigger tag autocomplete.
131132

133+
## Extensions
134+
135+
Extensions provide opt-in features beyond standard MJML. All extensions are disabled by default to maintain compatibility with stock MJML.
136+
137+
```tsx
138+
import { MjmlEditor, type EditorExtensions } from '@savvycal/mjml-editor';
139+
140+
function App() {
141+
const [mjml, setMjml] = useState(initialMjml);
142+
143+
return (
144+
<MjmlEditor
145+
value={mjml}
146+
onChange={setMjml}
147+
extensions={{
148+
conditionalBlocks: true,
149+
}}
150+
/>
151+
);
152+
}
153+
```
154+
155+
### Available Extensions
156+
157+
#### `conditionalBlocks`
158+
159+
Enables the `sc-if` attribute for server-side conditional rendering using Liquid expressions.
160+
161+
When enabled:
162+
- A "Condition (Liquid)" field appears in the Advanced section of the inspector for all block types
163+
- Blocks with conditions display an "if" badge indicator in both the canvas and outline tree
164+
- The Advanced section auto-expands when a block has a condition
165+
166+
**How it works:**
167+
- The `sc-if` attribute is preserved in the MJML output for server-side processing
168+
- The attribute is stripped from preview rendering to avoid MJML validation warnings
169+
- Your server processes the Liquid condition and conditionally renders the block
170+
171+
**Example MJML output:**
172+
173+
```xml
174+
<mj-section sc-if="event.is_recurring">
175+
<mj-column>
176+
<mj-text>This section only appears for recurring events.</mj-text>
177+
</mj-column>
178+
</mj-section>
179+
```
180+
181+
**Server-side processing example (Ruby/Liquid):**
182+
183+
```ruby
184+
# Before sending, wrap sc-if blocks with Liquid conditionals
185+
mjml = mjml.gsub(/<(mj-\w+)([^>]*)\ssc-if="([^"]+)"([^>]*)>/) do
186+
tag, before, condition, after = $1, $2, $3, $4
187+
"{% if #{condition} %}<#{tag}#{before}#{after}>"
188+
end
189+
# Don't forget to add closing {% endif %} tags as well
190+
```
191+
132192
## Exported Types
133193

134194
The library exports TypeScript types for integration:
@@ -138,11 +198,20 @@ import type {
138198
MjmlNode, // MJML document node structure
139199
MjmlTagName, // Union of supported MJML tag names
140200
ContentBlockType, // Union of content block types
201+
EditorExtensions, // Extensions configuration
141202
LiquidSchema, // Schema for Liquid autocomplete
142203
LiquidSchemaItem, // Individual variable/tag definition
143204
} from '@savvycal/mjml-editor';
144205
```
145206

207+
### EditorExtensions
208+
209+
```typescript
210+
interface EditorExtensions {
211+
conditionalBlocks?: boolean; // Enable sc-if attribute for conditional rendering
212+
}
213+
```
214+
146215
### LiquidSchema
147216

148217
```typescript

packages/mjml-editor/src/components/editor/BlockInspector.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useState } from 'react';
1+
import { useState, useEffect } from 'react';
22
import { ChevronRight, X, PanelRightClose } from 'lucide-react';
33
import { useEditor } from '@/context/EditorContext';
4+
import { useExtensions } from '@/context/ExtensionsContext';
45
import { ScrollArea } from '@/components/ui/scroll-area';
56
import { Button } from '@/components/ui/button';
67
import { Label } from '@/components/ui/label';
@@ -18,7 +19,7 @@ import {
1819
SelectTrigger,
1920
SelectValue,
2021
} from '@/components/ui/select';
21-
import { getSchemaForTag } from '@/lib/mjml/schema';
22+
import { getSchemaForTag, filterSchemaByExtensions } from '@/lib/mjml/schema';
2223
import {
2324
parseClassNames,
2425
addClassToNode,
@@ -131,6 +132,7 @@ interface BlockInspectorProps {
131132
export function BlockInspector({ onTogglePanel }: BlockInspectorProps) {
132133
const { selectedBlock, updateAttributes, getInheritedValue, definedClasses } =
133134
useEditor();
135+
const extensions = useExtensions();
134136

135137
if (!selectedBlock) {
136138
return (
@@ -162,7 +164,10 @@ export function BlockInspector({ onTogglePanel }: BlockInspectorProps) {
162164
);
163165
}
164166

165-
const schema = getSchemaForTag(selectedBlock.tagName);
167+
const schema = filterSchemaByExtensions(
168+
getSchemaForTag(selectedBlock.tagName),
169+
extensions
170+
);
166171
const tagLabel =
167172
selectedBlock.tagName.replace('mj-', '').charAt(0).toUpperCase() +
168173
selectedBlock.tagName.replace('mj-', '').slice(1);
@@ -223,6 +228,12 @@ export function BlockInspector({ onTogglePanel }: BlockInspectorProps) {
223228
groupedAttributes={groupedAttributes}
224229
selectedBlock={selectedBlock}
225230
handleAttributeChange={handleAttributeChange}
231+
defaultOpenGroups={
232+
extensions.conditionalBlocks &&
233+
selectedBlock.attributes['sc-if']
234+
? { advanced: true }
235+
: {}
236+
}
226237
/>
227238
) : (
228239
<div className="space-y-5">
@@ -253,16 +264,25 @@ interface GroupedAttributeEditorProps {
253264
groupedAttributes: Record<string, { key: string; schema: AttributeSchema }[]>;
254265
selectedBlock: MjmlNode;
255266
handleAttributeChange: (key: string, value: string) => void;
267+
defaultOpenGroups?: Record<string, boolean>;
256268
}
257269

258270
function GroupedAttributeEditor({
259271
groupedAttributes,
260272
selectedBlock,
261273
handleAttributeChange,
274+
defaultOpenGroups = {},
262275
}: GroupedAttributeEditorProps) {
263-
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
276+
const [openGroups, setOpenGroups] =
277+
useState<Record<string, boolean>>(defaultOpenGroups);
264278
const { getInheritedValue } = useEditor();
265279

280+
// Reset open groups when selected block changes
281+
useEffect(() => {
282+
setOpenGroups(defaultOpenGroups);
283+
// eslint-disable-next-line react-hooks/exhaustive-deps -- Reset only when block changes
284+
}, [selectedBlock._id]);
285+
266286
const toggleGroup = (group: string) => {
267287
setOpenGroups((prev) => ({ ...prev, [group]: !prev[group] }));
268288
};

packages/mjml-editor/src/components/editor/MjmlEditor.tsx

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useState, useRef } from 'react';
22
import { EditorProvider, useEditor } from '@/context/EditorContext';
33
import { ThemeProvider, useTheme } from '@/context/ThemeContext';
44
import { LiquidSchemaProvider } from '@/context/LiquidSchemaContext';
5+
import { ExtensionsProvider } from '@/context/ExtensionsContext';
56
import { OutlineTree, GLOBAL_STYLES_ID } from './OutlineTree';
67
import { EditorCanvas, type EditorTabType } from './EditorCanvas';
78
import { BlockInspector } from './BlockInspector';
@@ -12,7 +13,7 @@ import {
1213
serializeMjml,
1314
createEmptyDocument,
1415
} from '@/lib/mjml/parser';
15-
import type { MjmlNode } from '@/types/mjml';
16+
import type { MjmlNode, EditorExtensions } from '@/types/mjml';
1617
import type { LiquidSchema } from '@/types/liquid';
1718

1819
function parseInitialValue(value: string): MjmlNode {
@@ -33,6 +34,22 @@ interface MjmlEditorProps {
3334
className?: string;
3435
defaultTheme?: 'light' | 'dark' | 'system';
3536
liquidSchema?: LiquidSchema;
37+
/**
38+
* Enable optional editor extensions.
39+
* Extensions provide opt-in features beyond standard MJML.
40+
*
41+
* Available extensions:
42+
* - `conditionalBlocks`: Enable `sc-if` attribute for server-side conditional rendering
43+
*
44+
* @example
45+
* ```tsx
46+
* <MjmlEditor
47+
* extensions={{ conditionalBlocks: true }}
48+
* // ...
49+
* />
50+
* ```
51+
*/
52+
extensions?: EditorExtensions;
3653
/**
3754
* Whether to apply the theme class to document.documentElement.
3855
* This is needed for Radix UI portals (popovers, menus, etc.) which
@@ -229,6 +246,7 @@ export function MjmlEditor({
229246
className,
230247
defaultTheme = 'system',
231248
liquidSchema,
249+
extensions,
232250
applyThemeToDocument = true,
233251
showThemeToggle = true,
234252
defaultLeftPanelOpen = true,
@@ -263,18 +281,20 @@ export function MjmlEditor({
263281
defaultTheme={defaultTheme}
264282
applyToDocument={applyThemeToDocument}
265283
>
266-
<LiquidSchemaProvider schema={liquidSchema}>
267-
<ThemedEditorWrapper className={className}>
268-
<EditorProvider initialDocument={initialDocument}>
269-
<EditorContent
270-
onChange={handleChange}
271-
showThemeToggle={showThemeToggle}
272-
defaultLeftPanelOpen={defaultLeftPanelOpen}
273-
defaultRightPanelOpen={defaultRightPanelOpen}
274-
/>
275-
</EditorProvider>
276-
</ThemedEditorWrapper>
277-
</LiquidSchemaProvider>
284+
<ExtensionsProvider extensions={extensions}>
285+
<LiquidSchemaProvider schema={liquidSchema}>
286+
<ThemedEditorWrapper className={className}>
287+
<EditorProvider initialDocument={initialDocument}>
288+
<EditorContent
289+
onChange={handleChange}
290+
showThemeToggle={showThemeToggle}
291+
defaultLeftPanelOpen={defaultLeftPanelOpen}
292+
defaultRightPanelOpen={defaultRightPanelOpen}
293+
/>
294+
</EditorProvider>
295+
</ThemedEditorWrapper>
296+
</LiquidSchemaProvider>
297+
</ExtensionsProvider>
278298
</ThemeProvider>
279299
);
280300
}

packages/mjml-editor/src/components/editor/OutlineTree.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
PanelLeftClose,
2323
} from 'lucide-react';
2424
import { useEditor } from '@/context/EditorContext';
25+
import { useExtensions } from '@/context/ExtensionsContext';
2526
import { Button } from '@/components/ui/button';
2627
import {
2728
Popover,
@@ -94,13 +95,19 @@ function canAddColumns(tagName: string): boolean {
9495
// Custom node renderer for the tree
9596
function TreeNode({ node, style, dragHandle }: NodeRendererProps<MjmlNode>) {
9697
const { state, selectBlock, deleteBlock, addBlock, addColumn } = useEditor();
98+
const extensions = useExtensions();
9799
const [isAddOpen, setIsAddOpen] = useState(false);
98100

99101
const data = node.data;
100102
const isSelected = state.selectedBlockId === data._id;
101103
const hasChildren = node.children && node.children.length > 0;
102104
const showExpandButton = canHaveChildren(data.tagName);
103105

106+
// Only show condition indicator if extension is enabled
107+
const condition = extensions.conditionalBlocks
108+
? data.attributes['sc-if']
109+
: undefined;
110+
104111
const handleSelect = (e: React.MouseEvent) => {
105112
e.stopPropagation();
106113
node.select();
@@ -174,6 +181,16 @@ function TreeNode({ node, style, dragHandle }: NodeRendererProps<MjmlNode>) {
174181
{getDisplayName(data.tagName)}
175182
</span>
176183

184+
{/* Conditional indicator */}
185+
{condition && (
186+
<span
187+
className="text-[10px] px-1 py-0.5 bg-amber-500 text-white rounded font-mono mr-1"
188+
title={`Condition: ${condition}`}
189+
>
190+
if
191+
</span>
192+
)}
193+
177194
{/* Action buttons - visible on hover */}
178195
<div
179196
className={cn(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
interface ConditionalIndicatorProps {
2+
condition?: string;
3+
}
4+
5+
export function ConditionalIndicator({ condition }: ConditionalIndicatorProps) {
6+
if (!condition) return null;
7+
8+
return (
9+
<div
10+
className="absolute top-1 right-1 z-10 bg-amber-500 text-white text-xs px-1.5 py-0.5 rounded font-mono"
11+
title={`Condition: ${condition}`}
12+
>
13+
if
14+
</div>
15+
);
16+
}

packages/mjml-editor/src/components/editor/visual-blocks/VisualBlock.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import type { MjmlNode } from '@/types/mjml';
2+
import { useExtensions } from '@/context/ExtensionsContext';
23
import { VisualText } from './VisualText';
34
import { VisualImage } from './VisualImage';
45
import { VisualButton } from './VisualButton';
56
import { VisualDivider } from './VisualDivider';
67
import { VisualSpacer } from './VisualSpacer';
78
import { VisualSocial } from './VisualSocial';
89
import { VisualRaw } from './VisualRaw';
10+
import { ConditionalIndicator } from './ConditionalIndicator';
911

1012
interface VisualBlockProps {
1113
node: MjmlNode;
1214
}
1315

14-
export function VisualBlock({ node }: VisualBlockProps) {
16+
function renderBlock(node: MjmlNode) {
1517
switch (node.tagName) {
1618
case 'mj-text':
1719
return <VisualText node={node} />;
@@ -35,3 +37,17 @@ export function VisualBlock({ node }: VisualBlockProps) {
3537
);
3638
}
3739
}
40+
41+
export function VisualBlock({ node }: VisualBlockProps) {
42+
const extensions = useExtensions();
43+
const condition = extensions.conditionalBlocks
44+
? node.attributes['sc-if']
45+
: undefined;
46+
47+
return (
48+
<div className="relative">
49+
<ConditionalIndicator condition={condition} />
50+
{renderBlock(node)}
51+
</div>
52+
);
53+
}

packages/mjml-editor/src/components/editor/visual-blocks/VisualColumn.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useEditor } from '@/context/EditorContext';
2+
import { useExtensions } from '@/context/ExtensionsContext';
23
import { VisualBlock } from './VisualBlock';
4+
import { ConditionalIndicator } from './ConditionalIndicator';
35
import { cn } from '@/lib/utils';
46
import type { MjmlNode } from '@/types/mjml';
57
import { buildPadding } from './helpers';
@@ -12,9 +14,13 @@ interface VisualColumnProps {
1214

1315
export function VisualColumn({ node, totalColumns }: VisualColumnProps) {
1416
const { state, selectBlock } = useEditor();
17+
const extensions = useExtensions();
1518
const isSelected = state.selectedBlockId === node._id;
1619
const attrs = useResolvedAttributes(node);
1720

21+
// Only show condition indicator if extension is enabled
22+
const condition = extensions.conditionalBlocks ? attrs['sc-if'] : undefined;
23+
1824
const handleClick = (e: React.MouseEvent) => {
1925
e.stopPropagation();
2026
selectBlock(node._id!);
@@ -113,6 +119,7 @@ export function VisualColumn({ node, totalColumns }: VisualColumnProps) {
113119
style={columnStyle}
114120
onClick={handleClick}
115121
>
122+
<ConditionalIndicator condition={condition} />
116123
{hasInnerStyling ? (
117124
<div style={innerStyle}>
118125
{contentBlocks.map((block) => (

0 commit comments

Comments
 (0)