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
2 changes: 1 addition & 1 deletion docs/components/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This component is wrapper around [QuickChart API](https://quickchart.io/) for ge
Add the graph component to your email template.

```jsx
import { Html, Body, Section, Graph } from 'jsx-email';
import { Body, Graph, Html, Section } from 'jsx-email';

const Email = () => {
return (
Expand Down
4 changes: 2 additions & 2 deletions docs/core/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ const compiledFiles = await compile({ files: [templatePath], hashFiles: false, o
Once compiled into a bundle, the file can be imported and passed to render such like:

```jsx
import { Template } from './.compiled/batman.js';

import { render } from 'jsx-email';

import { Template } from './.compiled/batman.js';

const html = render(<Template />);
```

Expand Down
1 change: 1 addition & 0 deletions docs/core/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ To instruct a render to use plugins, utilize a [Configuration File](/docs/core/c

```js
import { defineConfig } from 'jsx-email/config';

import { somePlugin } from './plugins/some-plugin';

export const config = defineConfig({
Expand Down
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
>;
}
}
}
Comment on lines +5 to +19

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module augmentation for react/jsx-runtime uses // @ts-ignore and globally declares an intrinsic element. This is a fairly heavy-handed TS escape and can create friction if the package is consumed in different JSX runtimes or with different TS configs.

If you only need typing locally for this package, prefer putting the intrinsic element typing in a .d.ts file and avoid @ts-ignore, or scope it via JSX.IntrinsicElements in a types module that’s part of your build output.

Suggestion

Move the intrinsic element declaration into a dedicated *.d.ts (e.g. packages/jsx-email/src/types/jsx-email-elements.d.ts) and remove the // @ts-ignore.

Additionally, consider augmenting global JSX (or react) depending on your supported JSX runtimes.

Reply with "@CharlieHelps yes please" if you want me to add a commit that relocates this typing and eliminates the suppression.


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);
Comment on lines +46 to +59

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getConditionalPlugin() uses (props as any).dataMso / dataExpression / dataHead. This is an unnecessary any escape that weakens the guarantees in a critical HTML-generation path.

Since you already cast node.properties to Record<string, unknown>, you can handle the alternate property casing without any by reading keys from that record.

Suggestion

Avoid any by accessing camel-cased keys via the same Record<string, unknown>:

const props = (node.properties ?? {}) as Record<string, unknown>;
const msoProp = props['data-mso'] ?? props.dataMso;
const exprRaw = props['data-expression'] ?? props.dataExpression;
const headProp = props['data-head'] ?? props.dataHead;

This keeps the code type-safe without broad any casts.

Reply with "@CharlieHelps yes please" if you want me to add a commit removing the any and adjusting the parsing accordingly.


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);
}
}
Comment on lines +33 to +103

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getConditionalPlugin() collects matches with (index, parent) and then mutates the tree using the original indices. This is unsafe when multiple <jsx-email-cond> nodes share the same parent: after the first splice() replacement (which inserts multiple nodes), later index values for the same parent become stale and will point at the wrong node.

Example failure mode: two sibling conditionals in the same parent—after expanding the first into [before,...children,after], the second match’s stored index will no longer refer to the second conditional element.

This can lead to corrupted output, misplaced conditionals, or dropped content—especially in templates with multiple conditionals in <body> or <head>.

Suggestion

Consider transforming in a single pass without storing indices, or mutate safely by iterating per-parent in descending index order.

One robust pattern:

  • During visit, group matches by parent and collect indices.
  • For each parent, sort matches by index descending and then splice.

Sketch:

const byParent = new Map<Parents, Match[]>();
...
const list = byParent.get(parent) ?? [];
list.push({ parent, index, node });
byParent.set(parent, list);
...
for (const [parent, list] of byParent) {
  list.sort((a,b) => b.index - a.index);
  for (const { node, index } of list) {
    // splice safely
  }
}

If you’d like, reply with "@CharlieHelps yes please" and I can add a commit implementing this safely (including coverage for multiple siblings).

};
};
};
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;
};
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
Loading
Loading