react-email 6.3.2 → 6.4.0

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,13 @@
1
1
  # react-email
2
2
 
3
+ ## 6.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ba99365: resolve and strip unresolved `--tw-*` CSS variables in non-inlinable rules so Tailwind media query utilities no longer break Gmail
8
+
9
+ ## 6.3.3
10
+
3
11
  ## 6.3.2
4
12
 
5
13
  ### Patch Changes
@@ -6523,7 +6523,7 @@ const getEmailsDirectoryMetadata = async (absolutePathToEmailsDirectory, keepFil
6523
6523
  //#region package.json
6524
6524
  var package_default = {
6525
6525
  name: "react-email",
6526
- version: "6.3.2",
6526
+ version: "6.4.0",
6527
6527
  description: "A live preview of your emails right in your browser.",
6528
6528
  bin: { "email": "./dist/cli/index.mjs" },
6529
6529
  type: "module",
package/dist/index.cjs CHANGED
@@ -37771,6 +37771,55 @@ function getReactProperty(prop) {
37771
37771
  return fromDashCaseToCamelCase(modifiedProp);
37772
37772
  }
37773
37773
  //#endregion
37774
+ //#region src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts
37775
+ /**
37776
+ * Tailwind v4 emits variant-stacking idioms like
37777
+ * font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
37778
+ * filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
37779
+ * where each var() has an empty fallback so missing variants collapse to nothing.
37780
+ * Tailwind deliberately leaves these variant vars undefined until used, so they
37781
+ * stay in the output here and produce unresolvable custom properties in email HTML
37782
+ * (no email client supports CSS custom properties reliably). Per the CSS spec
37783
+ * (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
37784
+ * "use empty string if the variable is undefined", which is exactly what we want.
37785
+ *
37786
+ * Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
37787
+ * are left untouched.
37788
+ *
37789
+ * Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
37790
+ * correctly after the inner var() has been removed.
37791
+ */
37792
+ function stripEmptyTailwindVars(node) {
37793
+ walk(node, {
37794
+ visit: "Function",
37795
+ leave(func, funcItem, funcList) {
37796
+ if (func.name !== "var") return;
37797
+ let variableName;
37798
+ walk(func, {
37799
+ visit: "Identifier",
37800
+ enter(identifier) {
37801
+ variableName = identifier.name;
37802
+ return this.break;
37803
+ }
37804
+ });
37805
+ if (!variableName?.startsWith("--tw-")) return;
37806
+ let sawComma = false;
37807
+ let hasFallbackContent = false;
37808
+ func.children.forEach((child) => {
37809
+ if (!sawComma) {
37810
+ if (child.type === "Operator" && child.value === ",") sawComma = true;
37811
+ return;
37812
+ }
37813
+ let childValue = generate(child).trim();
37814
+ if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37815
+ if (childValue.length > 0) hasFallbackContent = true;
37816
+ });
37817
+ if (!sawComma || hasFallbackContent) return;
37818
+ funcList.remove(funcItem);
37819
+ }
37820
+ });
37821
+ }
37822
+ //#endregion
37774
37823
  //#region src/components/tailwind/utils/css/unwrap-value.ts
37775
37824
  function unwrapValue(value) {
37776
37825
  if (value.type === "Value" && value.children.size === 1) return value.children.first ?? value;
@@ -37815,34 +37864,7 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
37815
37864
  visit: "Declaration",
37816
37865
  enter(declaration) {
37817
37866
  if (declaration.property.startsWith("--")) return;
37818
- walk(declaration.value, {
37819
- visit: "Function",
37820
- leave(func, funcItem, funcList) {
37821
- if (func.name !== "var") return;
37822
- let variableName;
37823
- walk(func, {
37824
- visit: "Identifier",
37825
- enter(identifier) {
37826
- variableName = identifier.name;
37827
- return this.break;
37828
- }
37829
- });
37830
- if (!variableName?.startsWith("--tw-")) return;
37831
- let sawComma = false;
37832
- let hasFallbackContent = false;
37833
- func.children.forEach((child) => {
37834
- if (!sawComma) {
37835
- if (child.type === "Operator" && child.value === ",") sawComma = true;
37836
- return;
37837
- }
37838
- let childValue = generate(child).trim();
37839
- if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37840
- if (childValue.length > 0) hasFallbackContent = true;
37841
- });
37842
- if (!sawComma || hasFallbackContent) return;
37843
- funcList.remove(funcItem);
37844
- }
37845
- });
37867
+ stripEmptyTailwindVars(declaration.value);
37846
37868
  styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
37847
37869
  }
37848
37870
  });
@@ -37933,6 +37955,11 @@ function resolveAllCssVariables(node) {
37933
37955
  hasReplaced = true;
37934
37956
  break;
37935
37957
  }
37958
+ if (use.path[0]?.type === "Block" && use.path[1]?.type === "Atrule" && use.path[2]?.type === "Block" && use.path[3]?.type === "Rule" && definition.path[0]?.type === "Block" && definition.path[1]?.type === "Atrule" && definition.path[2]?.type === "Block" && definition.path[3]?.type === "Rule" && use.path[1].name === definition.path[1].name && (use.path[1].prelude ? definition.path[1].prelude ? generate(use.path[1].prelude) === generate(definition.path[1].prelude) : false : definition.path[1].prelude === null) && doSelectorsIntersect(use.path[3].prelude, definition.path[3].prelude)) {
37959
+ use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, definition.definition), { context: "value" });
37960
+ hasReplaced = true;
37961
+ break;
37962
+ }
37936
37963
  }
