react-email 6.0.5 → 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 +12 -0
- package/dist/cli/index.mjs +1 -1
- package/dist/index.cjs +153 -1
- package/dist/index.mjs +153 -1
- 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/src/components/tailwind/utils/css/make-inline-styles-for.spec.ts +75 -2
- package/src/components/tailwind/utils/css/make-inline-styles-for.ts +64 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 6.0.6
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 84bb7ab: collapse empty-fallback var() refs in inline styles
|
|
14
|
+
|
|
3
15
|
## 6.0.5
|
|
4
16
|
|
|
5
17
|
## 6.0.4
|
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.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
|
@@ -37790,7 +37790,35 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
|
|
|
37790
37790
|
visit: "Declaration",
|
|
37791
37791
|
enter(declaration) {
|
|
37792
37792
|
if (declaration.property.startsWith("--")) return;
|
|
37793
|
-
|
|
37793
|
+
walk(declaration.value, {
|
|
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
|
+
styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
|
|
37794
37822
|
}
|
|
37795
37823
|
});
|
|
37796
37824
|
}
|
|
@@ -38288,6 +38316,129 @@ function useSuspensedPromise(promiseFn, key) {
|
|
|
38288
38316
|
throw state.promise;
|
|
38289
38317
|
}
|
|
38290
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
|
|
38291
38442
|
//#region src/components/tailwind/utils/compatibility/sanitize-class-name.ts
|
|
38292
38443
|
const digitToNameMap = {
|
|
38293
38444
|
"0": "zero",
|
|
@@ -40411,6 +40562,7 @@ function Tailwind({ children, config }) {
|
|
|
40411
40562
|
children: new List().fromArray(Array.from(nonInlinableRules.values()))
|
|
40412
40563
|
};
|
|
40413
40564
|
sanitizeNonInlinableRules(nonInlineStyles);
|
|
40565
|
+
downlevelForEmailClients(nonInlineStyles);
|
|
40414
40566
|
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
|
|
40415
40567
|
let appliedNonInlineStyles = false;
|
|
40416
40568
|
mappedChildren = mapReactTree(mappedChildren, (node) => {
|
package/dist/index.mjs
CHANGED
|
@@ -37769,7 +37769,35 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
|
|
|
37769
37769
|
visit: "Declaration",
|
|
37770
37770
|
enter(declaration) {
|
|
37771
37771
|
if (declaration.property.startsWith("--")) return;
|
|
37772
|
-
|
|
37772
|
+
walk(declaration.value, {
|
|
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
|
+
styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
|
|
37773
37801
|
}
|
|
37774
37802
|
});
|
|
37775
37803
|
}
|
|
@@ -38267,6 +38295,129 @@ function useSuspensedPromise(promiseFn, key) {
|
|
|
38267
38295
|
throw state.promise;
|
|
38268
38296
|
}
|
|
38269
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
|
|
38270
38421
|
//#region src/components/tailwind/utils/compatibility/sanitize-class-name.ts
|
|
38271
38422
|
const digitToNameMap = {
|
|
38272
38423
|
"0": "zero",
|
|
@@ -40390,6 +40541,7 @@ function Tailwind({ children, config }) {
|
|
|
40390
40541
|
children: new List().fromArray(Array.from(nonInlinableRules.values()))
|
|
40391
40542
|
};
|
|
40392
40543
|
sanitizeNonInlinableRules(nonInlineStyles);
|
|
40544
|
+
downlevelForEmailClients(nonInlineStyles);
|
|
40393
40545
|
const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
|
|
40394
40546
|
let appliedNonInlineStyles = false;
|
|
40395
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
|
+
}
|
|
@@ -41,11 +41,84 @@ describe('makeInlineStylesFor()', async () => {
|
|
|
41
41
|
),
|
|
42
42
|
).toMatchInlineSnapshot(`
|
|
43
43
|
{
|
|
44
|
-
"backgroundColor": "
|
|
44
|
+
"backgroundColor": "#3490dc",
|
|
45
45
|
"borderRadius": "0.25rem",
|
|
46
|
-
"color": "
|
|
46
|
+
"color": "#fff",
|
|
47
47
|
"padding": "0.5rem 1rem",
|
|
48
48
|
}
|
|
49
49
|
`);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
it('strips Tailwind v4 variant-stacking var() refs with empty fallbacks', () => {
|
|
53
|
+
// Tailwind v4 compiles `tabular-nums` to a font-variant-numeric value
|
|
54
|
+
// where every optional variant slot is represented by an unresolved
|
|
55
|
+
// var(--tw-..., ) with an empty fallback. Email clients do not support
|
|
56
|
+
// CSS custom properties reliably, so these must collapse at inline time
|
|
57
|
+
// (per CSS spec, an empty fallback resolves to empty string).
|
|
58
|
+
const tailwindStyles = parse(`
|
|
59
|
+
.tabular-nums {
|
|
60
|
+
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) tabular-nums var(--tw-numeric-fraction,);
|
|
61
|
+
}
|
|
62
|
+
`) as StyleSheet;
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
makeInlineStylesFor(
|
|
66
|
+
tailwindStyles.children.toArray(),
|
|
67
|
+
getCustomProperties(tailwindStyles),
|
|
68
|
+
),
|
|
69
|
+
).toMatchInlineSnapshot(`
|
|
70
|
+
{
|
|
71
|
+
"fontVariantNumeric": "tabular-nums",
|
|
72
|
+
}
|
|
73
|
+
`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('preserves user-authored empty-fallback var() refs (non --tw- prefix)', () => {
|
|
77
|
+
// The collapse is scoped to Tailwind's --tw-* variant-stacking idiom.
|
|
78
|
+
// A user-authored var(--my-color,) with an empty fallback must pass
|
|
79
|
+
// through unchanged even though it syntactically matches the idiom --
|
|
80
|
+
// the user opted into that semantic and the render target may define
|
|
81
|
+
// --my-color at a higher scope.
|
|
82
|
+
const userStyles = parse(`
|
|
83
|
+
.thing {
|
|
84
|
+
color: var(--my-color,);
|
|
85
|
+
background: var(--brand,) var(--tw-custom,);
|
|
86
|
+
}
|
|
87
|
+
`) as StyleSheet;
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
makeInlineStylesFor(
|
|
91
|
+
userStyles.children.toArray(),
|
|
92
|
+
getCustomProperties(userStyles),
|
|
93
|
+
),
|
|
94
|
+
).toMatchInlineSnapshot(`
|
|
95
|
+
{
|
|
96
|
+
"background": "var(--brand,)",
|
|
97
|
+
"color": "var(--my-color,)",
|
|
98
|
+
}
|
|
99
|
+
`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('collapses outer --tw-* var() that becomes empty after inner --tw-* var() collapses', () => {
|
|
103
|
+
// Regression test for cubic-dev-ai P2 review on PR #3359:
|
|
104
|
+
// pre-order traversal misses outer var(--tw-X, var(--tw-Y,)) because the
|
|
105
|
+
// outer's fallback only becomes empty AFTER the inner collapses. Post-order
|
|
106
|
+
// traversal fixes this.
|
|
107
|
+
const tailwindStyles = parse(`
|
|
108
|
+
.nested {
|
|
109
|
+
font-variant-numeric: var(--tw-outer, var(--tw-inner,)) tabular-nums;
|
|
110
|
+
}
|
|
111
|
+
`) as StyleSheet;
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
makeInlineStylesFor(
|
|
115
|
+
tailwindStyles.children.toArray(),
|
|
116
|
+
getCustomProperties(tailwindStyles),
|
|
117
|
+
),
|
|
118
|
+
).toMatchInlineSnapshot(`
|
|
119
|
+
{
|
|
120
|
+
"fontVariantNumeric": "tabular-nums",
|
|
121
|
+
}
|
|
122
|
+
`);
|
|
123
|
+
});
|
|
51
124
|
});
|
|
@@ -59,8 +59,71 @@ export function makeInlineStylesFor(
|
|
|
59
59
|
if (declaration.property.startsWith('--')) {
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
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
|
+
});
|
|
124
|
+
|
|
62
125
|
styles[getReactProperty(declaration.property)] =
|
|
63
|
-
generate(declaration.value) +
|
|
126
|
+
generate(declaration.value).trim() +
|
|
64
127
|
(declaration.important ? '!important' : '');
|
|
65
128
|
},
|
|
66
129
|
});
|