11import { describe , test , expect } from "vitest" ;
22import 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+
430describe ( "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 ( / C o m i e n c e c o n ( < \/ s p a n > ) ? < s t r o n g > / ) ;
698+ expect ( output ) . not . toContain ( "Comience con<strong>" ) ;
699+ } ) ;
338700} ) ;
0 commit comments