Skip to content

Commit 4721c91

Browse files
committed
parse liquid syntax in links
1 parent e665228 commit 4721c91

3 files changed

Lines changed: 458 additions & 21 deletions

File tree

pkg/notifuse_mjml/converter.go

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,26 @@ func ConvertJSONToMJML(tree EmailBlock) string {
1616

1717
// ConvertJSONToMJMLWithData converts an EmailBlock JSON tree to MJML string with template data
1818
func ConvertJSONToMJMLWithData(tree EmailBlock, templateData string) (string, error) {
19-
return convertBlockToMJMLWithError(tree, 0, templateData)
19+
// Parse template data once at the beginning
20+
parsedData, parseErr := parseTemplateDataString(templateData)
21+
if parseErr != nil {
22+
return "", fmt.Errorf("template data parsing failed: %v", parseErr)
23+
}
24+
return convertBlockToMJMLWithErrorAndParsedData(tree, 0, templateData, parsedData)
2025
}
2126

2227
// convertBlockToMJMLWithError recursively converts a single EmailBlock to MJML string with error handling
2328
func convertBlockToMJMLWithError(block EmailBlock, indentLevel int, templateData string) (string, error) {
29+
// Parse template data once at the beginning
30+
parsedData, parseErr := parseTemplateDataString(templateData)
31+
if parseErr != nil {
32+
return "", fmt.Errorf("template data parsing failed: %v", parseErr)
33+
}
34+
return convertBlockToMJMLWithErrorAndParsedData(block, indentLevel, templateData, parsedData)
35+
}
36+
37+
// convertBlockToMJMLWithErrorAndParsedData recursively converts a single EmailBlock to MJML string with error handling and pre-parsed data
38+
func convertBlockToMJMLWithErrorAndParsedData(block EmailBlock, indentLevel int, templateData string, parsedData map[string]interface{}) (string, error) {
2439
indent := strings.Repeat(" ", indentLevel)
2540
tagName := string(block.GetType())
2641
children := block.GetChildren()
@@ -34,10 +49,6 @@ func convertBlockToMJMLWithError(block EmailBlock, indentLevel int, templateData
3449
// Process Liquid templating for mj-text, mj-button, mj-title, mj-preview, and mj-raw blocks
3550
blockType := block.GetType()
3651
if blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton || blockType == MJMLComponentMjTitle || blockType == MJMLComponentMjPreview || blockType == MJMLComponentMjRaw {
37-
parsedData, parseErr := parseTemplateDataString(templateData)
38-
if parseErr != nil {
39-
return "", fmt.Errorf("template data parsing failed for block %s: %v", block.GetID(), parseErr)
40-
}
4152
processedContent, err := processLiquidContent(content, parsedData, block.GetID())
4253
if err != nil {
4354
// Return error instead of just logging
@@ -48,15 +59,15 @@ func convertBlockToMJMLWithError(block EmailBlock, indentLevel int, templateData
4859
}
4960

5061
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML)
51-
attributeString := formatAttributes(block.GetAttributes())
62+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
5263
if blockType == MJMLComponentMjRaw || blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton {
5364
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, content, tagName), nil
5465
} else {
5566
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, escapeContent(content), tagName), nil
5667
}
5768
} else {
5869
// Self-closing block or empty block
59-
attributeString := formatAttributes(block.GetAttributes())
70+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
6071
if attributeString != "" {
6172
return fmt.Sprintf("%s<%s%s />", indent, tagName, attributeString), nil
6273
} else {
@@ -66,15 +77,15 @@ func convertBlockToMJMLWithError(block EmailBlock, indentLevel int, templateData
6677
}
6778

6879
// Block with children
69-
attributeString := formatAttributes(block.GetAttributes())
80+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
7081
openTag := fmt.Sprintf("%s<%s%s>", indent, tagName, attributeString)
7182
closeTag := fmt.Sprintf("%s</%s>", indent, tagName)
7283

7384
// Process children
7485
var childrenMJML []string
7586
for _, child := range children {
7687
if child != nil {
77-
childMJML, err := convertBlockToMJMLWithError(child, indentLevel+1, templateData)
88+
childMJML, err := convertBlockToMJMLWithErrorAndParsedData(child, indentLevel+1, templateData, parsedData)
7889
if err != nil {
7990
return "", err
8091
}
@@ -87,6 +98,16 @@ func convertBlockToMJMLWithError(block EmailBlock, indentLevel int, templateData
8798

8899
// convertBlockToMJML recursively converts a single EmailBlock to MJML string
89100
func convertBlockToMJML(block EmailBlock, indentLevel int, templateData string) string {
101+
// Parse template data once at the beginning
102+
parsedData, parseErr := parseTemplateDataString(templateData)
103+
if parseErr != nil {
104+
parsedData = nil // Continue with nil data if parsing fails
105+
}
106+
return convertBlockToMJMLWithParsedData(block, indentLevel, templateData, parsedData)
107+
}
108+
109+
// convertBlockToMJMLWithParsedData recursively converts a single EmailBlock to MJML string with pre-parsed data
110+
func convertBlockToMJMLWithParsedData(block EmailBlock, indentLevel int, templateData string, parsedData map[string]interface{}) string {
90111
indent := strings.Repeat(" ", indentLevel)
91112
tagName := string(block.GetType())
92113
children := block.GetChildren()
@@ -100,10 +121,7 @@ func convertBlockToMJML(block EmailBlock, indentLevel int, templateData string)
100121
// Process Liquid templating for mj-text, mj-button, mj-title, mj-preview, and mj-raw blocks
101122
blockType := block.GetType()
102123
if blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton || blockType == MJMLComponentMjTitle || blockType == MJMLComponentMjPreview || blockType == MJMLComponentMjRaw {
103-
parsedData, parseErr := parseTemplateDataString(templateData)
104-
if parseErr != nil {
105-
fmt.Printf("Warning: Template data parsing failed for block %s: %v\n", block.GetID(), parseErr)
106-
} else {
124+
if parsedData != nil {
107125
processedContent, err := processLiquidContent(content, parsedData, block.GetID())
108126
if err != nil {
109127
// Log error but continue with original content
@@ -115,15 +133,15 @@ func convertBlockToMJML(block EmailBlock, indentLevel int, templateData string)
115133
}
116134

117135
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML)
118-
attributeString := formatAttributes(block.GetAttributes())
136+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
119137
if blockType == MJMLComponentMjRaw || blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton {
120138
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, content, tagName)
121139
} else {
122140
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, escapeContent(content), tagName)
123141
}
124142
} else {
125143
// Self-closing block or empty block
126-
attributeString := formatAttributes(block.GetAttributes())
144+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
127145
if attributeString != "" {
128146
return fmt.Sprintf("%s<%s%s />", indent, tagName, attributeString)
129147
} else {
@@ -133,15 +151,15 @@ func convertBlockToMJML(block EmailBlock, indentLevel int, templateData string)
133151
}
134152

135153
// Block with children
136-
attributeString := formatAttributes(block.GetAttributes())
154+
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
137155
openTag := fmt.Sprintf("%s<%s%s>", indent, tagName, attributeString)
138156
closeTag := fmt.Sprintf("%s</%s>", indent, tagName)
139157

140158
// Process children
141159
var childrenMJML []string
142160
for _, child := range children {
143161
if child != nil {
144-
childrenMJML = append(childrenMJML, convertBlockToMJML(child, indentLevel+1, templateData))
162+
childrenMJML = append(childrenMJML, convertBlockToMJMLWithParsedData(child, indentLevel+1, templateData, parsedData))
145163
}
146164
}
147165

@@ -231,14 +249,19 @@ func getBlockContent(block EmailBlock) string {
231249

232250
// formatAttributes formats attributes object into MJML attribute string
233251
func formatAttributes(attributes map[string]interface{}) string {
252+
return formatAttributesWithLiquid(attributes, nil, "")
253+
}
254+
255+
// formatAttributesWithLiquid formats attributes object into MJML attribute string with liquid processing
256+
func formatAttributesWithLiquid(attributes map[string]interface{}, templateData map[string]interface{}, blockID string) string {
234257
if len(attributes) == 0 {
235258
return ""
236259
}
237260

238261
var attrPairs []string
239262
for key, value := range attributes {
240263
if shouldIncludeAttribute(value) {
241-
if attr := formatSingleAttribute(key, value); attr != "" {
264+
if attr := formatSingleAttributeWithLiquid(key, value, templateData, blockID); attr != "" {
242265
attrPairs = append(attrPairs, attr)
243266
}
244267
}
@@ -271,6 +294,11 @@ func shouldIncludeAttribute(value interface{}) bool {
271294

272295
// formatSingleAttribute formats a single attribute key-value pair
273296
func formatSingleAttribute(key string, value interface{}) string {
297+
return formatSingleAttributeWithLiquid(key, value, nil, "")
298+
}
299+
300+
// formatSingleAttributeWithLiquid formats a single attribute key-value pair with liquid processing
301+
func formatSingleAttributeWithLiquid(key string, value interface{}, templateData map[string]interface{}, blockID string) string {
274302
// Convert camelCase to kebab-case for MJML attributes
275303
kebabKey := camelToKebab(key)
276304

@@ -290,25 +318,50 @@ func formatSingleAttribute(key string, value interface{}) string {
290318
if v == "" {
291319
return ""
292320
}
293-
escapedValue := escapeAttributeValue(v, kebabKey)
321+
processedValue := processAttributeValue(v, kebabKey, templateData, blockID)
322+
escapedValue := escapeAttributeValue(processedValue, kebabKey)
294323
return fmt.Sprintf(` %s="%s"`, kebabKey, escapedValue)
295324
case *string:
296325
if v == nil || *v == "" {
297326
return ""
298327
}
299-
escapedValue := escapeAttributeValue(*v, kebabKey)
328+
processedValue := processAttributeValue(*v, kebabKey, templateData, blockID)
329+
escapedValue := escapeAttributeValue(processedValue, kebabKey)
300330
return fmt.Sprintf(` %s="%s"`, kebabKey, escapedValue)
301331
default:
302332
// Handle other types (int, float, etc.) by converting to string
303333
strValue := fmt.Sprintf("%v", value)
304334
if strValue == "" {
305335
return ""
306336
}
307-
escapedValue := escapeAttributeValue(strValue, kebabKey)
337+
processedValue := processAttributeValue(strValue, kebabKey, templateData, blockID)
338+
escapedValue := escapeAttributeValue(processedValue, kebabKey)
308339
return fmt.Sprintf(` %s="%s"`, kebabKey, escapedValue)
309340
}
310341
}
311342

343+
// processAttributeValue processes attribute values through liquid templating if applicable
344+
func processAttributeValue(value, attributeKey string, templateData map[string]interface{}, blockID string) string {
345+
// Only process liquid templates for URL-related attributes that might contain dynamic content
346+
isURLAttribute := attributeKey == "href" || attributeKey == "src" || attributeKey == "action" ||
347+
attributeKey == "background-url" || strings.HasSuffix(attributeKey, "-url")
348+
349+
// If templateData is nil or this isn't a URL attribute, return as-is
350+
if templateData == nil || !isURLAttribute {
351+
return value
352+
}
353+
354+
// Process liquid content for URL attributes
355+
processedValue, err := processLiquidContent(value, templateData, fmt.Sprintf("%s.%s", blockID, attributeKey))
356+
if err != nil {
357+
// If liquid processing fails, return original value and log warning
358+
fmt.Printf("Warning: Liquid processing failed for attribute %s in block %s: %v\n", attributeKey, blockID, err)
359+
return value
360+
}
361+
362+
return processedValue
363+
}
364+
312365
// camelToKebab converts camelCase to kebab-case
313366
func camelToKebab(str string) string {
314367
// Use regex to find capital letters and replace them with hyphen + lowercase

pkg/notifuse_mjml/converter_additional_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,154 @@ func TestGetBlockContent_AllTypes(t *testing.T) {
175175
t.Fatalf("expected empty content, got %q", c)
176176
}
177177
}
178+
179+
func TestOptimizedTemplateDataParsing(t *testing.T) {
180+
// Test that template data is parsed only once per conversion, not multiple times per block
181+
// This is a regression test for the optimization where we parse template data once and pass it through
182+
183+
// Create a nested structure that would trigger multiple parsings in the old implementation
184+
text1 := &MJTextBlock{
185+
BaseBlock: BaseBlock{
186+
ID: "text1",
187+
Type: MJMLComponentMjText,
188+
Attributes: map[string]interface{}{
189+
"href": "{{ base_url }}/text1",
190+
},
191+
},
192+
Content: stringPtr("Hello {{ user.name }}"),
193+
}
194+
195+
button1 := &MJButtonBlock{
196+
BaseBlock: BaseBlock{
197+
ID: "btn1",
198+
Type: MJMLComponentMjButton,
199+
Attributes: map[string]interface{}{
200+
"href": "{{ base_url }}/button",
201+
},
202+
},
203+
Content: stringPtr("Click {{ cta_text }}"),
204+
}
205+
206+
column := &MJColumnBlock{
207+
BaseBlock: BaseBlock{
208+
ID: "col1",
209+
Type: MJMLComponentMjColumn,
210+
Children: []interface{}{text1, button1},
211+
},
212+
}
213+
214+
section := &MJSectionBlock{
215+
BaseBlock: BaseBlock{
216+
ID: "sec1",
217+
Type: MJMLComponentMjSection,
218+
Children: []interface{}{column},
219+
Attributes: map[string]interface{}{
220+
"backgroundUrl": "{{ base_url }}/background.jpg",
221+
},
222+
},
223+
}
224+
225+
body := &MJBodyBlock{
226+
BaseBlock: BaseBlock{
227+
ID: "body1",
228+
Type: MJMLComponentMjBody,
229+
Children: []interface{}{section},
230+
},
231+
}
232+
233+
root := &MJMLBlock{
234+
BaseBlock: BaseBlock{
235+
ID: "root",
236+
Type: MJMLComponentMjml,
237+
Children: []interface{}{body},
238+
},
239+
}
240+
241+
templateData := `{
242+
"user": {"name": "John Doe"},
243+
"base_url": "https://example.com",
244+
"cta_text": "Get Started"
245+
}`
246+
247+
// Convert with template data
248+
result, err := ConvertJSONToMJMLWithData(root, templateData)
249+
if err != nil {
250+
t.Fatalf("Unexpected error: %v", err)
251+
}
252+
253+
// Verify all liquid expressions were processed correctly
254+
expectedStrings := []string{
255+
"Hello John Doe", // Content processing
256+
"Click Get Started", // Content processing
257+
`href="https://example.com/text1"`, // Attribute processing
258+
`href="https://example.com/button"`, // Attribute processing
259+
`background-url="https://example.com/background.jpg"`, // Attribute processing
260+
}
261+
262+
for _, expected := range expectedStrings {
263+
if !strings.Contains(result, expected) {
264+
t.Errorf("Expected result to contain '%s', got: %s", expected, result)
265+
}
266+
}
267+
}
268+
269+
func TestFormatAttributesWithLiquid(t *testing.T) {
270+
templateData := map[string]interface{}{
271+
"base_url": "https://example.com",
272+
"color": "#ff0000",
273+
}
274+
275+
attrs := map[string]interface{}{
276+
"href": "{{ base_url }}/profile", // Should be processed
277+
"src": "{{ base_url }}/image.jpg", // Should be processed
278+
"backgroundColor": "{{ color }}", // Should NOT be processed (not URL attribute)
279+
"fontSize": "16px", // Should not be processed
280+
}
281+
282+
result := formatAttributesWithLiquid(attrs, templateData, "test-block")
283+
284+
// Check that URL attributes were processed
285+
if !strings.Contains(result, `href="https://example.com/profile"`) {
286+
t.Errorf("href attribute not processed correctly: %s", result)
287+
}
288+
if !strings.Contains(result, `src="https://example.com/image.jpg"`) {
289+
t.Errorf("src attribute not processed correctly: %s", result)
290+
}
291+
292+
// Check that non-URL attributes were NOT processed
293+
if !strings.Contains(result, `background-color="{{ color }}"`) {
294+
t.Errorf("backgroundColor should not be processed, got: %s", result)
295+
}
296+
if !strings.Contains(result, `font-size="16px"`) {
297+
t.Errorf("fontSize should be included as-is, got: %s", result)
298+
}
299+
}
300+
301+
func TestTemplateDataParsingErrorHandling(t *testing.T) {
302+
// Test error handling for invalid template data
303+
text := &MJTextBlock{
304+
BaseBlock: BaseBlock{ID: "text1", Type: MJMLComponentMjText},
305+
Content: stringPtr("Hello {{ name }}"),
306+
}
307+
body := &MJBodyBlock{
308+
BaseBlock: BaseBlock{ID: "body1", Type: MJMLComponentMjBody, Children: []interface{}{text}},
309+
}
310+
root := &MJMLBlock{
311+
BaseBlock: BaseBlock{ID: "root", Type: MJMLComponentMjml, Children: []interface{}{body}},
312+
}
313+
314+
// Test with invalid JSON
315+
_, err := ConvertJSONToMJMLWithData(root, "{invalid json")
316+
if err == nil {
317+
t.Fatal("Expected error for invalid JSON template data")
318+
}
319+
if !strings.Contains(err.Error(), "template data parsing failed") {
320+
t.Errorf("Expected template data parsing error, got: %v", err)
321+
}
322+
323+
// Test with valid JSON but liquid processing error in error-handling function
324+
_, err = ConvertJSONToMJMLWithData(root, `{"name": "John"}`)
325+
if err != nil {
326+
t.Fatalf("Unexpected error with valid data: %v", err)
327+
}
328+
}

0 commit comments

Comments
 (0)