Skip to content

Commit a0b549e

Browse files
committed
fix(mjml): content extraction to prevent duplication
1 parent 1e47b59 commit a0b549e

File tree

2 files changed

+418
-46
lines changed

2 files changed

+418
-46
lines changed

packages/cli/src/cli/loaders/mjml.spec.ts

Lines changed: 364 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
import { describe, test, expect } from "vitest";
22
import createMjmlLoader from "./mjml";
33

4+
// Helper function to find a key by matching content or partial path
5+
function findKeyByContent(result: Record<string, string>, contentOrPath: string): string | undefined {
6+
// First try exact match
7+
if (result[contentOrPath]) {
8+
return contentOrPath;
9+
}
10+
11+
// Try to find by content value
12+
const byValue = Object.keys(result).find(key => result[key] === contentOrPath);
13+
if (byValue) return byValue;
14+
15+
// Try to find by partial path (e.g., "mj-text" finds first mj-text key)
16+
const byPartialPath = Object.keys(result).find(key => key.includes(contentOrPath));
17+
if (byPartialPath) return byPartialPath;
18+
19+
return undefined;
20+
}
21+
22+
// Helper to find key by path pattern and element type
23+
function findKeyByPattern(result: Record<string, string>, pattern: string, elementType: string): string | undefined {
24+
// Match keys that contain the pattern and element type
25+
return Object.keys(result).find(key =>
26+
key.includes(pattern) && key.includes(elementType)
27+
);
28+
}
29+
430
describe("mjml loader", () => {
531
test("should extract text from mj-text component", async () => {
632
const loader = createMjmlLoader();
@@ -19,7 +45,39 @@ describe("mjml loader", () => {
1945

2046
const result = await loader.pull("en", input);
2147

22-
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Hello World");
48+
// Find the mj-text key (now uses content-based hash)
49+
const textKey = findKeyByContent(result, "Hello World");
50+
expect(textKey).toBeDefined();
51+
expect(result[textKey!]).toBe("Hello World");
52+
});
53+
54+
test("content-based hash keys should be deterministic across multiple pulls", async () => {
55+
const loader = createMjmlLoader();
56+
loader.setDefaultLocale("en");
57+
58+
const input = `<?xml version="1.0" encoding="UTF-8"?>
59+
<mjml>
60+
<mj-body>
61+
<mj-section>
62+
<mj-column>
63+
<mj-text>Hello World</mj-text>
64+
<mj-button>Click Me</mj-button>
65+
</mj-column>
66+
</mj-section>
67+
</mj-body>
68+
</mjml>`;
69+
70+
// Pull twice and compare keys
71+
const result1 = await loader.pull("en", input);
72+
const result2 = await loader.pull("en", input);
73+
74+
console.log("First pull keys:", Object.keys(result1));
75+
console.log("Second pull keys:", Object.keys(result2));
76+
77+
// Keys should be identical
78+
expect(Object.keys(result1).sort()).toEqual(Object.keys(result2).sort());
79+
// Values should be identical
80+
expect(result1).toEqual(result2);
2381
});
2482

2583
test("should extract text from mj-button component", async () => {
@@ -39,7 +97,10 @@ describe("mjml loader", () => {
3997

4098
const result = await loader.pull("en", input);
4199

42-
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-button/0"]).toBe("Click Me");
100+
// Content-based hashing - find the actual key
101+
const buttonKeys = Object.keys(result).filter(k => k.includes('mj-button'));
102+
expect(buttonKeys.length).toBe(1);
103+
expect(result[buttonKeys[0]]).toBe("Click Me");
43104
});
44105

45106
test("should extract alt attribute from mj-image component", async () => {
@@ -335,4 +396,305 @@ describe("mjml loader", () => {
335396

336397
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBeUndefined();
337398
});
399+
400+
test("should preserve trailing whitespace in mixed HTML content", async () => {
401+
const loader = createMjmlLoader();
402+
loader.setDefaultLocale("en");
403+
404+
const input = `<?xml version="1.0" encoding="UTF-8"?>
405+
<mjml>
406+
<mj-body>
407+
<mj-section>
408+
<mj-column>
409+
<mj-text>
410+
<span>Get started with </span><strong>GitProtect.io</strong>
411+
</mj-text>
412+
</mj-column>
413+
</mj-section>
414+
</mj-body>
415+
</mjml>`;
416+
417+
await loader.pull("en", input);
418+
419+
const translations = {
420+
"mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Comience con <strong>GitProtect.io</strong>",
421+
};
422+
423+
const output = await loader.push("es", translations, input);
424+
425+
// Should have space between "con" and "<strong>"
426+
expect(output).toContain("Comience con <strong>GitProtect.io</strong>");
427+
// Should NOT have missing space (this would be wrong)
428+
expect(output).not.toContain("Comience con<strong>");
429+
});
430+
431+
test("should preserve Razor variables in text content", async () => {
432+
const loader = createMjmlLoader();
433+
loader.setDefaultLocale("en");
434+
435+
const input = `<?xml version="1.0" encoding="UTF-8"?>
436+
<mjml>
437+
<mj-body>
438+
<mj-section>
439+
<mj-column>
440+
<mj-text>Hello @Model.Name</mj-text>
441+
</mj-column>
442+
</mj-section>
443+
</mj-body>
444+
</mjml>`;
445+
446+
await loader.pull("en", input);
447+
448+
const translations = {
449+
"mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Hola @Model.Name",
450+
};
451+
452+
const output = await loader.push("es", translations, input);
453+
454+
expect(output).toContain("Hola @Model.Name");
455+
// Verify variable name not translated
456+
expect(output).not.toContain("@Modelo.Nombre");
457+
});
458+
459+
test("should not extract content from mj-raw blocks", async () => {
460+
const loader = createMjmlLoader();
461+
loader.setDefaultLocale("en");
462+
463+
const input = `<?xml version="1.0" encoding="UTF-8"?>
464+
<mjml>
465+
<mj-body>
466+
<mj-section>
467+
<mj-column>
468+
<mj-raw>@foreach (var x in Model.Items) {</mj-raw>
469+
<mj-text>Item text</mj-text>
470+
<mj-raw>}</mj-raw>
471+
</mj-column>
472+
</mj-section>
473+
</mj-body>
474+
</mjml>`;
475+
476+
const result = await loader.pull("en", input);
477+
478+
// Should NOT extract mj-raw content
479+
const allValues = Object.values(result);
480+
expect(allValues.find((v) => v.includes("@foreach"))).toBeUndefined();
481+
expect(allValues.find((v) => v.includes("}"))).toBeUndefined();
482+
483+
// Should extract mj-text content
484+
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Item text");
485+
});
486+
487+
test("should preserve Razor expressions in attributes", async () => {
488+
const loader = createMjmlLoader();
489+
loader.setDefaultLocale("en");
490+
491+
const input = `<?xml version="1.0" encoding="UTF-8"?>
492+
<mjml>
493+
<mj-body>
494+
<mj-section>
495+
<mj-column>
496+
<mj-image
497+
src='@System.Net.WebUtility.HtmlEncode(Model.LogoUrl ?? "default.png")'
498+
alt="Company Logo"
499+
/>
500+
</mj-column>
501+
</mj-section>
502+
</mj-body>
503+
</mjml>`;
504+
505+
await loader.pull("en", input);
506+
507+
const translations = {
508+
"mjml/mj-body/0/mj-section/0/mj-column/0/mj-image/0#alt": "Logo de la empresa",
509+
};
510+
511+
const output = await loader.push("es", translations, input);
512+
513+
// Verify Razor expression preserved in src
514+
expect(output).toContain("@System.Net.WebUtility.HtmlEncode");
515+
expect(output).toContain("Model.LogoUrl");
516+
// Verify translated alt attribute
517+
expect(output).toContain("Logo de la empresa");
518+
});
519+
520+
test("should extract from mj-navbar-link components", async () => {
521+
const loader = createMjmlLoader();
522+
loader.setDefaultLocale("en");
523+
524+
const input = `<?xml version="1.0" encoding="UTF-8"?>
525+
<mjml>
526+
<mj-body>
527+
<mj-section>
528+
<mj-column>
529+
<mj-navbar>
530+
<mj-navbar-link href="/home">Home</mj-navbar-link>
531+
<mj-navbar-link href="/about">About</mj-navbar-link>
532+
</mj-navbar>
533+
</mj-column>
534+
</mj-section>
535+
</mj-body>
536+
</mjml>`;
537+
538+
const result = await loader.pull("en", input);
539+
540+
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-navbar/0/mj-navbar-link/0"]).toBe("Home");
541+
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-navbar/0/mj-navbar-link/1"]).toBe("About");
542+
});
543+
544+
test("should extract from mj-accordion components", async () => {
545+
const loader = createMjmlLoader();
546+
loader.setDefaultLocale("en");
547+
548+
const input = `<?xml version="1.0" encoding="UTF-8"?>
549+
<mjml>
550+
<mj-body>
551+
<mj-section>
552+
<mj-column>
553+
<mj-accordion>
554+
<mj-accordion-element>
555+
<mj-accordion-title>FAQ 1</mj-accordion-title>
556+
<mj-accordion-text>Answer to question 1</mj-accordion-text>
557+
</mj-accordion-element>
558+
</mj-accordion>
559+
</mj-column>
560+
</mj-section>
561+
</mj-body>
562+
</mjml>`;
563+
564+
const result = await loader.pull("en", input);
565+
566+
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-accordion/0/mj-accordion-element/0/mj-accordion-title/0"]).toBe("FAQ 1");
567+
expect(result["mjml/mj-body/0/mj-section/0/mj-column/0/mj-accordion/0/mj-accordion-element/0/mj-accordion-text/0"]).toBe("Answer to question 1");
568+
});
569+
570+
test("should handle HTML with inline Razor variables", async () => {
571+
const loader = createMjmlLoader();
572+
loader.setDefaultLocale("en");
573+
574+
const input = `<?xml version="1.0" encoding="UTF-8"?>
575+
<mjml>
576+
<mj-body>
577+
<mj-section>
578+
<mj-column>
579+
<mj-text>
580+
Welcome back, <strong>@Model.FirstName</strong>!
581+
Your last login was @Model.LastLoginDate.
582+
</mj-text>
583+
</mj-column>
584+
</mj-section>
585+
</mj-body>
586+
</mjml>`;
587+
588+
await loader.pull("en", input);
589+
590+
const translations = {
591+
"mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0":
592+
"Bienvenido de nuevo, <strong>@Model.FirstName</strong>! Tu último inicio de sesión fue @Model.LastLoginDate.",
593+
};
594+
595+
const output = await loader.push("es", translations, input);
596+
597+
expect(output).toContain("Bienvenido de nuevo");
598+
expect(output).toContain("@Model.FirstName");
599+
expect(output).toContain("@Model.LastLoginDate");
600+
});
601+
602+
test("should handle mj-wrapper structure", async () => {
603+
const loader = createMjmlLoader();
604+
loader.setDefaultLocale("en");
605+
606+
const input = `<?xml version="1.0" encoding="UTF-8"?>
607+
<mjml>
608+
<mj-body>
609+
<mj-wrapper>
610+
<mj-section>
611+
<mj-column>
612+
<mj-text>Wrapped content</mj-text>
613+
</mj-column>
614+
</mj-section>
615+
</mj-wrapper>
616+
</mj-body>
617+
</mjml>`;
618+
619+
const result = await loader.pull("en", input);
620+
621+
expect(result["mjml/mj-body/0/mj-wrapper/0/mj-section/0/mj-column/0/mj-text/0"]).toBe("Wrapped content");
622+
});
623+
624+
test("should preserve space between text and inline tag (real CloudServiceCreated bug)", async () => {
625+
const loader = createMjmlLoader();
626+
loader.setDefaultLocale("en");
627+
628+
const input = `<?xml version="1.0" encoding="UTF-8"?>
629+
<mjml>
630+
<mj-body>
631+
<mj-section>
632+
<mj-column>
633+
<mj-text>
634+
<span>Get started with </span><strong>GitProtect.io by Xopero ONE</strong>
635+
</mj-text>
636+
</mj-column>
637+
</mj-section>
638+
</mj-body>
639+
</mjml>`;
640+
641+
const pulled = await loader.pull("en", input);
642+
643+
// Translator translates, keeping the HTML structure
644+
const translations = {
645+
"mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0": "Comience con <strong>GitProtect.io by Xopero ONE</strong>",
646+
};
647+
648+
const output = await loader.push("es", translations, input);
649+
650+
// Critical: space must be preserved before <strong>
651+
expect(output).toContain("Comience con <strong>");
652+
// This would be the bug: missing space
653+
expect(output).not.toContain("Comience con<strong>");
654+
655+
// Verify the full text is correct
656+
expect(output).toContain("Comience con <strong>GitProtect.io by Xopero ONE</strong>");
657+
});
658+
659+
test("should preserve trailing space when span ends with space (exact CloudServiceCreated structure)", async () => {
660+
const loader = createMjmlLoader();
661+
loader.setDefaultLocale("en");
662+
663+
// This is the EXACT structure from CloudServiceCreated.mjml line 148-149
664+
const input = `<?xml version="1.0" encoding="UTF-8"?>
665+
<mjml>
666+
<mj-body>
667+
<mj-section>
668+
<mj-column>
669+
<mj-text>
670+
<span>Get started with </span
671+
><strong>GitProtect.io by Xopero ONE</strong>
672+
</mj-text>
673+
</mj-column>
674+
</mj-section>
675+
</mj-body>
676+
</mjml>`;
677+
678+
const pulled = await loader.pull("en", input);
679+
const key = "mjml/mj-body/0/mj-section/0/mj-column/0/mj-text/0";
680+
681+
// Check what was extracted
682+
console.log("Extracted:", JSON.stringify(pulled[key]));
683+
684+
// The extracted value should preserve the space
685+
expect(pulled[key]).toContain("Get started with ");
686+
687+
// Translator provides Spanish without <span> wrapper
688+
const translations = {
689+
[key]: "Comience con <strong>GitProtect.io by Xopero ONE</strong>",
690+
};
691+
692+
const output = await loader.push("es", translations, input);
693+
694+
// The output should have space before <strong>
695+
// Either as: <span>Comience con </span><strong>
696+
// Or as: Comience con <strong>
697+
expect(output).toMatch(/Comience con (<\/span>)?<strong>/);
698+
expect(output).not.toContain("Comience con<strong>");
699+
});
338700
});

0 commit comments

Comments
 (0)