Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 23 additions & 30 deletions packages/jsx-email/src/components/conditional.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,31 @@
import React, { Suspense } from 'react';
import React from 'react';

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

declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
'jsx-email-cond': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
'data-expression'?: string;
'data-head'?: boolean;
'data-mso'?: boolean;
},
HTMLElement
>;
}
}
}

export interface ConditionalProps {
children?: React.ReactNode;
expression?: string;
head?: boolean;
mso?: boolean;
}

const notMso = (html: string) => `<!--[if !mso]><!-->${html}<!--<![endif]-->`;

const comment = (expression: string, html: string) => `<!--[if ${expression}]>${html}<![endif]-->`;

const Renderer = (props: ConditionalProps) => {
const { children, mso, head } = props;
let { expression } = props;
const html = useData(props, () => jsxToString(<>{children}</>));
let innerHtml = '';

if (mso === false) innerHtml = notMso(html);
else if (mso === true && !expression) expression = 'mso';
if (expression) innerHtml = comment(expression, html);

const Component = head ? 'head' : 'jsx-email-cond';

// @ts-ignore
// Note: This is perfectly valid. TS just expects lowercase tag names to match a specific type
return <Component dangerouslySetInnerHTML={{ __html: innerHtml }} />;
};

export const Conditional: JsxEmailComponent<ConditionalProps> = (props) => {
const { children, expression, mso } = props;
const { children, expression, mso, head } = props;

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

// Always render a JSX custom element with data-* markers.
// A rehype plugin will replace this element with proper conditional comments.
// @ts-ignore - lower-case custom element tag is valid
return (
<>
<Suspense fallback={<div>waiting</div>}>
<Renderer {...props}>{children}</Renderer>
</Suspense>
</>
<jsx-email-cond data-mso={mso} data-expression={expression} data-head={head}>
{children}
</jsx-email-cond>
);
};

Expand Down
15 changes: 5 additions & 10 deletions packages/jsx-email/src/components/head.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { BaseProps, JsxEmailComponent } from '../types.js';
import { debug } from '../debug.js';
import type { BaseProps, JsxEmailComponent } from '../types.js';

import { Conditional } from './conditional.js';
import { Raw } from './raw.js';

export interface HeadProps extends BaseProps<'head'> {
enableFormatDetection?: boolean;
Expand All @@ -27,15 +28,9 @@ export const Head: JsxEmailComponent<HeadProps> = ({
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
)}
{children}
<Conditional
head
mso
children={
// prettier-ignore
// @ts-expect-error: element don't exist
<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
}
/>
<Conditional head mso>
<Raw content="<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>" />
</Conditional>
</head>
);

Expand Down
106 changes: 106 additions & 0 deletions packages/jsx-email/src/renderer/conditional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Content, Element, Literal, Parents, Root } from 'hast';

// dynamic import of 'unist-util-visit' within factory to support CJS build

interface Match {
index: number;
node: Element;
parent: Parents;
}

// `raw` is an unofficial HAST node used by rehype to pass through HTML verbatim.
// Model it locally to avoid `any` casts while keeping the rest of the tree typed.
interface Raw extends Literal {
type: 'raw';
value: string;
}

interface ParentWithRaw {
children: (Content | Raw)[];
}

/**
* Returns a rehype plugin that replaces `<jsx-email-cond>` elements (from
* the Conditional component) with conditional comment wrappers, based on the
* `data-mso` and `data-expression` attributes.
*
* Mirrors the async factory pattern used by `getRawPlugin()`.
*/
export const getConditionalPlugin = async () => {
const { visit } = await import('unist-util-visit');

return function conditionalPlugin() {
return function transform(tree: Root) {
const matches: Match[] = [];
let headEl: Element | undefined;

visit(tree, 'element', (node, index, parent) => {
if (node.tagName === 'head') headEl = node;

if (!parent || typeof index !== 'number') return;
if (node.tagName !== 'jsx-email-cond') return;

matches.push({ index, node, parent });
});

for (const { node, parent, index } of matches) {
const props = (node.properties || {}) as Record<string, unknown>;
const msoProp = (props['data-mso'] ?? (props as any).dataMso) as unknown;
const msoAttr =
typeof msoProp === 'undefined' ? void 0 : msoProp === 'false' ? false : Boolean(msoProp);
const exprRaw = (props['data-expression'] ?? (props as any).dataExpression) as unknown;
const exprAttr = typeof exprRaw === 'string' ? exprRaw : void 0;
const headProp = (props['data-head'] ?? (props as any).dataHead) as unknown;
const toHead =
typeof headProp === 'undefined'
? false
: headProp === 'false'
? false
: Boolean(headProp);

let openRaw: string | undefined;
let closeRaw: string | undefined;

if (msoAttr === false) {
// Not MSO: <!--[if !mso]><!--> ... <!--<![endif]-->
openRaw = '<!--[if !mso]><!-->';
closeRaw = '<!--<![endif]-->';
} else {
// MSO / expression path
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]/-->';
}
}

// If no directive attributes present, leave the element in place.
// eslint-disable-next-line no-continue
if (!openRaw || !closeRaw) continue;

const before: Raw = { type: 'raw', value: openRaw };
const after: Raw = { type: 'raw', value: closeRaw };
const children = (node.children || []) as Content[];

if (toHead && headEl) {
if (parent === headEl) {
// Replace in place: open raw, original children, close raw.
(parent as ParentWithRaw).children.splice(index, 1, before, ...children, after);
} else {
// Remove wrapper from current location
(parent as ParentWithRaw).children.splice(index, 1);
// Append the conditional to the <head>
(headEl as unknown as ParentWithRaw).children.push(before, ...children, after);
}
} else {
// Replace in place: open raw, original children, close raw.
(parent as ParentWithRaw).children.splice(index, 1, before, ...children, after);
}
}
};
};
};
57 changes: 57 additions & 0 deletions packages/jsx-email/src/renderer/raw.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import type { Comment, Content, Element, Literal, Parents, Root } from 'hast';

