Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion packages/tailwind/src/tailwind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';
import type { Config } from 'tailwindcss';
import { useSuspensedPromise } from './hooks/use-suspended-promise';
import { sanitizeStyleSheet } from './sanitize-stylesheet';
import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients';
import { extractRulesPerClass } from './utils/css/extract-rules-per-class';
import { getCustomProperties } from './utils/css/get-custom-properties';
import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules';
Expand Down Expand Up @@ -136,7 +137,9 @@ export function Tailwind({ children, config }: TailwindProps) {

const styleElement = (
<style
dangerouslySetInnerHTML={{ __html: generate(nonInlineStyles) }}
dangerouslySetInnerHTML={{
__html: downlevelForEmailClients(generate(nonInlineStyles)),
}}
/>
);

Expand Down
137 changes: 137 additions & 0 deletions packages/tailwind/src/utils/css/downlevel-for-email-clients.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { downlevelForEmailClients } from './downlevel-for-email-clients';

describe('downlevelForEmailClients', () => {
describe('range syntax', () => {
it('converts width>= to min-width', () => {
expect(
downlevelForEmailClients(
'@media (width>=40rem){.sm_p-4{padding:1rem}}',
),
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
});

it('converts width<= to max-width', () => {
expect(
downlevelForEmailClients(
'@media (width<=40rem){.max-sm_p-4{padding:1rem}}',
),
).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}');
});

it('converts width< to max-width', () => {
expect(
downlevelForEmailClients(
'.max-sm_text-red-600{@media (width<40rem){color:red!important}}',
),
).toBe(
'@media (max-width:40rem){.max-sm_text-red-600{color:red!important}}',
);
});

it('converts width> to min-width', () => {
expect(
downlevelForEmailClients(
'@media (width>40rem){.sm_p-4{padding:1rem}}',
),
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}');
});

it('converts height range syntax', () => {
expect(
downlevelForEmailClients('@media (height>=600px){.tall{color:red}}'),
).toBe('@media (min-height:600px){.tall{color:red}}');
});

it('does not match partial property names', () => {
// prefers-color-scheme should not be affected
expect(
downlevelForEmailClients(
'@media (prefers-color-scheme:dark){.dark{color:white}}',
),
).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}');
});
});

describe('unnesting', () => {
it('unnests @media from inside a selector', () => {
expect(
downlevelForEmailClients(
'.sm_bg-red-300{@media (min-width:40rem){background-color:red!important}}',
),
).toBe(
'@media (min-width:40rem){.sm_bg-red-300{background-color:red!important}}',
);
});

it('handles combined range syntax + nesting', () => {
expect(
downlevelForEmailClients(
'.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}',
),
).toBe(
'@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}',
);
});

it('handles multiple concatenated rules', () => {
const input =
'.sm_bg-red-300{@media (width>=40rem){background-color:red!important}}' +
'.md_bg-red-400{@media (width>=48rem){background-color:blue!important}}' +
'.lg_bg-red-500{@media (width>=64rem){background-color:green!important}}';

const expected =
'@media (min-width:40rem){.sm_bg-red-300{background-color:red!important}}' +
'@media (min-width:48rem){.md_bg-red-400{background-color:blue!important}}' +
'@media (min-width:64rem){.lg_bg-red-500{background-color:green!important}}';

expect(downlevelForEmailClients(input)).toBe(expected);
});

it('preserves dark mode media queries and unnests them', () => {
expect(
downlevelForEmailClients(
'.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}',
),
).toBe(
'@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}',
);
});

it('preserves rules without nested @media', () => {
expect(
downlevelForEmailClients('.bg-red-300{background-color:red}'),
).toBe('.bg-red-300{background-color:red}');
});

it('preserves already top-level @media rules', () => {
expect(
downlevelForEmailClients(
'@media (min-width:40rem){.sm_p-4{padding:1rem!important}}',
),
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}');
});

it('passes through &:hover nesting unchanged', () => {
// &:hover nesting is a separate problem — email clients don't support
// :hover reliably anyway. This function only handles @media unnesting.
const input =
'.hover_bg-red-600{&:hover{@media (hover:hover){background-color:red!important}}}';
expect(downlevelForEmailClients(input)).toBe(input);
});

it('handles multiple @media nested in one selector', () => {
const input =
'.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}';

const expected =
'@media (min-width:40rem){.multi{color:red!important}}' +
'@media (min-width:48rem){.multi{color:blue!important}}';

expect(downlevelForEmailClients(input)).toBe(expected);
});

it('handles empty input', () => {
expect(downlevelForEmailClients('')).toBe('');
});
});
});
184 changes: 184 additions & 0 deletions packages/tailwind/src/utils/css/downlevel-for-email-clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we should not use regex here, we already use csstree, and regexes like this is going to be way too much to maintain.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Makes sense, I'll rewrite it using css-tree. I will push an update.

* Downlevels modern CSS features that email clients don't support:
*
* 1. Media Queries Level 4 range syntax → legacy min-width/max-width
* `(width>=40rem)` → `(min-width:40rem)`
*
* 2. CSS Nesting (nested @media inside selectors) → top-level @media
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
* → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
*
* Gmail, Outlook, Yahoo, and most email clients don't support either feature.
* See: https://www.caniemail.com/features/css-at-media/
* https://www.caniemail.com/features/css-nesting/
*/

