Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
52e9623
fix(jsx-email): use standard MSO closer in <head>
CharlieHelps Jan 21, 2026
d71cbb7
test(jsx-email): update snapshots for head MSO closer
CharlieHelps Jan 21, 2026
aafd2ca
chore(jsx-email): clarify head conditional closer comment
CharlieHelps Jan 21, 2026
38a677c
chore(jsx-email): document head closer behavior
CharlieHelps Jan 21, 2026
ab8017b
chore(jsx-email): tighten head closer logic
CharlieHelps Jan 21, 2026
6c766f2
test(jsx-email): cover head conditional closer variants
CharlieHelps Jan 21, 2026
535e964
test(jsx-email): assert expression closer outside <head>
CharlieHelps Jan 21, 2026
8e9608e
chore(jsx-email): clarify head-scope flag coupling
CharlieHelps Jan 21, 2026
bda6b4b
test(jsx-email): cover OfficeDocumentSettings head closer
CharlieHelps Jan 21, 2026
91ad60e
test(jsx-email): harden head closer assertions
CharlieHelps Jan 21, 2026
100baaf
chore(jsx-email): clarify head closer guardrails
CharlieHelps Jan 21, 2026
1bd2e6d
fix(jsx-email): choose MSO closer by final <head> placement
CharlieHelps Jan 21, 2026
25665dd
fix(jsx-email): key head closer to declared scope
CharlieHelps Jan 21, 2026
10414aa
fix(jsx-email): ensure head exists for head-scoped blocks
CharlieHelps Jan 21, 2026
074b800
fix(jsx-email): avoid synthesizing <head> root
CharlieHelps Jan 21, 2026
9abc3da
docs(jsx-email): clarify Conditional head scoping
CharlieHelps Jan 22, 2026
af878fd
fix: update conditional and raw components to standardize mso conditi…
lordelogos Jan 24, 2026
28df441
fix: update docs and unit tests
lordelogos Jan 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .charlie/playbooks/conditional-and-raw.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Rules