37937
37964
  if (!hasReplaced && use.fallback) use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: "value" });
37938
37965
  }
@@ -38498,6 +38525,12 @@ function sanitizeClassName(className) {
38498
38525
  * What it does:
38499
38526
  * 1. Converts all declarations in all rules into important ones
38500
38527
  * 2. Sanitizes class selectors of all non-inlinable rules
38528
+ * 3. Removes --tw-* custom property declarations — by this point all CSS
38529
+ * variables have been resolved, so these are dead weight in email HTML.
38530
+ * 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
38531
+ * variant-stacking idioms (filter, font-variant-numeric, etc.) — email
38532
+ * clients can't resolve CSS custom properties reliably, so any --tw-*
38533
+ * left as a bare empty-fallback ref would reach the client broken.
38501
38534
  */
38502
38535
  function sanitizeNonInlinableRules(node) {
38503
38536
  walk(node, {
@@ -38509,8 +38542,13 @@ function sanitizeNonInlinableRules(node) {
38509
38542
  });
38510
38543
  walk(rule, {
38511
38544
  visit: "Declaration",
38512
- enter(declaration) {
38545
+ enter(declaration, item, list) {
38546
+ if (declaration.property.startsWith("--tw-")) {
38547
+ list.remove(item);
38548
+ return;
38549
+ }
38513
38550
  declaration.important = true;
38551
+ stripEmptyTailwindVars(declaration.value);
38514
38552
  }
38515
38553
  });
38516
38554
  }
package/dist/index.mjs CHANGED
@@ -37750,6 +37750,55 @@ function getReactProperty(prop) {
37750
37750
  return fromDashCaseToCamelCase(modifiedProp);
37751
37751
  }
37752
37752
  //#endregion
37753
+ //#region src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts
37754
+ /**
37755
+ * Tailwind v4 emits variant-stacking idioms like
37756
+ * font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
37757
+ * filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
37758
+ * where each var() has an empty fallback so missing variants collapse to nothing.
37759
+ * Tailwind deliberately leaves these variant vars undefined until used, so they
37760
+ * stay in the output here and produce unresolvable custom properties in email HTML
37761
+ * (no email client supports CSS custom properties reliably). Per the CSS spec
37762
+ * (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
37763
+ * "use empty string if the variable is undefined", which is exactly what we want.
37764
+ *
37765
+ * Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
37766
+ * are left untouched.
37767
+ *
37768
+ * Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
37769
+ * correctly after the inner var() has been removed.
37770
+ */
37771
+ function stripEmptyTailwindVars(node) {
37772
+ walk(node, {
37773
+ visit: "Function",
37774
+ leave(func, funcItem, funcList) {
37775
+ if (func.name !== "var") return;
37776
+ let variableName;
37777
+ walk(func, {
37778
+ visit: "Identifier",
37779
+ enter(identifier) {
37780
+ variableName = identifier.name;
37781
+ return this.break;
37782
+ }
37783
+ });
37784
+ if (!variableName?.startsWith("--tw-")) return;
37785
+ let sawComma = false;
37786
+ let hasFallbackContent = false;
37787
+ func.children.forEach((child) => {
37788
+ if (!sawComma) {
37789
+ if (child.type === "Operator" && child.value === ",") sawComma = true;
37790
+ return;
37791
+ }
37792
+ let childValue = generate(child).trim();
37793
+ if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37794
+ if (childValue.length > 0) hasFallbackContent = true;
37795
+ });
37796
+ if (!sawComma || hasFallbackContent) return;
37797
+ funcList.remove(funcItem);
37798
+ }
37799
+ });
37800
+ }
37801
+ //#endregion
37753
37802
  //#region src/components/tailwind/utils/css/unwrap-value.ts
37754
37803
  function unwrapValue(value) {
37755
37804
  if (value.type === "Value" && value.children.size === 1) return value.children.first ?? value;
@@ -37794,34 +37843,7 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
37794
37843
  visit: "Declaration",
37795
37844
  enter(declaration) {
37796
37845
  if (declaration.property.startsWith("--")) return;
37797
- walk(declaration.value, {
37798
- visit: "Function",
37799
- leave(func, funcItem, funcList) {
37800
- if (func.name !== "var") return;
37801
- let variableName;
37802
- walk(func, {
37803
- visit: "Identifier",
37804
- enter(identifier) {
37805
- variableName = identifier.name;
37806
- return this.break;
37807
- }
37808
- });
37809
- if (!variableName?.startsWith("--tw-")) return;
37810
- let sawComma = false;
37811
- let hasFallbackContent = false;
37812
- func.children.forEach((child) => {
37813
- if (!sawComma) {
37814
- if (child.type === "Operator" && child.value === ",") sawComma = true;
37815
- return;
37816
- }
37817
- let childValue = generate(child).trim();
37818
- if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37819
- if (childValue.length > 0) hasFallbackContent = true;
37820
- });
37821
- if (!sawComma || hasFallbackContent) return;
37822
- funcList.remove(funcItem);
37823
- }
37824
- });
37846
+ stripEmptyTailwindVars(declaration.value);
37825
37847
  styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
37826
37848
  }
37827
37849
  });
@@ -37912,6 +37934,11 @@ function resolveAllCssVariables(node) {
37912
37934
  hasReplaced = true;
37913
37935
  break;
37914
37936
  }
37937
+ if (use.path[0]?.type === "Block" && use.path[1]?.type === "Atrule" && use.path[2]?.type === "Block" && use.path[3]?.type === "Rule" && definition.path[0]?.type === "Block" && definition.path[1]?.type === "Atrule" && definition.path[2]?.type === "Block" && definition.path[3]?.type === "Rule" && use.path[1].name === definition.path[1].name && (use.path[1].prelude ? definition.path[1].prelude ? generate(use.path[1].prelude) === generate(definition.path[1].prelude) : false : definition.path[1].prelude === null) && doSelectorsIntersect(use.path[3].prelude, definition.path[3].prelude)) {
37938
+ use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, definition.definition), { context: "value" });
37939
+ hasReplaced = true;
37940
+ break;
37941
+ }
37915
37942
  }