interface Match {
index: number;
node: Element;
parent: Parents;
}

interface ParentWithRaw {
children: (Content | Raw)[];
}

// `raw` is an unofficial HAST node used by rehype to pass through HTML verbatim.
// Model it locally to avoid `any` casts while keeping the rest of the tree typed.
interface Raw extends Literal {
type: 'raw';
value: string;
}

const START_TAG = '__COMMENT_START';
const END_TAG = '__COMMENT_END';
export function escapeForRawComponent(input: string): string {
Expand All @@ -10,3 +29,41 @@ export function unescapeForRawComponent(input: string): string {
.replace(new RegExp(START_TAG, 'g'), '<!--')
.replace(new RegExp(END_TAG, 'g'), '/-->');
}

/**
* Returns a rehype plugin that replaces `<jsx-email-raw><!--...--></jsx-email-raw>`
* elements with a raw HTML node using the original, unescaped content.
*
* Mirrors the async factory pattern used by `getMovePlugin()`.
*/
export const getRawPlugin = async () => {
const { visit } = await import('unist-util-visit');

return function rawPlugin() {
return function transform(tree: Root) {
const matches: Match[] = [];

visit(tree, 'element', (node, index, parent) => {
if (!parent || typeof index !== 'number') return;
if (node.tagName !== 'jsx-email-raw') return;

matches.push({ index, node: node as Element, parent });
});

for (const { node, parent, index } of matches) {
// The Raw component renders a single HTML comment child containing the
// escaped raw content. Extract it and unescape back to the original.
const commentChild = node.children.find((c): c is Comment => c.type === 'comment');

if (commentChild) {
const rawHtml = unescapeForRawComponent(commentChild.value);

// Replace the wrapper element with a `raw` node to inject HTML verbatim.
// rehype-stringify will pass this through when `allowDangerousHtml: true`.
const rawNode: Raw = { type: 'raw', value: rawHtml };
(parent as ParentWithRaw).children.splice(index, 1, rawNode);
}
}
};
};
};
14 changes: 9 additions & 5 deletions packages/jsx-email/src/renderer/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { type JsxEmailConfig, defineConfig, loadConfig, mergeConfig } from '../c
import { callHook, callProcessHook } from '../plugins.js';
import type { PlainTextOptions, RenderOptions } from '../types.js';

import { getConditionalPlugin } from './conditional.js';
import { jsxToString } from './jsx-to-string.js';
import { getMovePlugin } from './move-style.js';
import { unescapeForRawComponent } from './raw.js';
import { getRawPlugin, unescapeForRawComponent } from './raw.js';

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

Expand Down Expand Up @@ -74,13 +75,19 @@ const processHtml = async (config: JsxEmailConfig, html: string) => {
const docType =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const movePlugin = await getMovePlugin();
const rawPlugin = await getRawPlugin();
const conditionalPlugin = await getConditionalPlugin();
const settings = { emitParseErrors: true };
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})>`, 'g');
// Remove any stray jsx-email markers (with or without attributes)
const reJsxTags = new RegExp(`<[/]?(${jsxEmailTags.join('|')})(?:\\s[^>]*)?>`, 'g');

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

processor.use(movePlugin);
processor.use(rawPlugin);
// Ensure conditional processing happens after raw hoisting
processor.use(conditionalPlugin);
await callProcessHook({ config, processor });

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

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

return result;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

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>"`;
8 changes: 4 additions & 4 deletions packages/jsx-email/test/.snapshots/conditional.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// 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`] = `"<head><!--[if mso]><h1>batman</h1><![endif]--></head>"`;
exports[`<Conditional> component > renders with head: true 1`] = `"<jsx-email-cond data-mso="true" data-head="true"><h1>batman</h1></jsx-email-cond>"`;

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

exports[`<Conditional> component > throws on bad props 1`] = `[RangeError: jsx-email: Conditional expects the \`expression\` or \`mso\` prop to be defined]`;

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: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/head.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

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>"`;
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>"`;

exports[`<Head> component > renders style tags 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"/><style>body{
color: red;
}</style><head><!--[if mso]><xml><o:OfficeDocumentSettings><o:AllowPNG></o:AllowPNG><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--></head></head>"
}</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>"
`;
6 changes: 6 additions & 0 deletions packages/jsx-email/test/.snapshots/raw.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ exports[`<Raw> component > Should preserve content on plainText render 1`] = `"<

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>"`;

exports[`<Raw> component > Should work correctly when it has linebreaks 1`] = `
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html><body>
Raw context
</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!"`;
Expand Down
20 changes: 20 additions & 0 deletions packages/jsx-email/test/conditional-endif-closer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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';

describe('<Conditional mso> closer', () => {
it('emits a self-closing MSO closer `<![endif]/-->`', async () => {
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).not.toContain('<!--[endif]---->' /* previously corrupted closer */);
// Robustness: ensure the closer appears exactly once
expect((html.match(/<!\[endif\]\/-->/g) || []).length).toBe(1);
});
});
Loading
Loading