react-email 6.0.6 → 6.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cli/index.mjs +1 -1
- package/dist/index.cjs +124 -0
- package/dist/index.mjs +124 -0
- package/package.json +1 -1
- package/src/components/tailwind/tailwind.spec.tsx +12 -12
- package/src/components/tailwind/tailwind.tsx +2 -0
- package/src/components/tailwind/utils/css/downlevel-for-email-clients.spec.ts +119 -0
- package/src/components/tailwind/utils/css/downlevel-for-email-clients.ts +244 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# react-email
|
|
2
2
|
|
|
3
|
+
## 6.0.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 65525e0: Tailwind: parse non inline configuration variables
|
|
8
|
+
|
|
9
|
+
## 6.0.7
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 87a2486: undo nesting of all media queries, and replace >= <= exxpressions with min-width/max-width on the Tailwind component
|
|
14
|
+
|
|
3
15
|
## 6.0.6
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/cli/index.mjs
CHANGED
|
@@ -6522,7 +6522,7 @@ const getEmailsDirectoryMetadata = async (absolutePathToEmailsDirectory, keepFil
|
|
|
6522
6522
|
//#region package.json
|
|
6523
6523
|
var package_default = {
|
|
6524
6524
|
name: "react-email",
|
|
6525
|
-
version: "6.0.
|
|
6525
|
+
version: "6.0.8",
|
|
6526
6526
|
description: "A live preview of your emails right in your browser.",
|
|
6527
6527
|
bin: { "email": "./dist/cli/index.mjs" },
|
|
6528
6528
|
type: "module",
|
package/dist/index.cjs
CHANGED
|
@@ -38316,6 +38316,129 @@ function useSuspensedPromise(promiseFn, key) {
|
|
|
38316
38316
|
throw state.promise;
|
|
38317
38317
|
}
|
|
38318
38318
|
//#endregion
|
|
38319
|
+
//#region src/components/tailwind/utils/css/downlevel-for-email-clients.ts
|
|
38320
|
+
/**
|
|
38321
|
+
* Downlevels modern CSS features that email clients don't support,
|
|
38322
|
+
* operating on a css-tree StyleSheet AST.
|
|
38323
|
+
*
|
|
38324
|
+
* 1. CSS Nesting: unnests @media rules from inside selectors
|
|
38325
|
+
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
|
|
38326
|
+
* → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
|
|
38327
|
+
*
|
|
38328
|
+
* 2. Media Queries Level 4 range syntax → legacy min-width/max-width
|
|
38329
|
+
* `(width>=40rem)` → `(min-width:40rem)`
|
|
38330
|
+
*
|
|
38331
|
+
* Gmail, Outlook, Yahoo, and most email clients don't support either feature.
|
|
38332
|
+
* See: https://www.caniemail.com/features/css-at-media/
|
|
38333
|
+
* https://www.caniemail.com/features/css-nesting/
|
|
38334
|
+
*/
|
|
38335
|
+
/**
|
|
38336
|
+
* Unnest @media at-rules from inside regular rules, and downlevel
|
|
38337
|
+
* range media query syntax to legacy min-width/max-width.
|
|
38338
|
+
*
|
|
38339
|
+
* Mutates the stylesheet in place.
|
|
38340
|
+
*/
|
|
38341
|
+
function downlevelForEmailClients(styleSheet) {
|
|
38342
|
+
unnestMediaQueries(styleSheet);
|
|
38343
|
+
downlevelRangeMediaQueries(styleSheet);
|
|
38344
|
+
}
|
|
38345
|
+
/**
|
|
38346
|
+
* Walk the stylesheet and unnest any @media/@supports rules that are nested
|
|
38347
|
+
* inside regular rules. For each, the parent Rule's selector wraps the
|
|
38348
|
+
* at-rule's body.
|
|
38349
|
+
*
|
|
38350
|
+
* Before: `.sm_p-4 { @media (...) { padding: 1rem } }`
|
|
38351
|
+
* After: `@media (...) { .sm_p-4 { padding: 1rem } }`
|
|
38352
|
+
*/
|
|
38353
|
+
function unnestMediaQueries(styleSheet) {
|
|
38354
|
+
const transforms = [];
|
|
38355
|
+
walk(styleSheet, {
|
|
38356
|
+
visit: "Rule",
|
|
38357
|
+
enter(rule, item, list) {
|
|
38358
|
+
if (!rule.block || !item) return;
|
|
38359
|
+
const nestedAtrules = [];
|
|
38360
|
+
const remainingChildren = [];
|
|
38361
|
+
rule.block.children.forEach((child) => {
|
|
38362
|
+
if (child.type === "Atrule" && (child.name === "media" || child.name === "supports")) nestedAtrules.push(child);
|
|
38363
|
+
else remainingChildren.push(child);
|
|
38364
|
+
});
|
|
38365
|
+
if (nestedAtrules.length > 0) transforms.push({
|
|
38366
|
+
parentRule: rule,
|
|
38367
|
+
parentItem: item,
|
|
38368
|
+
parentList: list,
|
|
38369
|
+
nestedAtrules,
|
|
38370
|
+
remainingChildren
|
|
38371
|
+
});
|
|
38372
|
+
}
|
|
38373
|
+
});
|
|
38374
|
+
for (let i = transforms.length - 1; i >= 0; i--) {
|
|
38375
|
+
const { parentRule, parentItem, parentList, nestedAtrules, remainingChildren } = transforms[i];
|
|
38376
|
+
const replacements = new List();
|
|
38377
|
+
if (remainingChildren.length > 0) {
|
|
38378
|
+
parentRule.block.children = new List().fromArray(remainingChildren);
|
|
38379
|
+
replacements.appendData(parentRule);
|
|
38380
|
+
}
|
|
38381
|
+
for (const atrule of nestedAtrules) {
|
|
38382
|
+
const wrappedRule = {
|
|
38383
|
+
type: "Rule",
|
|
38384
|
+
prelude: clone(parentRule.prelude),
|
|
38385
|
+
block: {
|
|
38386
|
+
type: "Block",
|
|
38387
|
+
children: atrule.block ? atrule.block.children : new List()
|
|
38388
|
+
}
|
|
38389
|
+
};
|
|
38390
|
+
const newAtrule = {
|
|
38391
|
+
type: "Atrule",
|
|
38392
|
+
name: atrule.name,
|
|
38393
|
+
prelude: atrule.prelude,
|
|
38394
|
+
block: {
|
|
38395
|
+
type: "Block",
|
|
38396
|
+
children: new List().fromArray([wrappedRule])
|
|
38397
|
+
}
|
|
38398
|
+
};
|
|
38399
|
+
replacements.appendData(newAtrule);
|
|
38400
|
+
}
|
|
38401
|
+
parentList.replace(parentItem, replacements);
|
|
38402
|
+
}
|
|
38403
|
+
}
|
|
38404
|
+
/**
|
|
38405
|
+
* Walk all nodes and downlevel range syntax (`FeatureRange`) inside @media
|
|
38406
|
+
* preludes to legacy `Feature` nodes (`min-width` / `max-width`).
|
|
38407
|
+
*/
|
|
38408
|
+
function downlevelRangeMediaQueries(styleSheet) {
|
|
38409
|
+
const replacements = [];
|
|
38410
|
+
walk(styleSheet, { enter(originalNode, item) {
|
|
38411
|
+
const node = originalNode;
|
|
38412
|
+
if (item && node.type === "FeatureRange") {
|
|
38413
|
+
const replacement = downlevelFeatureRange(node);
|
|
38414
|
+
if (replacement) replacements.push({
|
|
38415
|
+
item,
|
|
38416
|
+
replacement
|
|
38417
|
+
});
|
|
38418
|
+
}
|
|
38419
|
+
} });
|
|
38420
|
+
for (const { item, replacement } of replacements) item.data = replacement;
|
|
38421
|
+
}
|
|
38422
|
+
/**
|
|
38423
|
+
* Convert a `FeatureRange` node to a `Feature` node (legacy min-/max- syntax).
|
|
38424
|
+
*
|
|
38425
|
+
* For `width >= 40rem`: left=Identifier("width"), leftComparison=">=", middle=Dimension("40","rem")
|
|
38426
|
+
* Result: { type: "Feature", name: "min-width", value: Dimension("40","rem") }
|
|
38427
|
+
*/
|
|
38428
|
+
function downlevelFeatureRange(range) {
|
|
38429
|
+
if (range.left.type !== "Identifier") return null;
|
|
38430
|
+
let prefix;
|
|
38431
|
+
if (range.leftComparison === ">=" || range.leftComparison === ">") prefix = "min-";
|
|
38432
|
+
else if (range.leftComparison === "<=" || range.leftComparison === "<") prefix = "max-";
|
|
38433
|
+
else return null;
|
|
38434
|
+
return {
|
|
38435
|
+
type: "Feature",
|
|
38436
|
+
kind: "media",
|
|
38437
|
+
name: `${prefix}${range.left.name}`,
|
|
38438
|
+
value: range.middle
|
|
38439
|
+
};
|
|
38440
|
+
}
|
|
38441
|
+
//#endregion
|
|
38319
38442
|
//#region src/components/tailwind/utils/compatibility/sanitize-class-name.ts
|
|
38320
38443
|
const digitToNameMap = {
|
|
38321
38444
|
"0": "zero",
|
|
@@ -40439,6 +40562,7 @@ function Tailwind({ children, config }) {
|
|
|
40439
40562
|
children: new List().fromArray(Array.from(nonInlinableRules.values()))
|
|
40440
40563
|
};
|
|
40441
40564
|
sanitizeNonInlinableRules(nonInlineStyles);
|
|
40565
|
+
downlevelForEmailClients(nonInlineStyles);
|
|
40442
40566
|
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
|
|
40443
40567
|
let appliedNonInlineStyles = false;
|
|
40444
40568
|
mappedChildren = mapReactTree(mappedChildren, (node) => {
|
package/dist/index.mjs
CHANGED
|
@@ -38295,6 +38295,129 @@ function useSuspensedPromise(promiseFn, key) {
|
|
|
38295
38295
|
throw state.promise;
|
|
38296
38296
|
}
|
|
38297
38297
|
//#endregion
|
|
38298
|
+
//#region src/components/tailwind/utils/css/downlevel-for-email-clients.ts
|
|
38299
|
+
/**
|
|
38300
|
+
* Downlevels modern CSS features that email clients don't support,
|
|
38301
|
+
* operating on a css-tree StyleSheet AST.
|
|
38302
|
+
*
|
|
38303
|
+
* 1. CSS Nesting: unnests @media rules from inside selectors
|
|
38304
|
+
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
|
|
38305
|
+
* → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
|
|
38306
|
+
*
|
|
38307
|
+
* 2. Media Queries Level 4 range syntax → legacy min-width/max-width
|
|
38308
|
+
* `(width>=40rem)` → `(min-width:40rem)`
|
|
38309
|
+
*
|
|
38310
|
+
* Gmail, Outlook, Yahoo, and most email clients don't support either feature.
|
|
38311
|
+
* See: https://www.caniemail.com/features/css-at-media/
|
|
38312
|
+
* https://www.caniemail.com/features/css-nesting/
|
|
38313
|
+
*/
|
|
38314
|
+
/**
|
|
38315
|
+
* Unnest @media at-rules from inside regular rules, and downlevel
|
|
38316
|
+
* range media query syntax to legacy min-width/max-width.
|
|
38317
|
+
*
|
|
38318
|
+
* Mutates the stylesheet in place.
|
|
38319
|
+
*/
|
|
38320
|
+
function downlevelForEmailClients(styleSheet) {
|
|
38321
|
+
unnestMediaQueries(styleSheet);
|
|
38322
|
+
downlevelRangeMediaQueries(styleSheet);
|
|
38323
|
+
}
|
|
38324
|
+
/**
|
|
38325
|
+
* Walk the stylesheet and unnest any @media/@supports rules that are nested
|
|
38326
|
+
* inside regular rules. For each, the parent Rule's selector wraps the
|
|
38327
|
+
* at-rule's body.
|
|
38328
|
+
*
|
|
38329
|
+
* Before: `.sm_p-4 { @media (...) { padding: 1rem } }`
|
|
38330
|
+
* After: `@media (...) { .sm_p-4 { padding: 1rem } }`
|
|
38331
|
+
*/
|
|
38332
|
+
function unnestMediaQueries(styleSheet) {
|
|
38333
|
+
const transforms = [];
|
|
38334
|
+
walk(styleSheet, {
|
|
38335
|
+
visit: "Rule",
|
|
38336
|
+
enter(rule, item, list) {
|
|
38337
|
+
if (!rule.block || !item) return;
|
|
38338
|
+
const nestedAtrules = [];
|
|
38339
|
+
const remainingChildren = [];
|
|
38340
|
+
rule.block.children.forEach((child) => {
|
|
38341
|
+
if (child.type === "Atrule" && (child.name === "media" || child.name === "supports")) nestedAtrules.push(child);
|
|
38342
|
+
else remainingChildren.push(child);
|
|
38343
|
+
});
|
|
38344
|
+
if (nestedAtrules.length > 0) transforms.push({
|
|
38345
|
+
parentRule: rule,
|
|
38346
|
+
parentItem: item,
|
|
38347
|
+
parentList: list,
|
|
38348
|
+
nestedAtrules,
|
|
38349
|
+
remainingChildren
|
|
38350
|
+
});
|
|
38351
|
+
}
|
|
38352
|
+
});
|
|
38353
|
+
for (let i = transforms.length - 1; i >= 0; i--) {
|
|
38354
|
+
const { parentRule, parentItem, parentList, nestedAtrules, remainingChildren } = transforms[i];
|
|
38355
|
+
const replacements = new List();
|
|
38356
|
+
if (remainingChildren.length > 0) {
|
|
38357
|
+
parentRule.block.children = new List().fromArray(remainingChildren);
|
|
38358
|
+
replacements.appendData(parentRule);
|
|
38359
|
+
}
|
|
38360
|
+
for (const atrule of nestedAtrules) {
|
|
38361
|
+
const wrappedRule = {
|
|
38362
|
+
type: "Rule",
|
|
38363
|
+
prelude: clone(parentRule.prelude),
|
|
38364
|
+
block: {
|
|
38365
|
+
type: "Block",
|
|
38366
|
+
children: atrule.block ? atrule.block.children : new List()
|
|
38367
|
+
}
|
|
38368
|
+
};
|
|
38369
|
+
const newAtrule = {
|
|
38370
|
+
type: "Atrule",
|
|
38371
|
+
name: atrule.name,
|
|
38372
|
+
prelude: atrule.prelude,
|
|
38373
|
+
block: {
|
|
38374
|
+
type: "Block",
|
|
38375
|
+
children: new List().fromArray([wrappedRule])
|
|
38376
|
+
}
|
|
38377
|
+
};
|
|
38378
|
+
replacements.appendData(newAtrule);
|
|
38379
|
+
}
|
|
38380
|
+
parentList.replace(parentItem, replacements);
|
|
38381
|
+
}
|
|
38382
|
+
}
|
|
38383
|
+
/**
|
|
38384
|
+
* Walk all nodes and downlevel range syntax (`FeatureRange`) inside @media
|
|
38385
|
+
* preludes to legacy `Feature` nodes (`min-width` / `max-width`).
|
|
38386
|
+
*/
|
|
38387
|
+
function downlevelRangeMediaQueries(styleSheet) {
|
|
38388
|
+
const replacements = [];
|
|
38389
|
+
walk(styleSheet, { enter(originalNode, item) {
|
|
38390
|
+
const node = originalNode;
|
|
38391
|
+
if (item && node.type === "FeatureRange") {
|
|
38392
|
+
const replacement = downlevelFeatureRange(node);
|
|
38393
|
+
if (replacement) replacements.push({
|
|
38394
|
+
item,
|
|
38395
|
+
replacement
|
|
38396
|
+
});
|
|
38397
|
+
}
|
|
38398
|
+
} });
|
|
38399
|
+
for (const { item, replacement } of replacements) item.data = replacement;
|
|
38400
|
+
}
|
|
38401
|
+
/**
|
|
38402
|
+
* Convert a `FeatureRange` node to a `Feature` node (legacy min-/max- syntax).
|
|
38403
|
+
*
|
|
38404
|
+
* For `width >= 40rem`: left=Identifier("width"), leftComparison=">=", middle=Dimension("40","rem")
|
|
38405
|
+
* Result: { type: "Feature", name: "min-width", value: Dimension("40","rem") }
|
|
38406
|
+
*/
|
|
38407
|
+
function downlevelFeatureRange(range) {
|
|
38408
|
+
if (range.left.type !== "Identifier") return null;
|
|
38409
|
+
let prefix;
|
|
38410
|
+
if (range.leftComparison === ">=" || range.leftComparison === ">") prefix = "min-";
|
|
38411
|
+
else if (range.leftComparison === "<=" || range.leftComparison === "<") prefix = "max-";
|
|
38412
|
+
else return null;
|
|
38413
|
+
return {
|
|
38414
|
+
type: "Feature",
|
|
38415
|
+
kind: "media",
|
|
38416
|
+
name: `${prefix}${range.left.name}`,
|
|
38417
|
+
value: range.middle
|
|
38418
|
+
};
|
|
38419
|
+
}
|
|
38420
|
+
//#endregion
|
|
38298
38421
|
//#region src/components/tailwind/utils/compatibility/sanitize-class-name.ts
|
|
38299
38422
|
const digitToNameMap = {
|
|
38300
38423
|
"0": "zero",
|
|
@@ -40418,6 +40541,7 @@ function Tailwind({ children, config }) {
|
|
|
40418
40541
|
children: new List().fromArray(Array.from(nonInlinableRules.values()))
|
|
40419
40542
|
};
|
|
40420
40543
|
sanitizeNonInlinableRules(nonInlineStyles);
|
|
40544
|
+
downlevelForEmailClients(nonInlineStyles);
|
|
40421
40545
|
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
|
|
40422
40546
|
let appliedNonInlineStyles = false;
|
|
40423
40547
|
mappedChildren = mapReactTree(mappedChildren, (node) => {
|
package/package.json
CHANGED
|
@@ -50,7 +50,7 @@ describe('Tailwind component', () => {
|
|
|
50
50
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
|
51
51
|
<meta name="x-apple-disable-message-reformatting" />
|
|
52
52
|
<style>
|
|
53
|
-
|
|
53
|
+
@media (min-width:48rem){.md_p-4{padding:1rem!important}}
|
|
54
54
|
</style>
|
|
55
55
|
</head>
|
|
56
56
|
<body>
|
|
@@ -353,7 +353,7 @@ describe('Tailwind component', () => {
|
|
|
353
353
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
|
354
354
|
<meta name="x-apple-disable-message-reformatting" />
|
|
355
355
|
<style>
|
|
356
|
-
|
|
356
|
+
@media (min-width:40rem){.sm_bg-red-50{background-color:rgb(254,242,242)!important}}@media (min-width:40rem){.sm_text-sm{font-size:0.875rem!important;line-height:1.4285714285714286!important}}@media (min-width:48rem){.md_text-lg{font-size:1.125rem!important;line-height:1.5555555555555556!important}}
|
|
357
357
|
</style></head
|
|
358
358
|
><!--$--><!--html--><!--head--><span
|
|
359
359
|
><!--[if mso]><i style="letter-spacing: 10px;mso-font-width:-100%;" hidden> </i><![endif]--></span
|
|
@@ -391,7 +391,7 @@ describe('Tailwind component', () => {
|
|
|
391
391
|
);
|
|
392
392
|
|
|
393
393
|
expect(actualOutput).toMatchInlineSnapshot(
|
|
394
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><style
|
|
394
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><style>@media (prefers-color-scheme:dark){.text-body{color:orange!important}}</style></head><body class="text-body"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="color:green">this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
|
|
395
395
|
);
|
|
396
396
|
});
|
|
397
397
|
|
|
@@ -425,7 +425,7 @@ describe('Tailwind component', () => {
|
|
|
425
425
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
|
426
426
|
<meta name="x-apple-disable-message-reformatting" />
|
|
427
427
|
<style>
|
|
428
|
-
|
|
428
|
+
@media (min-width:1280px){.xl_bg-green-500{background-color:rgb(0,201,80)!important}}@media (min-width:1536px){.twoxl_bg-blue-500{background-color:rgb(43,127,255)!important}}
|
|
429
429
|
</style></head
|
|
430
430
|
><!--$--><!--html--><!--head-->
|
|
431
431
|
<div class="xl_bg-green-500" style="background-color:rgb(255,226,226)">
|
|
@@ -452,7 +452,7 @@ describe('Tailwind component', () => {
|
|
|
452
452
|
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
453
453
|
<head>
|
|
454
454
|
<style>
|
|
455
|
-
|
|
455
|
+
@media (min-width:64rem){.lg_max-h-calc50pxplus5rem{max-height:calc(50px + 5rem)!important}}
|
|
456
456
|
</style></head
|
|
457
457
|
><!--$--><!--head-->
|
|
458
458
|
<div
|
|
@@ -494,7 +494,7 @@ describe('Tailwind component', () => {
|
|
|
494
494
|
<html lang="en">
|
|
495
495
|
<head>
|
|
496
496
|
<style>
|
|
497
|
-
|
|
497
|
+
@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}
|
|
498
498
|
</style>
|
|
499
499
|
</head>
|
|
500
500
|
<body>
|
|
@@ -524,7 +524,7 @@ describe('Tailwind component', () => {
|
|
|
524
524
|
</Tailwind>,
|
|
525
525
|
),
|
|
526
526
|
).toMatchInlineSnapshot(
|
|
527
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><style
|
|
527
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><style>@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}</style></head><body><!--$--><!--html--><!--head--><!--body--><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(255,201,201)"></div><!--/$--></body></html>"`,
|
|
528
528
|
);
|
|
529
529
|
});
|
|
530
530
|
|
|
@@ -558,7 +558,7 @@ describe('Tailwind component', () => {
|
|
|
558
558
|
<html lang="en">
|
|
559
559
|
<head>
|
|
560
560
|
<style>
|
|
561
|
-
|
|
561
|
+
@media (min-width:40rem){.text-body{color:darkgreen!important}}
|
|
562
562
|
</style>
|
|
563
563
|
</head>
|
|
564
564
|
<body>
|
|
@@ -588,7 +588,7 @@ describe('Tailwind component', () => {
|
|
|
588
588
|
<html lang="en">
|
|
589
589
|
<head>
|
|
590
590
|
<style>
|
|
591
|
-
.hover_bg-red-600{
|
|
591
|
+
.hover_bg-red-600{@media (hover:hover){&:hover{background-color:rgb(231,0,11)!important}}}.focus_bg-red-700{&:focus{background-color:rgb(193,0,7)!important}}@media (min-width:40rem){.sm_bg-red-300{background-color:rgb(255,162,162)!important}}@media (min-width:40rem){.sm_hover_bg-red-200{@media (hover:hover){&:hover{background-color:rgb(255,201,201)!important}}}}@media (min-width:48rem){.md_bg-red-400{background-color:rgb(255,100,103)!important}}@media (min-width:64rem){.lg_bg-red-500{background-color:rgb(251,44,54)!important}}
|
|
592
592
|
</style>
|
|
593
593
|
</head>
|
|
594
594
|
<body>
|
|
@@ -659,7 +659,7 @@ describe('Tailwind component', () => {
|
|
|
659
659
|
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
|
660
660
|
<meta name="x-apple-disable-message-reformatting" />
|
|
661
661
|
<style>
|
|
662
|
-
.max-sm_text-red-600{
|
|
662
|
+
@media (max-width:40rem){.max-sm_text-red-600{color:rgb(231,0,11)!important}}
|
|
663
663
|
</style></head
|
|
664
664
|
><!--$--><!--head-->
|
|
665
665
|
<p class="max-sm_text-red-600" style="color:rgb(20,71,230)">I am some text</p>
|
|
@@ -702,7 +702,7 @@ describe('Tailwind component', () => {
|
|
|
702
702
|
<html lang="en">
|
|
703
703
|
<head>
|
|
704
704
|
<style>
|
|
705
|
-
|
|
705
|
+
@media (min-width:40rem){.sm_bg-red-500{background-color:rgb(251,44,54)!important}}
|
|
706
706
|
</style>
|
|
707
707
|
<style></style>
|
|
708
708
|
<link />
|
|
@@ -923,7 +923,7 @@ describe('Tailwind component', () => {
|
|
|
923
923
|
<html lang="en">
|
|
924
924
|
<head>
|
|
925
925
|
<style>
|
|
926
|
-
|
|
926
|
+
@media (min-width:40rem){.sm_border-custom{border:2px solid!important}}
|
|
927
927
|
</style>
|
|
928
928
|
</head>
|
|
929
929
|
<body>
|
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import type { Config } from 'tailwindcss';
|
|
4
4
|
import { useSuspensedPromise } from './hooks/use-suspended-promise.js';
|
|
5
5
|
import { sanitizeStyleSheet } from './sanitize-stylesheet.js';
|
|
6
|
+
import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients.js';
|
|
6
7
|
import { extractRulesPerClass } from './utils/css/extract-rules-per-class.js';
|
|
7
8
|
import { getCustomProperties } from './utils/css/get-custom-properties.js';
|
|
8
9
|
import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules.js';
|
|
@@ -118,6 +119,7 @@ export function Tailwind({ children, config }: TailwindProps) {
|
|
|
118
119
|
),
|
|
119
120
|
};
|
|
120
121
|
sanitizeNonInlinableRules(nonInlineStyles);
|
|
122
|
+
downlevelForEmailClients(nonInlineStyles);
|
|
121
123
|
|
|
122
124
|
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
|
|
123
125
|
let appliedNonInlineStyles = false as boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { generate, parse, type StyleSheet } from 'css-tree';
|
|
2
|
+
import { downlevelForEmailClients } from './downlevel-for-email-clients.js';
|
|
3
|
+
|
|
4
|
+
function transform(css: string): string {
|
|
5
|
+
const ast = parse(css) as StyleSheet;
|
|
6
|
+
downlevelForEmailClients(ast);
|
|
7
|
+
return generate(ast);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('downlevelForEmailClients', () => {
|
|
11
|
+
describe('range syntax', () => {
|
|
12
|
+
it('converts width>= to min-width', () => {
|
|
13
|
+
expect(transform('@media (width>=40rem){.sm_p-4{padding:1rem}}')).toBe(
|
|
14
|
+
'@media (min-width:40rem){.sm_p-4{padding:1rem}}',
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('converts width<= to max-width', () => {
|
|
19
|
+
expect(
|
|
20
|
+
transform('@media (width<=40rem){.max-sm_p-4{padding:1rem}}'),
|
|
21
|
+
).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('converts width< to max-width', () => {
|
|
25
|
+
expect(
|
|
26
|
+
transform('@media (width<40rem){.max-sm_text-red{color:red}}'),
|
|
27
|
+
).toBe('@media (max-width:40rem){.max-sm_text-red{color:red}}');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('converts width> to min-width', () => {
|
|
31
|
+
expect(transform('@media (width>40rem){.sm_p-4{padding:1rem}}')).toBe(
|
|
32
|
+
'@media (min-width:40rem){.sm_p-4{padding:1rem}}',
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not affect non-range media queries', () => {
|
|
37
|
+
expect(
|
|
38
|
+
transform('@media (prefers-color-scheme:dark){.dark{color:white}}'),
|
|
39
|
+
).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('unnesting', () => {
|
|
44
|
+
it('unnests @media from inside a selector', () => {
|
|
45
|
+
expect(
|
|
46
|
+
transform(
|
|
47
|
+
'.sm_bg-red{@media (min-width:40rem){background-color:red!important}}',
|
|
48
|
+
),
|
|
49
|
+
).toBe(
|
|
50
|
+
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles combined range syntax + nesting', () => {
|
|
55
|
+
expect(
|
|
56
|
+
transform(
|
|
57
|
+
'.sm_bg-red{@media (width>=40rem){background-color:rgb(255,162,162)!important}}',
|
|
58
|
+
),
|
|
59
|
+
).toBe(
|
|
60
|
+
'@media (min-width:40rem){.sm_bg-red{background-color:rgb(255,162,162)!important}}',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('handles multiple concatenated rules', () => {
|
|
65
|
+
const input =
|
|
66
|
+
'.sm_bg-red{@media (width>=40rem){background-color:red!important}}' +
|
|
67
|
+
'.md_bg-blue{@media (width>=48rem){background-color:blue!important}}';
|
|
68
|
+
|
|
69
|
+
const result = transform(input);
|
|
70
|
+
|
|
71
|
+
expect(result).toContain(
|
|
72
|
+
'@media (min-width:40rem){.sm_bg-red{background-color:red!important}}',
|
|
73
|
+
);
|
|
74
|
+
expect(result).toContain(
|
|
75
|
+
'@media (min-width:48rem){.md_bg-blue{background-color:blue!important}}',
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('unnests dark mode media queries', () => {
|
|
80
|
+
expect(
|
|
81
|
+
transform(
|
|
82
|
+
'.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}',
|
|
83
|
+
),
|
|
84
|
+
).toBe(
|
|
85
|
+
'@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('preserves rules without nested @media', () => {
|
|
90
|
+
expect(transform('.bg-red{background-color:red}')).toBe(
|
|
91
|
+
'.bg-red{background-color:red}',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('preserves already top-level @media rules', () => {
|
|
96
|
+
expect(
|
|
97
|
+
transform('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}'),
|
|
98
|
+
).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem!important}}');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('handles multiple @media nested in one selector', () => {
|
|
102
|
+
const input =
|
|
103
|
+
'.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}';
|
|
104
|
+
|
|
105
|
+
const result = transform(input);
|
|
106
|
+
|
|
107
|
+
expect(result).toContain(
|
|
108
|
+
'@media (min-width:40rem){.multi{color:red!important}}',
|
|
109
|
+
);
|
|
110
|
+
expect(result).toContain(
|
|
111
|
+
'@media (min-width:48rem){.multi{color:blue!important}}',
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('handles empty stylesheet', () => {
|
|
116
|
+
expect(transform('')).toBe('');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downlevels modern CSS features that email clients don't support,
|
|
3
|
+
* operating on a css-tree StyleSheet AST.
|
|
4
|
+
*
|
|
5
|
+
* 1. CSS Nesting: unnests @media rules from inside selectors
|
|
6
|
+
* `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}`
|
|
7
|
+
* → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}`
|
|
8
|
+
*
|
|
9
|
+
* 2. Media Queries Level 4 range syntax → legacy min-width/max-width
|
|
10
|
+
* `(width>=40rem)` → `(min-width:40rem)`
|
|
11
|
+
*
|
|
12
|
+
* Gmail, Outlook, Yahoo, and most email clients don't support either feature.
|
|
13
|
+
* See: https://www.caniemail.com/features/css-at-media/
|
|
14
|
+
* https://www.caniemail.com/features/css-nesting/
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type Atrule,
|
|
19
|
+
type CssNode,
|
|
20
|
+
clone,
|
|
21
|
+
type Feature,
|
|
22
|
+
type FeatureRange,
|
|
23
|
+
List,
|
|
24
|
+
type ListItem,
|
|
25
|
+
type Rule,
|
|
26
|
+
type StyleSheet,
|
|
27
|
+
walk,
|
|
28
|
+
} from 'css-tree';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* css-tree 3.x introduced new AST node types for query-related at-rules that
|
|
32
|
+
* `@types/css-tree` (still on 2.x at the time of writing) doesn't expose.
|
|
33
|
+
* Augmenting the module here lets the rest of this file work with strong
|
|
34
|
+
* types instead of scattering `as` casts everywhere.
|
|
35
|
+
*
|
|
36
|
+
* - `FeatureRange` is what the parser emits for Media Queries Level 4 range
|
|
37
|
+
* syntax: `(width >= 40rem)`.
|
|
38
|
+
* - `Feature` is the legacy form (`(min-width: 40rem)`) we construct as the
|
|
39
|
+
* downleveled output.
|
|
40
|
+
*
|
|
41
|
+
* Note: we cannot extend the `CssNode` union itself (it's a `type` alias, not
|
|
42
|
+
* an interface), so two narrow casts remain in this file:
|
|
43
|
+
* 1. Widening `node` inside `walk()` so we can narrow against
|
|
44
|
+
* `FeatureRange.type` (`CssNode` doesn't list `'FeatureRange'`).
|
|
45
|
+
* 2. Assigning a constructed `Feature` back to `ListItem<CssNode>.data`.
|
|
46
|
+
*
|
|
47
|
+
* Both are flagged inline and reference back to this block.
|
|
48
|
+
*
|
|
49
|
+
* See:
|
|
50
|
+
* https://github.com/csstree/csstree/blob/master/lib/syntax/node/FeatureRange.js
|
|
51
|
+
* https://github.com/csstree/csstree/blob/master/lib/syntax/node/Feature.js
|
|
52
|
+
*/
|
|
53
|
+
declare module 'css-tree' {
|
|
54
|
+
interface FeatureRange extends CssNodeCommon {
|
|
55
|
+
type: 'FeatureRange';
|
|
56
|
+
kind: string;
|
|
57
|
+
left: CssNode;
|
|
58
|
+
leftComparison: string;
|
|
59
|
+
middle: CssNode;
|
|
60
|
+
rightComparison: string | null;
|
|
61
|
+
right: CssNode | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Feature extends CssNodeCommon {
|
|
65
|
+
type: 'Feature';
|
|
66
|
+
kind: string;
|
|
67
|
+
name: string;
|
|
68
|
+
value: CssNode | null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unnest @media at-rules from inside regular rules, and downlevel
|
|
74
|
+
* range media query syntax to legacy min-width/max-width.
|
|
75
|
+
*
|
|
76
|
+
* Mutates the stylesheet in place.
|
|
77
|
+
*/
|
|
78
|
+
export function downlevelForEmailClients(styleSheet: StyleSheet): void {
|
|
79
|
+
unnestMediaQueries(styleSheet);
|
|
80
|
+
downlevelRangeMediaQueries(styleSheet);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Unnesting
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
interface UnnestTransform {
|
|
88
|
+
parentRule: Rule;
|
|
89
|
+
parentItem: ListItem<CssNode>;
|
|
90
|
+
parentList: List<CssNode>;
|
|
91
|
+
nestedAtrules: Atrule[];
|
|
92
|
+
remainingChildren: CssNode[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Walk the stylesheet and unnest any @media/@supports rules that are nested
|
|
97
|
+
* inside regular rules. For each, the parent Rule's selector wraps the
|
|
98
|
+
* at-rule's body.
|
|
99
|
+
*
|
|
100
|
+
* Before: `.sm_p-4 { @media (...) { padding: 1rem } }`
|
|
101
|
+
* After: `@media (...) { .sm_p-4 { padding: 1rem } }`
|
|
102
|
+
*/
|
|
103
|
+
function unnestMediaQueries(styleSheet: StyleSheet): void {
|
|
104
|
+
const transforms: UnnestTransform[] = [];
|
|
105
|
+
|
|
106
|
+
walk(styleSheet, {
|
|
107
|
+
visit: 'Rule',
|
|
108
|
+
enter(rule, item, list) {
|
|
109
|
+
if (!rule.block || !item) return;
|
|
110
|
+
|
|
111
|
+
const nestedAtrules: Atrule[] = [];
|
|
112
|
+
const remainingChildren: CssNode[] = [];
|
|
113
|
+
|
|
114
|
+
rule.block.children.forEach((child) => {
|
|
115
|
+
if (
|
|
116
|
+
child.type === 'Atrule' &&
|
|
117
|
+
(child.name === 'media' || child.name === 'supports')
|
|
118
|
+
) {
|
|
119
|
+
nestedAtrules.push(child);
|
|
120
|
+
} else {
|
|
121
|
+
remainingChildren.push(child);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (nestedAtrules.length > 0) {
|
|
126
|
+
transforms.push({
|
|
127
|
+
parentRule: rule,
|
|
128
|
+
parentItem: item,
|
|
129
|
+
parentList: list,
|
|
130
|
+
nestedAtrules,
|
|
131
|
+
remainingChildren,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Apply in reverse so list positions stay valid
|
|
138
|
+
for (let i = transforms.length - 1; i >= 0; i--) {
|
|
139
|
+
const {
|
|
140
|
+
parentRule,
|
|
141
|
+
parentItem,
|
|
142
|
+
parentList,
|
|
143
|
+
nestedAtrules,
|
|
144
|
+
remainingChildren,
|
|
145
|
+
} = transforms[i]!;
|
|
146
|
+
|
|
147
|
+
// Build replacement list: [modified parent rule (if any), unnested @media rules...]
|
|
148
|
+
const replacements = new List<CssNode>();
|
|
149
|
+
|
|
150
|
+
if (remainingChildren.length > 0) {
|
|
151
|
+
parentRule.block.children = new List<CssNode>().fromArray(
|
|
152
|
+
remainingChildren,
|
|
153
|
+
);
|
|
154
|
+
replacements.appendData(parentRule);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const atrule of nestedAtrules) {
|
|
158
|
+
const wrappedRule: Rule = {
|
|
159
|
+
type: 'Rule',
|
|
160
|
+
prelude: clone(parentRule.prelude) as Rule['prelude'],
|
|
161
|
+
block: {
|
|
162
|
+
type: 'Block',
|
|
163
|
+
children: atrule.block ? atrule.block.children : new List<CssNode>(),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const newAtrule: Atrule = {
|
|
168
|
+
type: 'Atrule',
|
|
169
|
+
name: atrule.name,
|
|
170
|
+
prelude: atrule.prelude,
|
|
171
|
+
block: {
|
|
172
|
+
type: 'Block',
|
|
173
|
+
children: new List<CssNode>().fromArray([wrappedRule]),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
replacements.appendData(newAtrule);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Replace the original rule with the entire list of new nodes
|
|
181
|
+
parentList.replace(parentItem, replacements);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Range media query downleveling
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk all nodes and downlevel range syntax (`FeatureRange`) inside @media
|
|
191
|
+
* preludes to legacy `Feature` nodes (`min-width` / `max-width`).
|
|
192
|
+
*/
|
|
193
|
+
function downlevelRangeMediaQueries(styleSheet: StyleSheet): void {
|
|
194
|
+
const replacements: Array<{
|
|
195
|
+
item: ListItem<CssNode>;
|
|
196
|
+
replacement: Feature;
|
|
197
|
+
}> = [];
|
|
198
|
+
|
|
199
|
+
walk(styleSheet, {
|
|
200
|
+
enter(originalNode, item) {
|
|
201
|
+
// See module augmentation above: `CssNode` (from @types/css-tree 2.x)
|
|
202
|
+
// doesn't include `FeatureRange`, so widen here to enable narrowing.
|
|
203
|
+
const node = originalNode as CssNode | FeatureRange;
|
|
204
|
+
if (item && node.type === 'FeatureRange') {
|
|
205
|
+
const replacement = downlevelFeatureRange(node);
|
|
206
|
+
if (replacement) {
|
|
207
|
+
replacements.push({ item, replacement });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
for (const { item, replacement } of replacements) {
|
|
214
|
+
// See module augmentation above: `Feature` is not part of the `CssNode`
|
|
215
|
+
// union, so a single cast is required when handing it back to the AST.
|
|
216
|
+
item.data = replacement as unknown as CssNode;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Convert a `FeatureRange` node to a `Feature` node (legacy min-/max- syntax).
|
|
222
|
+
*
|
|
223
|
+
* For `width >= 40rem`: left=Identifier("width"), leftComparison=">=", middle=Dimension("40","rem")
|
|
224
|
+
* Result: { type: "Feature", name: "min-width", value: Dimension("40","rem") }
|
|
225
|
+
*/
|
|
226
|
+
function downlevelFeatureRange(range: FeatureRange): Feature | null {
|
|
227
|
+
if (range.left.type !== 'Identifier') return null;
|
|
228
|
+
|
|
229
|
+
let prefix: string;
|
|
230
|
+
if (range.leftComparison === '>=' || range.leftComparison === '>') {
|
|
231
|
+
prefix = 'min-';
|
|
232
|
+
} else if (range.leftComparison === '<=' || range.leftComparison === '<') {
|
|
233
|
+
prefix = 'max-';
|
|
234
|
+
} else {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
type: 'Feature',
|
|
240
|
+
kind: 'media',
|
|
241
|
+
name: `${prefix}${range.left.name}`,
|
|
242
|
+
value: range.middle,
|
|
243
|
+
};
|
|
244
|
+
}
|