Skip to content
Closed
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
45 changes: 0 additions & 45 deletions .github/workflows/test-smoke.yml

This file was deleted.

54 changes: 24 additions & 30 deletions packages/jsx-email/src/components/conditional.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,32 @@
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 {
// @ts-ignore
'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 +38,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);
}
}
};
};
};
18 changes: 11 additions & 7 deletions packages/jsx-email/src/renderer/render.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { htmlToText } from 'html-to-text';
import { rehype } from 'rehype';
import stringify from 'rehype-stringify';

import { type JsxEmailConfig, defineConfig, loadConfig, mergeConfig } from '../config.js';
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 @@ -71,16 +70,24 @@ export const render = async (component: React.ReactElement, options?: RenderOpti
};

const processHtml = async (config: JsxEmailConfig, html: string) => {
const { rehype } = await import('rehype');
const { default: stringify } = await import('rehype-stringify');
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>"`;
Loading
Loading