react-email 6.0.6 → 6.0.7

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # react-email
2
2
 
3
+ ## 6.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 87a2486: undo nesting of all media queries, and replace >= <= exxpressions with min-width/max-width on the Tailwind component
8
+
3
9
  ## 6.0.6
4
10
 
5
11
  ### Patch Changes
@@ -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.6",
6525
+ version: "6.0.7",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "6.0.6",
3
+ "version": "6.0.7",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.mjs"
@@ -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
- .md_p-4{@media (width>=48rem){padding:1rem!important}}
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
- .sm_bg-red-50{@media (width>=40rem){background-color:rgb(254,242,242)!important}}.sm_text-sm{@media (width>=40rem){font-size:0.875rem!important;line-height:1.4285714285714286!important}}.md_text-lg{@media (width>=48rem){font-size:1.125rem!important;line-height:1.5555555555555556!important}}
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>&nbsp;</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>.text-body{@media (prefers-color-scheme:dark){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>"`,
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
- .xl_bg-green-500{@media (width>=1280px){background-color:rgb(0,201,80)!important}}.twoxl_bg-blue-500{@media (width>=1536px){background-color:rgb(43,127,255)!important}}
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
- .lg_max-h-calc50pxplus5rem{@media (width>=64rem){max-height:calc(50px + 5rem)!important}}
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
- .sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){background-color:rgb(251,44,54)!important}}
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>.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){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>"`,
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
- .text-body{@media (width>=40rem){color:darkgreen!important}}
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{&:hover{@media (hover:hover){background-color:rgb(231,0,11)!important}}}.focus_bg-red-700{&:focus{background-color:rgb(193,0,7)!important}}.sm_bg-red-300{@media (width>=40rem){background-color:rgb(255,162,162)!important}}.sm_hover_bg-red-200{@media (width>=40rem){&:hover{@media (hover:hover){background-color:rgb(255,201,201)!important}}}}.md_bg-red-400{@media (width>=48rem){background-color:rgb(255,100,103)!important}}.lg_bg-red-500{@media (width>=64rem){background-color:rgb(251,44,54)!important}}
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{@media (width<40rem){color:rgb(231,0,11)!important}}
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
- .sm_bg-red-500{@media (width>=40rem){background-color:rgb(251,44,54)!important}}
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
- .sm_border-custom{@media (width>=40rem){border:2px solid!important}}
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
+ }