37916
37943
  if (!hasReplaced && use.fallback) use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: "value" });
37917
37944
  }
@@ -38477,6 +38504,12 @@ function sanitizeClassName(className) {
38477
38504
  * What it does:
38478
38505
  * 1. Converts all declarations in all rules into important ones
38479
38506
  * 2. Sanitizes class selectors of all non-inlinable rules
38507
+ * 3. Removes --tw-* custom property declarations — by this point all CSS
38508
+ * variables have been resolved, so these are dead weight in email HTML.
38509
+ * 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
38510
+ * variant-stacking idioms (filter, font-variant-numeric, etc.) — email
38511
+ * clients can't resolve CSS custom properties reliably, so any --tw-*
38512
+ * left as a bare empty-fallback ref would reach the client broken.
38480
38513
  */
38481
38514
  function sanitizeNonInlinableRules(node) {
38482
38515
  walk(node, {
@@ -38488,8 +38521,13 @@ function sanitizeNonInlinableRules(node) {
38488
38521
  });
38489
38522
  walk(rule, {
38490
38523
  visit: "Declaration",
38491
- enter(declaration) {
38524
+ enter(declaration, item, list) {
38525
+ if (declaration.property.startsWith("--tw-")) {
38526
+ list.remove(item);
38527
+ return;
38528
+ }
38492
38529
  declaration.important = true;
38530
+ stripEmptyTailwindVars(declaration.value);
38493
38531
  }
38494
38532
  });
38495
38533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "6.3.2",
3
+ "version": "6.4.0",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.mjs"
@@ -1,6 +1,7 @@
1
1
  import { type CssNode, type Declaration, generate, walk } from 'css-tree';
2
2
  import { getReactProperty } from '../compatibility/get-react-property.js';
3
3
  import type { CustomProperties } from './get-custom-properties.js';
4
+ import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
4
5
  import { unwrapValue } from './unwrap-value.js';
5
6
 
6
7
  export function makeInlineStylesFor(
@@ -59,68 +60,7 @@ export function makeInlineStylesFor(
59
60
  if (declaration.property.startsWith('--')) {
60
61
  return;
61
62
  }
62
- // Tailwind v4 emits variant-stacking idioms like
63
- // font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
64
- // where each var() has an empty fallback so missing variants collapse to nothing.
65
- // The walker above replaces var() calls with an initialValue when one is defined,
66
- // but Tailwind deliberately leaves these variant vars undefined until used, so they
67
- // stay in the output here and produce unresolvable custom properties in email HTML
68
- // (no email client supports CSS custom properties reliably). Per the CSS spec
69
- // (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
70
- // "use empty string if the variable is undefined", which is exactly what we want at
71
- // inline-style time.
72
- //
73
- // Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
74
- // (even ones used inside tailwind utilities) are left untouched.
75
- walk(declaration.value, {
76
- visit: 'Function',
77
- leave(func, funcItem, funcList) {
78
- if (func.name !== 'var') {
79
- return;
80
- }
81
-
82
- let variableName: string | undefined;
83
- walk(func, {
84
- visit: 'Identifier',
85
- enter(identifier) {
86
- variableName = identifier.name;
87
- return this.break;
88
- },
89
- });
90
- if (!variableName?.startsWith('--tw-')) {
91
- return;
92
- }
93
-
94
- let sawComma = false;
95
- let hasFallbackContent = false;
96
- func.children.forEach((child) => {
97
- if (!sawComma) {
98
- if (child.type === 'Operator' && child.value === ',') {
99
- sawComma = true;
100
- }
101
- return;
102
- }
103
-
104
- let childValue = generate(child).trim();
105
- if (child.type === 'Raw') {
106
- const emptyTailwindVarPattern = /var\(--tw-[^,()]+,\s*\)/g;
107
- childValue = childValue
108
- .replaceAll(emptyTailwindVarPattern, '')
109
- .trim();
110
- }
111
-
112
- if (childValue.length > 0) {
113
- hasFallbackContent = true;
114
- }
115
- });
116
-
117
- if (!sawComma || hasFallbackContent) {
118
- return;
119
- }
120
-
121
- funcList.remove(funcItem);
122
- },
123
- });
63
+ stripEmptyTailwindVars(declaration.value);
124
64
 
125
65
  styles[getReactProperty(declaration.property)] =
126
66
  generate(declaration.value).trim() +
@@ -237,6 +237,22 @@ describe('resolveAllCSSVariables', () => {
237
237
  );
238
238
  });
239
239
 
240
+ it('does not leak variables across sibling nested at-rules within the same rule', () => {
241
+ const root = parse(`.print_invert {
242
+ @media print {
243
+ --tw-invert: invert(100%);
244
+ filter: var(--tw-invert, none);
245
+ }
246
+ @media screen {
247
+ filter: var(--tw-invert, none);
248
+ }
249
+ }`);
250
+ resolveAllCssVariables(root);
251
+ expect(generate(root)).toMatchInlineSnapshot(
252
+ `".print_invert{@media print{--tw-invert: invert(100%);filter:invert(100%)}@media screen{filter:none}}"`,
253
+ );
254
+ });
255
+
240
256
  it('handles selectors with asterisks in attribute selectors and pseudo-functions', () => {
241
257
  const root = parse(`* {
242
258
  --global-color: red;
@@ -187,6 +187,43 @@ export function resolveAllCssVariables(node: CssNode) {
187
187
  hasReplaced = true;
188
188
  break;
189
189
  }
190
+
191
+ // Both use and definition live inside the same nested @media (or other
192
+ // at-rule) block of the same rule — e.g. Tailwind v4's
193
+ // .print_invert { @media print { --tw-invert: invert(100%); filter: var(--tw-invert,) ... } }
194
+ // The previous two checks only cover a Rule directly containing the
195
+ // declaration; this one covers Rule → Block → Atrule → Block → Declaration
196
+ // on both sides.
197
+ if (
198
+ use.path[0]?.type === 'Block' &&
199
+ use.path[1]?.type === 'Atrule' &&
200
+ use.path[2]?.type === 'Block' &&
201
+ use.path[3]?.type === 'Rule' &&
202
+ definition.path[0]?.type === 'Block' &&
203
+ definition.path[1]?.type === 'Atrule' &&
204
+ definition.path[2]?.type === 'Block' &&
205
+ definition.path[3]?.type === 'Rule' &&
206
+ use.path[1].name === definition.path[1].name &&
207
+ (use.path[1].prelude
208
+ ? definition.path[1].prelude
209
+ ? generate(use.path[1].prelude) ===
210
+ generate(definition.path[1].prelude)
211
+ : false
212
+ : definition.path[1].prelude === null) &&
213
+ doSelectorsIntersect(use.path[3].prelude, definition.path[3].prelude)
214
+ ) {
215
+ use.declaration.value = parse(
216
+ generate(use.declaration.value).replaceAll(
217
+ use.raw,
218
+ definition.definition,
219
+ ),
220
+ {
221
+ context: 'value',
222
+ },
223
+ ) as Raw | Value;
224
+ hasReplaced = true;
225
+ break;
226
+ }
190
227
  }
191
228
 
192
229
  if (!hasReplaced && use.fallback) {
@@ -1,4 +1,5 @@
1
1
  import { generate } from 'css-tree';
2
+ import { sanitizeStyleSheet } from '../../sanitize-stylesheet.js';
2
3
  import { setupTailwind } from '../tailwindcss/setup-tailwind.js';
3
4
  import { sanitizeNonInlinableRules } from './sanitize-non-inlinable-rules.js';
4
5
 
@@ -27,7 +28,27 @@ describe('sanitizeNonInlinableRules()', () => {
27
28
 
28
29
  sanitizeNonInlinableRules(stylesheet);
29
30
  expect(generate(stylesheet)).toMatchInlineSnapshot(
30
- `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer theme{:root,:host{--color-sky-600: oklch(58.8% 0.158 241.966)!important;--color-gray-100: oklch(96.7% 0.003 264.542)!important}}@layer utilities{.hover_text-sky-600{&:hover{@media (hover:hover){color:var(--color-sky-600)!important}}}.sm_focus_outline-none{@media (width>=40rem){&:focus{--tw-outline-style: none!important;outline-style:none!important}}}.md_hover_bg-gray-100{@media (width>=48rem){&:hover{@media (hover:hover){background-color:var(--color-gray-100)!important}}}}.lg_focus_underline{@media (width>=64rem){&:focus{text-decoration-line:underline!important}}}}"`,
31
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer theme{:root,:host{--color-sky-600: oklch(58.8% 0.158 241.966)!important;--color-gray-100: oklch(96.7% 0.003 264.542)!important}}@layer utilities{.hover_text-sky-600{&:hover{@media (hover:hover){color:var(--color-sky-600)!important}}}.sm_focus_outline-none{@media (width>=40rem){&:focus{outline-style:none!important}}}.md_hover_bg-gray-100{@media (width>=48rem){&:hover{@media (hover:hover){background-color:var(--color-gray-100)!important}}}}.lg_focus_underline{@media (width>=64rem){&:focus{text-decoration-line:underline!important}}}}"`,
32
+ );
33
+ });
34
+
35
+ it('strips Tailwind v4 variant-stacking var() refs with empty fallbacks inside print: media queries', async () => {
36
+ // Mirrors the inline-style behavior asserted in make-inline-styles-for.spec.ts.
37
+ // `print:invert` compiles to a filter value that is a chain of var(--tw-...,)
38
+ // with empty fallbacks. After resolveAllCssVariables the filter is concrete;
39
+ // sanitizeNonInlinableRules then drops the leftover --tw-* declarations and
40
+ // any remaining empty-fallback var() refs.
41
+ const tailwind = await setupTailwind({});
42
+ tailwind.addUtilities(['md:block', 'print:invert']);
43
+ const stylesheet = tailwind.getStyleSheet();
44
+
45
+ sanitizeStyleSheet(stylesheet);
46
+ sanitizeNonInlinableRules(stylesheet);
47
+ const result = generate(stylesheet);
48
+
49
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
50
+ expect(result).toMatchInlineSnapshot(
51
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer utilities{.md_block{@media (width>=48rem){display:block!important}}.print_invert{@media print{filter:invert(100%)!important}}}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}"`,
31
52
  );
32
53
  });
33
54
 
@@ -1,6 +1,7 @@
1
1
  import { type CssNode, string, walk } from 'css-tree';
2
2
  import { sanitizeClassName } from '../compatibility/sanitize-class-name.js';
3
3
  import { isRuleInlinable } from './is-rule-inlinable.js';
4
+ import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
4
5
 
5
6
  /**
6
7
  * This function goes through a few steps to ensure the best email client support and
@@ -10,6 +11,12 @@ import { isRuleInlinable } from './is-rule-inlinable.js';
10
11
  * What it does:
11
12
  * 1. Converts all declarations in all rules into important ones
12
13
  * 2. Sanitizes class selectors of all non-inlinable rules
14
+ * 3. Removes --tw-* custom property declarations — by this point all CSS
15
+ * variables have been resolved, so these are dead weight in email HTML.
16
+ * 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
17
+ * variant-stacking idioms (filter, font-variant-numeric, etc.) — email
18
+ * clients can't resolve CSS custom properties reliably, so any --tw-*
19
+ * left as a bare empty-fallback ref would reach the client broken.
13
20
  */
14
21
  export function sanitizeNonInlinableRules(node: CssNode) {
15
22
  walk(node, {
@@ -25,8 +32,14 @@ export function sanitizeNonInlinableRules(node: CssNode) {
25
32
 
26
33
  walk(rule, {
27
34
  visit: 'Declaration',
28
- enter(declaration) {
35
+ enter(declaration, item, list) {
36
+ if (declaration.property.startsWith('--tw-')) {
37
+ list.remove(item);
38
+ return;
39
+ }
40
+
29
41
  declaration.important = true;
42
+ stripEmptyTailwindVars(declaration.value);
30
43
  },
31
44
  });
32
45
  }
@@ -0,0 +1,128 @@
1
+ import {
2
+ type Atrule,
3
+ type Declaration,
4
+ generate,
5
+ parse,
6
+ type Rule,
7
+ type StyleSheet,
8
+ walk,
9
+ } from 'css-tree';
10
+ import { sanitizeStyleSheet } from '../../sanitize-stylesheet.js';
11
+ import { setupTailwind } from '../tailwindcss/setup-tailwind.js';
12
+ import { isRuleInlinable } from './is-rule-inlinable.js';
13
+ import { sanitizeNonInlinableRules } from './sanitize-non-inlinable-rules.js';
14
+ import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
15
+
16
+ describe('stripEmptyTailwindVars()', () => {
17
+ it('removes empty-fallback var(--tw-*,) refs from declaration values', () => {
18
+ const stylesheet = parse(`
19
+ .tabular-nums {
20
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,);
21
+ }
22
+ `) as StyleSheet;
23
+
24
+ const rule = stylesheet.children.first as Rule;
25
+ const declaration = rule.block.children.first as Declaration;
26
+ stripEmptyTailwindVars(declaration.value);
27
+
28
+ expect(generate(declaration.value)).toBe('tabular-nums');
29
+ });
30
+
31
+ it('does not remove var() refs with non-empty fallbacks or non --tw- names', () => {
32
+ const stylesheet = parse(`
33
+ .thing {
34
+ line-height: var(--tw-leading, var(--text-lg--line-height));
35
+ color: var(--my-color,);
36
+ }
37
+ `) as StyleSheet;
38
+
39
+ const rule = stylesheet.children.first as Rule;
40
+ const leading = rule.block.children.first as Declaration;
41
+ const color = rule.block.children.last as Declaration;
42
+
43
+ stripEmptyTailwindVars(leading.value);
44
+ stripEmptyTailwindVars(color.value);
45
+
46
+ expect(generate(leading.value)).toBe(
47
+ 'var(--tw-leading, var(--text-lg--line-height))',
48
+ );
49
+ expect(generate(color.value)).toBe('var(--my-color,)');
50
+ });
51
+
52
+ it('does not remove --tw-* custom property declarations (only var() usages in values)', () => {
53
+ const stylesheet = parse(`
54
+ .print_border-solid {
55
+ @media print {
56
+ --tw-border-style: solid;
57
+ border-style: var(--tw-border-style,);
58
+ }
59
+ }
60
+ `) as StyleSheet;
61
+
62
+ const rule = stylesheet.children.first as Rule;
63
+ const atrule = rule.block.children.first as Atrule;
64
+ const twDeclaration = atrule.block!.children.first as Declaration;
65
+ const borderDeclaration = atrule.block!.children.last as Declaration;
66
+
67
+ stripEmptyTailwindVars(borderDeclaration.value);
68
+
69
+ expect(twDeclaration.property).toBe('--tw-border-style');
70
+ expect(generate(twDeclaration.value).trim()).toBe('solid');
71
+ expect(generate(borderDeclaration.value)).toBe('');
72
+ expect(generate(stylesheet)).toContain('--tw-border-style: solid');
73
+ });
74
+ });
75
+
76
+ describe('stripEmptyTailwindVars() with non-inlinable print: rules', () => {
77
+ it('print:border-solid still leaves --tw-* declarations if only stripEmptyTailwindVars runs', async () => {
78
+ const tailwind = await setupTailwind({});
79
+ tailwind.addUtilities(['print:border-solid']);
80
+ const stylesheet = tailwind.getStyleSheet();
81
+
82
+ sanitizeStyleSheet(stylesheet);
83
+
84
+ walkDeclarationsInNonInlinableRules(stylesheet, (declaration) => {
85
+ stripEmptyTailwindVars(declaration.value);
86
+ });
87
+
88
+ const result = generate(stylesheet);
89
+
90
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
91
+ expect(result).toMatch(/--tw-[^:]+:/);
92
+ });
93
+
94
+ it('print:border-solid is clean after sanitizeNonInlinableRules removes resolved --tw-* declarations', async () => {
95
+ const tailwind = await setupTailwind({});
96
+ tailwind.addUtilities(['print:border-solid']);
97
+ const stylesheet = tailwind.getStyleSheet();
98
+
99
+ sanitizeStyleSheet(stylesheet);
100
+ sanitizeNonInlinableRules(stylesheet);
101
+ const result = generate(stylesheet);
102
+
103
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
104
+ expect(result).not.toMatch(/--tw-[^:]+:/);
105
+ expect(result).toMatchInlineSnapshot(
106
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer utilities{.print_border-solid{@media print{border-style:solid!important}}}"`,
107
+ );
108
+ });
109
+ });
110
+
111
+ function walkDeclarationsInNonInlinableRules(
112
+ node: StyleSheet,
113
+ onDeclaration: (declaration: Declaration) => void,
114
+ ) {
115
+ walk(node, {
116
+ visit: 'Rule',
117
+ enter(rule) {
118
+ if (!isRuleInlinable(rule)) {
119
+ walk(rule, {
120
+ visit: 'Declaration',
121
+ enter(declaration) {
122
+ onDeclaration(declaration);
123
+ },
124
+ });
125
+ }
126
+ },
127
+ });
128
+ }
@@ -0,0 +1,70 @@
1
+ import { type CssNode, generate, walk } from 'css-tree';
2
+
3
+ /**
4
+ * Tailwind v4 emits variant-stacking idioms like
5
+ * font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
6
+ * filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
7
+ * where each var() has an empty fallback so missing variants collapse to nothing.
8
+ * Tailwind deliberately leaves these variant vars undefined until used, so they
9
+ * stay in the output here and produce unresolvable custom properties in email HTML
10
+ * (no email client supports CSS custom properties reliably). Per the CSS spec
11
+ * (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
12
+ * "use empty string if the variable is undefined", which is exactly what we want.
13
+ *
14
+ * Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
15
+ * are left untouched.
16
+ *
17
+ * Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
18
+ * correctly after the inner var() has been removed.
19
+ */
20
+ export function stripEmptyTailwindVars(node: CssNode) {
21
+ walk(node, {
22
+ visit: 'Function',
23
+ leave(func, funcItem, funcList) {
24
+ if (func.name !== 'var') {
25
+ return;
26
+ }
27
+
28
+ let variableName: string | undefined;
29
+ walk(func, {
30
+ visit: 'Identifier',
31
+ enter(identifier) {
32
+ variableName = identifier.name;
33
+ return this.break;
34
+ },
35
+ });
36
+ if (!variableName?.startsWith('--tw-')) {
37
+ return;
38
+ }
39
+
40
+ let sawComma = false;
41
+ let hasFallbackContent = false;
42
+ func.children.forEach((child) => {
43
+ if (!sawComma) {
44
+ if (child.type === 'Operator' && child.value === ',') {
45
+ sawComma = true;
46
+ }
47
+ return;
48
+ }
49
+
50
+ let childValue = generate(child).trim();
51
+ if (child.type === 'Raw') {
52
+ const emptyTailwindVarPattern = /var\(--tw-[^,()]+,\s*\)/g;
53
+ childValue = childValue
54
+ .replaceAll(emptyTailwindVarPattern, '')
55
+ .trim();
56
+ }
57
+
58
+ if (childValue.length > 0) {
59
+ hasFallbackContent = true;
60
+ }
61
+ });
62
+
63
+ if (!sawComma || hasFallbackContent) {
64
+ return;
65
+ }
66
+
67
+ funcList.remove(funcItem);
68
+ },
69
+ });
70
+ }
package/tsconfig.json CHANGED
@@ -35,5 +35,10 @@
35
35
  "outDir": "dist"
36
36
  },
37
37
  "include": ["src/**/*.ts", "src/**/*.tsx"],
38
- "exclude": ["dist", "node_modules"]
38
+ "exclude": [
39
+ "dist",
40
+ "node_modules",
41
+ "src/cli/utils/preview/hot-reloading/test",
42
+ "src/components/tailwind/e2e"
43
+ ]
39
44
  }