diff --git a/.charlie/playbooks/conditional-and-raw.md b/.charlie/playbooks/conditional-and-raw.md index 743045c3..117af932 100644 --- a/.charlie/playbooks/conditional-and-raw.md +++ b/.charlie/playbooks/conditional-and-raw.md @@ -9,7 +9,7 @@ Rules Test guidelines - Closer/opener integrity - - Assert exactly one opener and closer for MSO: ``. + - Assert exactly one opener and closer for MSO: ``. - Add a small case for the expression path (e.g., `expression="gte mso 16"`) and assert the same closer. - No‑duplication when nesting Raw - For ``, assert the inner payload appears exactly once and not outside the block. diff --git a/docs/components/conditional.md b/docs/components/conditional.md index 8caa4531..7f31febe 100644 --- a/docs/components/conditional.md +++ b/docs/components/conditional.md @@ -19,7 +19,7 @@ import { Conditional, Head } from 'jsx-email'; const Email = () => { return ( - + @@ -33,6 +33,7 @@ const Email = () => { interface ConditionalProps { children?: React.ReactNode; expression?: string; + head?: boolean; mso?: boolean; } ``` @@ -55,6 +56,8 @@ head?: boolean; If `true`, the conditional expression will be placed in the `head` section of your email template. +Note: the component renders an intermediate `` element which HTML parsers may hoist out of a literal `` tag. If you need the conditional to reliably land in ``, use `head` / `data-head`. + ```ts mso?: boolean; ``` diff --git a/packages/jsx-email/src/renderer/conditional.ts b/packages/jsx-email/src/renderer/conditional.ts index 70690334..7c1be40b 100644 --- a/packages/jsx-email/src/renderer/conditional.ts +++ b/packages/jsx-email/src/renderer/conditional.ts @@ -69,11 +69,10 @@ export const getConditionalPlugin = async () => { const expression = exprAttr || (msoAttr === true ? 'mso' : void 0); if (expression) { openRaw = `` form - // for maximum compatibility. - closeRaw = ''; + // Use the standard MSO conditional closer per W3C and Microsoft + // specifications. This is compatible with all Outlook versions + // and matches industry frameworks (MJML, Maizzle, Litmus examples). + closeRaw = ''; } } diff --git a/packages/jsx-email/src/renderer/raw.ts b/packages/jsx-email/src/renderer/raw.ts index 381c9e6e..2e27d229 100644 --- a/packages/jsx-email/src/renderer/raw.ts +++ b/packages/jsx-email/src/renderer/raw.ts @@ -25,9 +25,7 @@ export function escapeForRawComponent(input: string): string { } export function unescapeForRawComponent(input: string): string { - return input - .replace(new RegExp(START_TAG, 'g'), ''); + return input.replace(new RegExp(START_TAG, 'g'), ''); } /** diff --git a/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap b/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap index b8722635..78ca16ae 100644 --- a/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap @@ -2,4 +2,4 @@ exports[`Raw in Conditional > Raw in Conditional 1`] = `""`; -exports[`Raw in Conditional > Raw in Conditional 2`] = `""`; +exports[`Raw in Conditional > Raw in Conditional 2`] = `""`; diff --git a/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap b/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap index b36a58ae..88c37899 100644 --- a/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` component > renders expression 1`] = `""`; +exports[` component > renders expression 1`] = `""`; exports[` component > renders mso: false 1`] = `"

batman

"`; -exports[` component > renders mso: true 1`] = `""`; +exports[` component > renders mso: true 1`] = `""`; exports[` component > renders with head: true 1`] = `"

batman

"`; diff --git a/packages/jsx-email/test/.snapshots/debug.test.tsx.snap b/packages/jsx-email/test/.snapshots/debug.test.tsx.snap index fb89dce3..7fafeaab 100644 --- a/packages/jsx-email/test/.snapshots/debug.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/debug.test.tsx.snap @@ -11,7 +11,7 @@ exports[`render > renders with debug attributes 1`] = ` - + diff --git a/packages/jsx-email/test/.snapshots/raw.test.tsx.snap b/packages/jsx-email/test/.snapshots/raw.test.tsx.snap index d7195d31..55ebaa0a 100644 --- a/packages/jsx-email/test/.snapshots/raw.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/raw.test.tsx.snap @@ -10,8 +10,8 @@ exports[` component > Should work correctly when it has linebreaks 1`] = ` " `; -exports[` component > Should work correctly with a comment as a content 1`] = `"Ola!"`; +exports[` component > Should work correctly with a comment as a content 1`] = `"Ola!"`; exports[` component > disablePlainTextOutput > Should not output to the plain text when enabled 1`] = `"Ola!"`; -exports[` component > disablePlainTextOutput > Should output to html when enabled 1`] = `"Ola!"`; +exports[` component > disablePlainTextOutput > Should output to html when enabled 1`] = `"Ola!"`; diff --git a/packages/jsx-email/test/conditional-endif-closer.test.tsx b/packages/jsx-email/test/conditional-endif-closer.test.tsx index ff98b18a..e7305f96 100644 --- a/packages/jsx-email/test/conditional-endif-closer.test.tsx +++ b/packages/jsx-email/test/conditional-endif-closer.test.tsx @@ -3,18 +3,81 @@ import { describe, expect, it } from 'vitest'; // Import from source to keep tests hermetic and avoid prebuild coupling import { Conditional, Raw, render } from '../src/index.ts'; +function getHead(html: string) { + // Test helper: assumes well-formed HTML with a single … pair. + const start = html.indexOf('', start); + if (end === -1) return ''; + + return html.slice(start, end + ''.length); +} + describe(' closer', () => { - it('emits a self-closing MSO closer ``', async () => { + it('emits the standard MSO closer ``', async () => { + // Standard closer per W3C and Microsoft specifications const html = await render( hi'} /> ); - expect(html).toContain('' /* Outlook-friendly closer */); - expect(html).not.toContain('' /* slashless closer */); + expect(html).toContain('' /* standard closer */); + expect(html).not.toContain('' /* slashed closer */); expect(html).not.toContain('' /* previously corrupted closer */); // Robustness: ensure the closer appears exactly once - expect((html.match(//g) || []).length).toBe(1); + expect((html.match(//g) || []).length).toBe(1); + }); + + it('emits the standard closer for expression conditionals', async () => { + const html = await render( + + hi'} /> + + ); + + expect(html).toContain('' /* standard closer */); + expect(html).not.toContain('' /* slashed closer */); + expect((html.match(//g) || []).length).toBe(1); + }); + + it('emits the standard closer within ', async () => { + const html = await render( + + hi'} /> + + ); + + const head = getHead(html); + + expect(head).toContain('' /* standard closer */); + expect(head).not.toContain('' /* slashed closer */); + expect(head).not.toContain('' /* previously corrupted closer */); + // Robustness: ensure the closer appears exactly once + expect((head.match(//g) || []).length).toBe(1); + }); + + it('emits the standard closer for OfficeDocumentSettings XML within ', async () => { + // Canonical guardrail for the Classic Outlook + OfficeDocumentSettings scenario. + const officeXml = + '96'; + + const html = await render( + + + + ); + + const head = getHead(html); + + expect(head).toContain('' /* standard closer */); + expect(head).not.toContain('' /* slashed closer */); + expect(head).not.toContain('' /* previously corrupted closer */); }); }); diff --git a/packages/jsx-email/test/conditional-raw-nodup.test.tsx b/packages/jsx-email/test/conditional-raw-nodup.test.tsx index fe6b0d2c..91978c98 100644 --- a/packages/jsx-email/test/conditional-raw-nodup.test.tsx +++ b/packages/jsx-email/test/conditional-raw-nodup.test.tsx @@ -32,7 +32,7 @@ describe('Conditional + Raw – no duplication', () => { // Exactly one conditional block, one closer, and one copy of the inner table const opener = ''; + const closer = ''; expect(count(html, opener)).toBe(1); expect(count(html, closer)).toBe(1); expect(count(html, 'id="msoTableTest"')).toBe(1); diff --git a/packages/jsx-email/test/render/.snapshots/render.test.tsx.snap b/packages/jsx-email/test/render/.snapshots/render.test.tsx.snap index 29588d1d..51804905 100644 --- a/packages/jsx-email/test/render/.snapshots/render.test.tsx.snap +++ b/packages/jsx-email/test/render/.snapshots/render.test.tsx.snap @@ -28,7 +28,7 @@ exports[`render > inlining 1`] = ` - + @@ -265,7 +265,7 @@ exports[`render > inlining 2`] = ` } } } - + @@ -338,7 +338,7 @@ exports[`render > inlining 2`] = ` `; exports[`render > renders the airbnb demo template 1`] = ` -"
Read undefined's review
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
Airbnb

Here's what wrote

Now that the review period is over, we’ve posted ’s review to your Airbnb profile.

While it’s too late to write a review of your own, you can send your feedback to using your Airbnb message thread.

Read undefined's review
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
Airbnb

Here's what wrote

Now that the review period is over, we’ve posted ’s review to your Airbnb profile.

While it’s too late to write a review of your own, you can send your feedback to using your Airbnb message thread.

+ @@ -453,7 +453,7 @@ exports[`render > renders the airbnb demo template 2`] = ` " `; -exports[`render > renders the plaid demo template 1`] = `"
Plaid

Verify Your Identity

Enter the following code to finish linking Venmo.

Not expecting this email?

Contact login@plaid.com if you did not request this code.

Securely powered by Plaid.

"`; +exports[`render > renders the plaid demo template 1`] = `"
Plaid

Verify Your Identity

Enter the following code to finish linking Venmo.

Not expecting this email?

Contact login@plaid.com if you did not request this code.

Securely powered by Plaid.

"`; exports[`render > renders the plaid demo template 2`] = ` " @@ -466,7 +466,7 @@ exports[`render > renders the plaid demo template 2`] = ` - + @@ -541,7 +541,7 @@ exports[`render > renders the vercel demo template 1`] = ` .dark\\:bg-black{background-color:rgb(0,0,0);} @media (prefers-color-scheme: dark){ .dark\\:bg-black{background-color:rgb(0,0,0);}} -}