Skip to content

Commit 92c96ca

Browse files
committed
fix(jsx-email): align Conditional/Raw via rehype
1 parent e1abc1a commit 92c96ca

23 files changed

+397
-120
lines changed

packages/jsx-email/src/components/conditional.tsx

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
1-
import React, { Suspense } from 'react';
1+
import React from 'react';
22

3-
import { jsxToString } from '../renderer/jsx-to-string.js';
4-
import { useData } from '../renderer/suspense.js';
53
import type { JsxEmailComponent } from '../types.js';
64

5+
declare module 'react/jsx-runtime' {
6+
namespace JSX {
7+
interface IntrinsicElements {
8+
// @ts-ignore
9+
'jsx-email-cond': React.DetailedHTMLProps<
10+
React.HTMLAttributes<HTMLElement> & {
11+
'data-expression'?: string;
12+
'data-head'?: boolean;
13+
'data-mso'?: boolean;
14+
},
15+
HTMLElement
16+
>;
17+
}
18+
}
19+
}
20+
721
export interface ConditionalProps {
822
children?: React.ReactNode;
923
expression?: string;
1024
head?: boolean;
1125
mso?: boolean;
1226
}
1327

14-
const notMso = (html: string) => `<!--[if !mso]><!-->${html}<!--<![endif]-->`;
15-
16-
const comment = (expression: string, html: string) => `<!--[if ${expression}]>${html}<![endif]-->`;
17-
18-
const Renderer = (props: ConditionalProps) => {
19-
const { children, mso, head } = props;
20-
let { expression } = props;
21-
const html = useData(props, () => jsxToString(<>{children}</>));
22-
let innerHtml = '';
23-
24-
if (mso === false) innerHtml = notMso(html);
25-
else if (mso === true && !expression) expression = 'mso';
26-
if (expression) innerHtml = comment(expression, html);
27-
28-
const Component = head ? 'head' : 'jsx-email-cond';
29-
30-
// @ts-ignore
31-
// Note: This is perfectly valid. TS just expects lowercase tag names to match a specific type
32-
return <Component dangerouslySetInnerHTML={{ __html: innerHtml }} />;
33-
};
34-
3528
export const Conditional: JsxEmailComponent<ConditionalProps> = (props) => {
36-
const { children, expression, mso } = props;
29+
const { children, expression, mso, head } = props;
3730

3831
if (typeof expression === 'undefined' && typeof mso === 'undefined')
3932
throw new RangeError(
@@ -45,12 +38,13 @@ export const Conditional: JsxEmailComponent<ConditionalProps> = (props) => {
4538
'jsx-email: Conditional expects the `expression` or `mso` prop to be defined, not both'
4639
);
4740

41+
// Always render a JSX custom element with data-* markers.
42+
// A rehype plugin will replace this element with proper conditional comments.
43+
// @ts-ignore - lower-case custom element tag is valid
4844
return (
49-
<>
50-
<Suspense fallback={<div>waiting</div>}>
51-
<Renderer {...props}>{children}</Renderer>
52-
</Suspense>
53-
</>
45+
<jsx-email-cond data-mso={mso} data-expression={expression} data-head={head}>
46+
{children}
47+
</jsx-email-cond>
5448
);
5549
};
5650

packages/jsx-email/src/components/head.tsx

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { BaseProps, JsxEmailComponent } from '../types.js';
21
import { debug } from '../debug.js';
2+
import type { BaseProps, JsxEmailComponent } from '../types.js';
33

44
import { Conditional } from './conditional.js';
5+
import { Raw } from './raw.js';
56

67
export interface HeadProps extends BaseProps<'head'> {
78
enableFormatDetection?: boolean;
@@ -27,15 +28,9 @@ export const Head: JsxEmailComponent<HeadProps> = ({
2728
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
2829
)}
2930
{children}
30-
<Conditional
31-
head
32-
mso
33-
children={
34-
// prettier-ignore
35-
// @ts-expect-error: element don't exist
36-
<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
37-
}
38-
/>
31+
<Conditional head mso>
32+
<Raw content="<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>" />
33+
</Conditional>
3934
</head>
4035
);
4136

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { Content, Element, Literal, Parents, Root } from 'hast';
2+
3+
// dynamic import of 'unist-util-visit' within factory to support CJS build
4+
5+
interface Match {
6+
index: number;
7+
node: Element;
8+
parent: Parents;
9+
}
10+
11+
// `raw` is an unofficial HAST node used by rehype to pass through HTML verbatim.
12+
// Model it locally to avoid `any` casts while keeping the rest of the tree typed.
13+
interface Raw extends Literal {
14+
type: 'raw';
15+
value: string;
16+
}
17+
18+
interface ParentWithRaw {
19+
children: (Content | Raw)[];
20+
}
21+
22+
/**
23+
* Returns a rehype plugin that replaces `<jsx-email-cond>` elements (from
24+
* the Conditional component) with conditional comment wrappers, based on the
25+
* `data-mso` and `data-expression` attributes.
26+
*
27+
* Mirrors the async factory pattern used by `getRawPlugin()`.
28+
*/
29+
export const getConditionalPlugin = async () => {
30+
const { visit } = await import('unist-util-visit');
31+
32+
return function conditionalPlugin() {
33+
return function transform(tree: Root) {
34+
const matches: Match[] = [];
35+
let headEl: Element | undefined;
36+
37+
visit(tree, 'element', (node, index, parent) => {
38+
if (node.tagName === 'head') headEl = node;
39+
40+
if (!parent || typeof index !== 'number') return;
41+
if (node.tagName !== 'jsx-email-cond') return;
42+
43+
matches.push({ index, node, parent });
44+
});
45+
46+
for (const { node, parent, index } of matches) {
47+
const props = (node.properties || {}) as Record<string, unknown>;
48+
const msoProp = (props['data-mso'] ?? (props as any).dataMso) as unknown;
49+
const msoAttr =
50+
typeof msoProp === 'undefined' ? void 0 : msoProp === 'false' ? false : Boolean(msoProp);
51+
const exprRaw = (props['data-expression'] ?? (props as any).dataExpression) as unknown;
52+
const exprAttr = typeof exprRaw === 'string' ? exprRaw : void 0;
53+
const headProp = (props['data-head'] ?? (props as any).dataHead) as unknown;
54+
const toHead =
55+
typeof headProp === 'undefined'
56+
? false
57+
: headProp === 'false'
58+
? false
59+
: Boolean(headProp);
60+
61+
let openRaw: string | undefined;
62+
let closeRaw: string | undefined;
63+
64+
if (msoAttr === false) {
65+
// Not MSO: <!--[if !mso]><!--> ... <!--<![endif]-->
66+
openRaw = '<!--[if !mso]><!-->';
67+
closeRaw = '<!--<![endif]-->';
68+
} else {
69+
// MSO / expression path
70+
const expression = exprAttr || (msoAttr === true ? 'mso' : void 0);
71+
if (expression) {
72+
openRaw = `<!--[if ${expression}]>`;
73+
// Older Outlook/Word HTML parsers prefer the self-closing
74+
// conditional terminator variant to avoid comment spillover
75+
// when adjacent comments appear. Use the `<![endif]/-->` form
76+
// for maximum compatibility.
77+
closeRaw = '<![endif]/-->';
78+
}
79+
}
80+
81+
// If no directive attributes present, leave the element in place.
82+
// eslint-disable-next-line no-continue
83+
if (!openRaw || !closeRaw) continue;
84+
85+
const before: Raw = { type: 'raw', value: openRaw };
86+
const after: Raw = { type: 'raw', value: closeRaw };
87+
const children = (node.children || []) as Content[];
88+
89+
if (toHead && headEl) {
90+
if (parent === headEl) {
91+
// Replace in place: open raw, original children, close raw.
92+
(parent as ParentWithRaw).children.splice(index, 1, before, ...children, after);
93+
} else {
94+
// Remove wrapper from current location
95+
(parent as ParentWithRaw).children.splice(index, 1);
96+
// Append the conditional to the <head>
97+
(headEl as unknown as ParentWithRaw).children.push(before, ...children, after);
98+
}
99+
} else {
100+
// Replace in place: open raw, original children, close raw.
101+
(parent as ParentWithRaw).children.splice(index, 1, before, ...children, after);
102+
}
103+
}
104+
};
105+
};
106+
};

packages/jsx-email/src/renderer/raw.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
import type { Comment, Content, Element, Literal, Parents, Root } from 'hast';
2+
3+
interface Match {
4+
index: number;
5+
node: Element;
6+
parent: Parents;
7+
}
8+
9+
interface ParentWithRaw {
10+
children: (Content | Raw)[];
11+
}
12+
13+
// `raw` is an unofficial HAST node used by rehype to pass through HTML verbatim.
14+
// Model it locally to avoid `any` casts while keeping the rest of the tree typed.
15+
interface Raw extends Literal {
16+
type: 'raw';
17+
value: string;
18+
}
19+
120
const START_TAG = '__COMMENT_START';
221
const END_TAG = '__COMMENT_END';
322
export function escapeForRawComponent(input: string): string {
@@ -10,3 +29,41 @@ export function unescapeForRawComponent(input: string): string {
1029
.replace(new RegExp(START_TAG, 'g'), '<!--')
1130
.replace(new RegExp(END_TAG, 'g'), '/-->');
1231
}
32+
33+
/**
34+
* Returns a rehype plugin that replaces `<jsx-email-raw><!--...--></jsx-email-raw>`
35+
* elements with a raw HTML node using the original, unescaped content.
36+
*
37+
* Mirrors the async factory pattern used by `getMovePlugin()`.
38+
*/
39+
export const getRawPlugin = async () => {
40+
const { visit } = await import('unist-util-visit');
41+
42+
return function rawPlugin() {
43+
return function transform(tree: Root) {
44+
const matches: Match[] = [];
45+
46+
visit(tree, 'element', (node, index, parent) => {
47+
if (!parent || typeof index !== 'number') return;
48+
if (node.tagName !== 'jsx-email-raw') return;
49+
50+
matches.push({ index, node: node as Element, parent });
51+
});
52+
53+
for (const { node, parent, index } of matches) {
54+
// The Raw component renders a single HTML comment child containing the
55+
// escaped raw content. Extract it and unescape back to the original.
56+
const commentChild = node.children.find((c): c is Comment => c.type === 'comment');
57+
58+
if (commentChild) {
59+
const rawHtml = unescapeForRawComponent(commentChild.value);
60+
61+
// Replace the wrapper element with a `raw` node to inject HTML verbatim.
62+
// rehype-stringify will pass this through when `allowDangerousHtml: true`.
63+
const rawNode: Raw = { type: 'raw', value: rawHtml };
64+
(parent as ParentWithRaw).children.splice(index, 1, rawNode);
65+
}
66+
}
67+
};
68+
};
69+
};

packages/jsx-email/src/renderer/render.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { htmlToText } from 'html-to-text';
2-
import { rehype } from 'rehype';
3-
import stringify from 'rehype-stringify';
42

53
import { type JsxEmailConfig, defineConfig, loadConfig, mergeConfig } from '../config.js';
64
import { callHook, callProcessHook } from '../plugins.js';
75
import type { PlainTextOptions, RenderOptions } from '../types.js';
86

7+
import { getConditionalPlugin } from './conditional.js';
98
import { jsxToString } from './jsx-to-string.js';
109
import { getMovePlugin } from './move-style.js';
11-
import { unescapeForRawComponent } from './raw.js';
10+
import { getRawPlugin, unescapeForRawComponent } from './raw.js';
1211

1312
export const jsxEmailTags = ['jsx-email-cond'];
1413

@@ -71,16 +70,24 @@ export const render = async (component: React.ReactElement, options?: RenderOpti
7170
};
7271

7372
const processHtml = async (config: JsxEmailConfig, html: string) => {
73+
const { rehype } = await import('rehype');
74+
const { default: stringify } = await import('rehype-stringify');
7475
const docType =
7576
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
7677
const movePlugin = await getMovePlugin();
78+
const rawPlugin = await getRawPlugin();
79+
const conditionalPlugin = await getConditionalPlugin();
7780
const settings = { emitParseErrors: true };
78-
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})>`, 'g');
81+
// Remove any stray jsx-email markers (with or without attributes)
82+
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})(?:\\s[^>]*)?>`, 'g');
7983