Test guidelines
- Closer/opener integrity
- Assert exactly one opener and closer for MSO: `<!--[if mso]>` and the current closer `<![endif]/-->`.
- Assert exactly one opener and closer for MSO: `<!--[if mso]>` and the standard closer `<![endif]-->`.
- 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 `<Conditional mso><Raw …/></Conditional>`, assert the inner payload appears exactly once and not outside the block.
Expand Down
5 changes: 4 additions & 1 deletion docs/components/conditional.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Conditional, Head } from 'jsx-email';
const Email = () => {
return (
<Head>
<Conditional mso={true}>
<Conditional head mso={true}>
<meta content="batman" />
</Conditional>
</Head>
Expand All @@ -33,6 +33,7 @@ const Email = () => {
interface ConditionalProps {
children?: React.ReactNode;
expression?: string;
head?: boolean;
mso?: boolean;
}
```
Expand All @@ -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 `<jsx-email-cond>` element which HTML parsers may hoist out of a literal `<head>` tag. If you need the conditional to reliably land in `<head>`, use `head` / `data-head`.

```ts
mso?: boolean;
```
Expand Down
9 changes: 4 additions & 5 deletions packages/jsx-email/src/renderer/conditional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,10 @@ export const getConditionalPlugin = async () => {
const expression = exprAttr || (msoAttr === true ? 'mso' : void 0);
if (expression) {
openRaw = `<!--[if ${expression}]>`;
// Older Outlook/Word HTML parsers prefer the self-closing
// conditional terminator variant to avoid comment spillover
// when adjacent comments appear. Use the `<![endif]/-->` form
// for maximum compatibility.
closeRaw = '<![endif]/-->';
// 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 = '<![endif]-->';
}
}

Expand Down
4 changes: 1 addition & 3 deletions packages/jsx-email/src/renderer/raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export function escapeForRawComponent(input: string): string {
}

export function unescapeForRawComponent(input: string): string {
return input
.replace(new RegExp(START_TAG, 'g'), '<!--')
.replace(new RegExp(END_TAG, 'g'), '/-->');
return input.replace(new RegExp(START_TAG, 'g'), '<!--').replace(new RegExp(END_TAG, 'g'), '-->');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

exports[`Raw in Conditional > Raw in Conditional 1`] = `"<jsx-email-cond data-mso="true" data-head="true"><jsx-email-raw><!--<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>--></jsx-email-raw></jsx-email-cond>"`;

exports[`Raw in Conditional > Raw in Conditional 2`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><head><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]/--></head><body></body></html>"`;
exports[`Raw in Conditional > Raw in Conditional 2`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><head><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--></head><body></body></html>"`;
4 changes: 2 additions & 2 deletions packages/jsx-email/test/.snapshots/conditional.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`<Conditional> component > renders expression 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if lt batman]><h1>joker</h1><![endif]/--></body></html>"`;
exports[`<Conditional> component > renders expression 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if lt batman]><h1>joker</h1><![endif]--></body></html>"`;

exports[`<Conditional> component > renders mso: false 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if !mso]><!--><h1>batman</h1><!--<![endif]--></body></html>"`;

exports[`<Conditional> component > renders mso: true 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if mso]><h1>batman</h1><![endif]/--></body></html>"`;
exports[`<Conditional> component > renders mso: true 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if mso]><h1>batman</h1><![endif]--></body></html>"`;

exports[`<Conditional> component > renders with head: true 1`] = `"<jsx-email-cond data-mso="true" data-head="true"><h1>batman</h1></jsx-email-cond>"`;

Expand Down
2 changes: 1 addition & 1 deletion packages/jsx-email/test/.snapshots/debug.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ exports[`render > renders with debug attributes 1`] = `
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no"><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]/-->
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no"><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
</head>

<body data-type="jsx-email/body" style="background-color:#ffffff;font-family:HelveticaNeue,Helvetica,Arial,sans-serif">
Expand Down
4 changes: 2 additions & 2 deletions packages/jsx-email/test/.snapshots/raw.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ exports[`<Raw> component > Should work correctly when it has linebreaks 1`] = `
</body></html>"
`;

exports[`<Raw> component > Should work correctly with a comment as a content 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if !mso]><!-->Ola!<!--<![endif]/--></body></html>"`;
exports[`<Raw> component > Should work correctly with a comment as a content 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if !mso]><!-->Ola!<!--<![endif]--></body></html>"`;

exports[`<Raw> component > disablePlainTextOutput > Should not output to the plain text when enabled 1`] = `"Ola!"`;

exports[`<Raw> component > disablePlainTextOutput > Should output to html when enabled 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if !mso]><!-->Ola!<!--<![endif]/--></body></html>"`;
exports[`<Raw> component > disablePlainTextOutput > Should output to html when enabled 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><!--[if !mso]><!-->Ola!<!--<![endif]--></body></html>"`;
71 changes: 67 additions & 4 deletions packages/jsx-email/test/conditional-endif-closer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head>…</head> pair.
const start = html.indexOf('<head');
if (start === -1) return '';

const end = html.indexOf('</head>', start);
if (end === -1) return '';

return html.slice(start, end + '</head>'.length);
}

describe('<Conditional mso> closer', () => {
it('emits a self-closing MSO closer `<![endif]/-->`', async () => {
it('emits the standard MSO closer `<![endif]-->`', async () => {
// Standard closer per W3C and Microsoft specifications
const html = await render(
<Conditional mso>
<Raw content={'<b data-testid="closer">hi</b>'} />
</Conditional>
);

expect(html).toContain('<![endif]/-->' /* Outlook-friendly closer */);
expect(html).not.toContain('<![endif]-->' /* slashless closer */);
expect(html).toContain('<![endif]-->' /* standard closer */);
expect(html).not.toContain('<![endif]/-->' /* slashed closer */);
expect(html).not.toContain('<!--[endif]---->' /* previously corrupted closer */);
// Robustness: ensure the closer appears exactly once
expect((html.match(/<!\[endif\]\/-->/g) || []).length).toBe(1);
expect((html.match(/<!\[endif\]-->/g) || []).length).toBe(1);
});

it('emits the standard closer for expression conditionals', async () => {
const html = await render(
<Conditional expression="gte mso 9">
<Raw content={'<b data-testid="closer-expr">hi</b>'} />
</Conditional>
);

expect(html).toContain('<!--[if gte mso 9]>' /* opener */);
expect(html).toContain('<![endif]-->' /* standard closer */);
expect(html).not.toContain('<![endif]/-->' /* slashed closer */);
expect((html.match(/<!\[endif\]-->/g) || []).length).toBe(1);
});

it('emits the standard closer within <head>', async () => {
const html = await render(
<Conditional head mso>
<Raw content={'<b data-testid="closer-head">hi</b>'} />
</Conditional>
);

const head = getHead(html);

expect(head).toContain('<!--[if mso]>' /* opener */);
expect(head).toContain('data-testid="closer-head"');
expect(head).toContain('<![endif]-->' /* standard closer */);
expect(head).not.toContain('<![endif]/-->' /* slashed closer */);
expect(head).not.toContain('<!--[endif]---->' /* previously corrupted closer */);
// Robustness: ensure the closer appears exactly once
expect((head.match(/<!\[endif\]-->/g) || []).length).toBe(1);
});

it('emits the standard closer for OfficeDocumentSettings XML within <head>', async () => {
// Canonical guardrail for the Classic Outlook + OfficeDocumentSettings scenario.
const officeXml =
'<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>';

const html = await render(
<Conditional head mso>
<Raw content={officeXml} />
</Conditional>
);

const head = getHead(html);

expect(head).toContain('<!--[if mso]>' /* opener */);
expect(head).toContain('<o:OfficeDocumentSettings>');
expect(head).toContain('<![endif]-->' /* standard closer */);
expect(head).not.toContain('<![endif]/-->' /* slashed closer */);
expect(head).not.toContain('<!--[endif]---->' /* previously corrupted closer */);
});
});
2 changes: 1 addition & 1 deletion packages/jsx-email/test/conditional-raw-nodup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('Conditional + Raw – no duplication', () => {

// Exactly one conditional block, one closer, and one copy of the inner table
const opener = '<!--[if mso]>';
const closer = '<![endif]/-->';
const closer = '<![endif]-->';
expect(count(html, opener)).toBe(1);
expect(count(html, closer)).toBe(1);
expect(count(html, 'id="msoTableTest"')).toBe(1);
Expand Down
Loading