diff --git a/packages/jsx-email/src/components/conditional.tsx b/packages/jsx-email/src/components/conditional.tsx index 8c039076..357c34eb 100644 --- a/packages/jsx-email/src/components/conditional.tsx +++ b/packages/jsx-email/src/components/conditional.tsx @@ -1,9 +1,22 @@ -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 & { + 'data-expression'?: string; + 'data-head'?: boolean; + 'data-mso'?: boolean; + }, + HTMLElement + >; + } + } +} + export interface ConditionalProps { children?: React.ReactNode; expression?: string; @@ -11,29 +24,8 @@ export interface ConditionalProps { mso?: boolean; } -const notMso = (html: string) => `${html}`; - -const comment = (expression: string, html: string) => ``; - -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 ; -}; - export const Conditional: JsxEmailComponent = (props) => { - const { children, expression, mso } = props; + const { children, expression, mso, head } = props; if (typeof expression === 'undefined' && typeof mso === 'undefined') throw new RangeError( @@ -45,12 +37,13 @@ export const Conditional: JsxEmailComponent = (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 ( - <> - waiting}> - {children} - - + + {children} + ); }; diff --git a/packages/jsx-email/src/components/head.tsx b/packages/jsx-email/src/components/head.tsx index ff249d03..ca78a26d 100644 --- a/packages/jsx-email/src/components/head.tsx +++ b/packages/jsx-email/src/components/head.tsx @@ -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; @@ -27,15 +28,9 @@ export const Head: JsxEmailComponent = ({ )} {children} - 96 - } - /> + + + ); diff --git a/packages/jsx-email/src/renderer/conditional.ts b/packages/jsx-email/src/renderer/conditional.ts new file mode 100644 index 00000000..5fc78bbc --- /dev/null +++ b/packages/jsx-email/src/renderer/conditional.ts @@ -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 `` 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; + 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: ... + openRaw = ''; + closeRaw = ''; + } else { + // MSO / expression path + const expression = exprAttr || (msoAttr === true ? 'mso' : void 0); + if (expression) { + openRaw = `` form + // for maximum compatibility. + closeRaw = ''; + } + } + + // 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 + (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); + } + } + }; + }; +}; diff --git a/packages/jsx-email/src/renderer/raw.ts b/packages/jsx-email/src/renderer/raw.ts index 7721c226..a10eb6ad 100644 --- a/packages/jsx-email/src/renderer/raw.ts +++ b/packages/jsx-email/src/renderer/raw.ts @@ -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 { @@ -10,3 +29,41 @@ export function unescapeForRawComponent(input: string): string { .replace(new RegExp(START_TAG, 'g'), ''); } + +/** + * Returns a rehype plugin that replaces `` + * 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); + } + } + }; + }; +}; diff --git a/packages/jsx-email/src/renderer/render.ts b/packages/jsx-email/src/renderer/render.ts index a5229b8d..4e7e13cb 100644 --- a/packages/jsx-email/src/renderer/render.ts +++ b/packages/jsx-email/src/renderer/render.ts @@ -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']; @@ -74,13 +75,19 @@ const processHtml = async (config: JsxEmailConfig, html: string) => { const docType = ''; 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 @@ -95,9 +102,6 @@ const processHtml = async (config: JsxEmailConfig, html: string) => { let result = docType + String(doc).replace('', '').replace('', ''); result = result.replace(reJsxTags, ''); - result = result.replace(/<\/jsx-email-raw>/g, (_, p1) => - unescapeForRawComponent(p1) - ); return result; }; diff --git a/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap b/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap new file mode 100644 index 00000000..b8722635 --- /dev/null +++ b/packages/jsx-email/test/.snapshots/conditional-raw.test.tsx.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Raw in Conditional > Raw in Conditional 1`] = `""`; + +exports[`Raw in Conditional > Raw in Conditional 2`] = `""`; diff --git a/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap b/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap index f15449d5..b36a58ae 100644 --- a/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/conditional.test.tsx.snap @@ -1,14 +1,14 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` component > renders expression 1`] = `""`; +exports[` component > renders expression 1`] = `""`; exports[` component > renders mso: false 1`] = `"

batman

"`; -exports[` component > renders mso: true 1`] = `""`; +exports[` component > renders mso: true 1`] = `""`; -exports[` component > renders with head: true 1`] = `""`; +exports[` component > renders with head: true 1`] = `"

batman

"`; -exports[` component > renders with jsxToString 1`] = `""`; +exports[` component > renders with jsxToString 1`] = `"

batman

"`; exports[` component > throws on bad props 1`] = `[RangeError: jsx-email: Conditional expects the \`expression\` or \`mso\` prop to be defined]`; diff --git a/packages/jsx-email/test/.snapshots/debug.test.tsx.snap b/packages/jsx-email/test/.snapshots/debug.test.tsx.snap index eab541a1..fb89dce3 100644 --- a/packages/jsx-email/test/.snapshots/debug.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/debug.test.tsx.snap @@ -11,7 +11,7 @@ exports[`render > renders with debug attributes 1`] = ` - + diff --git a/packages/jsx-email/test/.snapshots/head.test.tsx.snap b/packages/jsx-email/test/.snapshots/head.test.tsx.snap index 05fd0bfb..0068ffdd 100644 --- a/packages/jsx-email/test/.snapshots/head.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/head.test.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` component > renders correctly 1`] = `""`; +exports[` component > renders correctly 1`] = `""`; exports[` component > renders style tags 1`] = ` "" + }" `; diff --git a/packages/jsx-email/test/.snapshots/raw.test.tsx.snap b/packages/jsx-email/test/.snapshots/raw.test.tsx.snap index 6f49ea9b..d7195d31 100644 --- a/packages/jsx-email/test/.snapshots/raw.test.tsx.snap +++ b/packages/jsx-email/test/.snapshots/raw.test.tsx.snap @@ -4,6 +4,12 @@ exports[` component > Should preserve content on plainText render 1`] = `"< exports[` component > Should render without escaping 1`] = `"<#if firstname & lastname>Ola!"`; +exports[` component > Should work correctly when it has linebreaks 1`] = ` +" + Raw context + " +`; + exports[` component > Should work correctly with a comment as a content 1`] = `"Ola!"`; exports[` component > disablePlainTextOutput > Should not output to the plain text when enabled 1`] = `"Ola!"`; diff --git a/packages/jsx-email/test/conditional-endif-closer.test.tsx b/packages/jsx-email/test/conditional-endif-closer.test.tsx new file mode 100644 index 00000000..ff98b18a --- /dev/null +++ b/packages/jsx-email/test/conditional-endif-closer.test.tsx @@ -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(' closer', () => { + it('emits a self-closing MSO closer ``', async () => { + const html = await render( + + hi'} /> + + ); + + expect(html).toContain('' /* Outlook-friendly closer */); + expect(html).not.toContain('' /* slashless closer */); + expect(html).not.toContain('' /* previously corrupted closer */); + // Robustness: ensure the closer appears exactly once + expect((html.match(//g) || []).length).toBe(1); + }); +}); diff --git a/packages/jsx-email/test/conditional-raw-nodup.test.tsx b/packages/jsx-email/test/conditional-raw-nodup.test.tsx new file mode 100644 index 00000000..fe6b0d2c --- /dev/null +++ b/packages/jsx-email/test/conditional-raw-nodup.test.tsx @@ -0,0 +1,52 @@ +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 count(haystack: string, needle: string) { + // Prevent hangs on empty needle + if (needle.length === 0) return 0; + // Avoid regex escaping pitfalls by scanning linearly + let i = 0; + let c = 0; + for (let at = haystack.indexOf(needle, i); at !== -1; at = haystack.indexOf(needle, i)) { + c += 1; + i = at + needle.length; + } + return c; +} + +describe('Conditional + Raw – no duplication', () => { + it('renders the inner Raw block exactly once inside a single MSO conditional', async () => { + const table = [ + '', + '', + '' + ].join(''); + + const html = await render( + + + + ); + + // Exactly one conditional block, one closer, and one copy of the inner table + const opener = ''; + expect(count(html, opener)).toBe(1); + expect(count(html, closer)).toBe(1); + expect(count(html, 'id="msoTableTest"')).toBe(1); + expect(count(html, 'data-testid="unique"')).toBe(1); + + // Sanity: should not contain an unguarded duplicate of the table before/after the block + const firstIdx = html.indexOf(opener); + const lastIdx = html.lastIndexOf(closer); + expect(firstIdx).toBeGreaterThanOrEqual(0); + expect(lastIdx).toBeGreaterThan(firstIdx); + + const before = html.slice(0, firstIdx); + const after = html.slice(lastIdx + closer.length); + expect(before).not.toContain('id="msoTableTest"'); + expect(after).not.toContain('id="msoTableTest"'); + }); +}); diff --git a/packages/jsx-email/test/conditional-raw.test.tsx b/packages/jsx-email/test/conditional-raw.test.tsx new file mode 100644 index 00000000..c25a2a9c --- /dev/null +++ b/packages/jsx-email/test/conditional-raw.test.tsx @@ -0,0 +1,28 @@ +/** + * Note: Parts of this file are derived from [Hyperons](https://github.com/i-like-robots/hyperons). + * @license MIT + */ +import type { FC } from 'react'; +// @ts-ignore +import React from 'react'; + +import { Conditional, Raw, render } from '../dist/index.js'; +import { jsxToString } from '../src/renderer/jsx-to-string.js'; + +const Template: FC<{}> = () => ( + <> + + + + +); + +describe('Raw in Conditional', async () => { + it('Raw in Conditional', async () => { + const htmlRes = await jsxToString(