/**
* Convert Media Queries Level 4 range syntax to legacy min-width/max-width.
*
* Tailwind v4 generates `@media (width>=40rem)` but email clients
* only understand `@media (min-width:40rem)`.
*
* Note: strict `<` and `>` are approximated as `<=` and `>=` respectively.
* The sub-pixel difference is irrelevant for email rendering.
*/
function downlevelRangeSyntax(css: string): string {
// Order matters: >= and <= must be replaced before > and <
return css
.replace(/\(\s*width\s*>=\s*([^)]+)\)/g, '(min-width:$1)')
.replace(/\(\s*width\s*<=\s*([^)]+)\)/g, '(max-width:$1)')
.replace(/\(\s*width\s*>\s*([^)]+)\)/g, '(min-width:$1)')
.replace(/\(\s*width\s*<\s*([^)]+)\)/g, '(max-width:$1)')
.replace(/\(\s*height\s*>=\s*([^)]+)\)/g, '(min-height:$1)')
.replace(/\(\s*height\s*<=\s*([^)]+)\)/g, '(max-height:$1)')
.replace(/\(\s*height\s*>\s*([^)]+)\)/g, '(min-height:$1)')
.replace(/\(\s*height\s*<\s*([^)]+)\)/g, '(max-height:$1)');
}

/**
* Extract a brace-balanced block starting at position `start` (which should
* point to the opening `{`). Returns the index of the closing `}`.
*/
function findClosingBrace(css: string, start: number): number {
let depth = 0;
for (let i = start; i < css.length; i++) {
if (css[i] === '{') depth++;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
else if (css[i] === '}') {
depth--;
if (depth === 0) return i;
}
}
return css.length;
}

/**
* Parse a block's content into top-level segments, respecting brace nesting.
* Each segment is a complete nested rule or at-rule.
*/
function parseBlockSegments(blockContent: string): string[] {
const segments: string[] = [];
let i = 0;

while (i < blockContent.length) {
// Skip whitespace
while (i < blockContent.length && /\s/.test(blockContent[i])) i++;
if (i >= blockContent.length) break;

const segStart = i;

// Read until we find a '{' (start of a block) or run out
while (i < blockContent.length && blockContent[i] !== '{') i++;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

if (i >= blockContent.length) {
// No block found — this is bare declarations (shouldn't happen in
// the non-inlinable output, but handle gracefully)
segments.push(blockContent.slice(segStart).trim());
break;
}

// Find the matching closing brace
const closeIdx = findClosingBrace(blockContent, i);
segments.push(blockContent.slice(segStart, closeIdx + 1).trim());
i = closeIdx + 1;
}

return segments.filter((s) => s.length > 0);
}

/**
* Unnest `@media` rules that are nested inside selectors.
*
* Tailwind v4 with CSS nesting generates:
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
*
* Email clients need:
* `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
*
* This handles one level of nesting (selector → @media → declarations).
* Deeper nesting (e.g. `&:hover` inside `@media`) is preserved as-is
* since pseudo-class support in email clients is limited regardless.
*/
function unnestMediaQueries(css: string): string {
const result: string[] = [];
let i = 0;

while (i < css.length) {
// Skip whitespace
while (i < css.length && /\s/.test(css[i])) {
result.push(css[i]);
i++;
}

if (i >= css.length) break;

// Check if this is already a top-level at-rule (@media, @supports, etc.)
if (css[i] === '@') {
const atStart = i;
while (i < css.length && css[i] !== '{') i++;
if (i >= css.length) {
result.push(css.slice(atStart));
break;
}
const closingBrace = findClosingBrace(css, i);
result.push(css.slice(atStart, closingBrace + 1));
i = closingBrace + 1;
continue;
}

// This should be a selector — read until '{'
const selectorStart = i;
while (i < css.length && css[i] !== '{') i++;
if (i >= css.length) {
result.push(css.slice(selectorStart));
break;
}

const selector = css.slice(selectorStart, i).trim();
const outerOpen = i;
const outerClose = findClosingBrace(css, outerOpen);
const blockContent = css.slice(outerOpen + 1, outerClose).trim();

// Parse the block into top-level segments to handle multiple nested @media
const segments = parseBlockSegments(blockContent);
const mediaSegments: string[] = [];
const otherSegments: string[] = [];

for (const segment of segments) {
if (/^@media\s/.test(segment)) {
mediaSegments.push(segment);
} else {
otherSegments.push(segment);
}
}

if (mediaSegments.length > 0) {
// Emit non-@media segments as a regular rule (if any)
if (otherSegments.length > 0) {
result.push(`${selector}{${otherSegments.join('')}}`);
}

// Unnest each @media: wrap the selector inside the @media
for (const mediaSegment of mediaSegments) {
const braceIdx = mediaSegment.indexOf('{');
const mediaHeader = mediaSegment.slice(0, braceIdx).trim();
const mediaClose = findClosingBrace(mediaSegment, braceIdx);
const mediaBody = mediaSegment.slice(braceIdx + 1, mediaClose);

result.push(`${mediaHeader}{${selector}{${mediaBody}}}`);
}
} else {
// No nested @media — emit the entire rule as-is
result.push(css.slice(selectorStart, outerClose + 1));
}

i = outerClose + 1;
}

return result.join('');
}

export function downlevelForEmailClients(css: string): string {
css = downlevelRangeSyntax(css);
css = unnestMediaQueries(css);
return css;
}
Loading