8084
// @ts-ignore: This is perfectly valid, see here: https://www.npmjs.com/package/rehype#examples
8185
const processor = rehype().data('settings', settings);
8286

8387
processor.use(movePlugin);
88+
processor.use(rawPlugin);
89+
// Ensure conditional processing happens after raw hoisting
90+
processor.use(conditionalPlugin);
8491
await callProcessHook({ config, processor });
8592

8693
const doc = await processor
@@ -95,9 +102,6 @@ const processHtml = async (config: JsxEmailConfig, html: string) => {
95102
let result = docType + String(doc).replace('<!doctype html>', '').replace('<head></head>', '');
96103

97104
result = result.replace(reJsxTags, '');
98-
result = result.replace(/<jsx-email-raw.*?><!--(.*?)--><\/jsx-email-raw>/g, (_, p1) =>
99-
unescapeForRawComponent(p1)
100-
);
101105

102106
return result;
103107
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
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>"`;
4+
5+
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>"`;

packages/jsx-email/test/.snapshots/conditional.test.tsx.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
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>"`;
3+
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>"`;
44
55
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>"`;
66
7-
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>"`;
7+
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>"`;
88
9-
exports[`<Conditional> component > renders with head: true 1`] = `"<head><!--[if mso]><h1>batman</h1><![endif]--></head>"`;
9+
exports[`<Conditional> component > renders with head: true 1`] = `"<jsx-email-cond data-mso="true" data-head="true"><h1>batman</h1></jsx-email-cond>"`;
1010
11-
exports[`<Conditional> component > renders with jsxToString 1`] = `"<jsx-email-cond><!--[if mso]><h1>batman</h1><![endif]--></jsx-email-cond>"`;
11+
exports[`<Conditional> component > renders with jsxToString 1`] = `"<jsx-email-cond data-mso="true"><h1>batman</h1></jsx-email-cond>"`;
1212
1313
exports[`<Conditional> component > throws on bad props 1`] = `[RangeError: jsx-email: Conditional expects the \`expression\` or \`mso\` prop to be defined]`;
1414

packages/jsx-email/test/.snapshots/debug.test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ exports[`render > renders with debug attributes 1`] = `
1111
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
1212
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes">
1313
<meta name="x-apple-disable-message-reformatting">
14-
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no"><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG></o:AllowPNG><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
14+
<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]/-->
1515
</head>
1616
1717
<body data-type="jsx-email/body" style="background-color:#ffffff;font-family:HelveticaNeue,Helvetica,Arial,sans-serif">
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
exports[`<Head> component > renders correctly 1`] = `"<head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><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"/><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"/><head><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG></o:AllowPNG><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--></head></head>"`;
3+
exports[`<Head> component > renders correctly 1`] = `"<head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><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"/><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"/><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></head>"`;
44
55
exports[`<Head> component > renders style tags 1`] = `
66
"<head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><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"/><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"/><style>body{
77
color: red;
8-
}</style><head><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG></o:AllowPNG><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--></head></head>"
8+
}</style><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></head>"
99
`;

packages/jsx-email/test/.snapshots/raw.test.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ exports[`<Raw> component > Should preserve content on plainText render 1`] = `"<
44
55
exports[`<Raw> component > Should render without escaping 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body><#if firstname & lastname>Ola!</#if></body></html>"`;
66
7+
exports[`<Raw> component > Should work correctly when it has linebreaks 1`] = `
8+
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body>
9+
Raw context
10+
</body></html>"
11+
`;
12+
713
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>"`;
814
915
exports[`<Raw> component > disablePlainTextOutput > Should not output to the plain text when enabled 1`] = `"Ola!"`;

0 commit comments

Comments
 (0)