mirror of
https://github.com/resend/react-email.git
synced 2026-09-14 14:18:02 +08:00
fix(tailwind): inlinable styles not handled optimally in mixed rules (#2949)
This commit is contained in:
committed by
GitHub
parent
9015f76aeb
commit
6ff41b716f
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@react-email/tailwind": patch
|
||||
---
|
||||
|
||||
- enhance CSS rule extraction and inline style handling
|
||||
@@ -390,7 +390,7 @@ describe('Tailwind component', () => {
|
||||
);
|
||||
|
||||
expect(actualOutput).toMatchInlineSnapshot(
|
||||
`"<!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{color:green!important;@media (prefers-color-scheme:dark){color:orange!important}}</style></head><body class="text-body"><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td>this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
|
||||
`"<!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"><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>"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -560,11 +560,11 @@ describe('Tailwind component', () => {
|
||||
<head>
|
||||
<!--$-->
|
||||
<style>
|
||||
.text-body{color:green!important;@media (width>=40rem){color:darkgreen!important}}
|
||||
.text-body{@media (width>=40rem){color:darkgreen!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-body"></div>
|
||||
<div class="text-body" style="color:green"></div>
|
||||
<!--/$-->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -77,4 +77,39 @@ describe('extractRulesPerClass()', async () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('splits mixed rules (base + media) into inlinable base and non-inlinable media', async () => {
|
||||
const tailwind = await setupTailwind({
|
||||
plugins: [
|
||||
{
|
||||
handler: (api) => {
|
||||
api.addUtilities({
|
||||
'.text-body': {
|
||||
'@apply text-[green] sm:text-[darkgreen]': {},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const classes = ['text-body'];
|
||||
tailwind.addUtilities(classes);
|
||||
|
||||
const stylesheet = tailwind.getStyleSheet();
|
||||
const { inlinable, nonInlinable } = extractRulesPerClass(
|
||||
stylesheet,
|
||||
classes,
|
||||
);
|
||||
|
||||
expect(convertToComparable(inlinable)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"text-body": ".text-body{color:green}",
|
||||
}
|
||||
`);
|
||||
expect(convertToComparable(nonInlinable)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"text-body": ".text-body{@media (width>=40rem){color:darkgreen}}",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CssNode, type Rule, string, walk } from 'css-tree';
|
||||
import { isRuleInlinable } from './is-rule-inlinable';
|
||||
import { splitMixedRule } from './split-mixed-rule';
|
||||
|
||||
export function extractRulesPerClass(root: CssNode, classes: string[]) {
|
||||
const classSet = new Set(classes);
|
||||
@@ -23,9 +24,14 @@ export function extractRulesPerClass(root: CssNode, classes: string[]) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
|
||||
for (const className of selectorClasses) {
|
||||
if (classSet.has(className)) {
|
||||
nonInlinableRules.set(className, rule);
|
||||
if (!classSet.has(className)) continue;
|
||||
if (inlinablePart) {
|
||||
inlinableRules.set(className, inlinablePart);
|
||||
}
|
||||
if (nonInlinablePart) {
|
||||
nonInlinableRules.set(className, nonInlinablePart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type CssNode, find } from 'css-tree';
|
||||
|
||||
export function isPartInlinable(part: CssNode): boolean {
|
||||
const hasAtRuleInside = find(part, (node) => node.type === 'Atrule') !== null;
|
||||
|
||||
const hasPseudoSelector =
|
||||
find(
|
||||
part,
|
||||
(node) =>
|
||||
node.type === 'PseudoClassSelector' ||
|
||||
node.type === 'PseudoElementSelector',
|
||||
) !== null;
|
||||
|
||||
return !hasAtRuleInside && !hasPseudoSelector;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type CssNode, clone, List, type Rule } from 'css-tree';
|
||||
import { isPartInlinable } from './is-part-inlinable';
|
||||
|
||||
/**
|
||||
* Split a rule into inlinable and non-inlinable parts. Caller must only pass rules
|
||||
* for which isRuleInlinable(rule) is false. Returns clones so the original stylesheet is never mutated.
|
||||
*
|
||||
* @returns inlinablePart: rule with only inlinable block children, or null if none.
|
||||
* nonInlinablePart: rule with only non-inlinable block children, or null if none.
|
||||
*/
|
||||
export function splitMixedRule(rule: Rule): {
|
||||
inlinablePart: Rule | null;
|
||||
nonInlinablePart: Rule | null;
|
||||
} {
|
||||
const ruleCloneInlinable = clone(rule) as Rule;
|
||||
const ruleCloneNonInlinable = clone(rule) as Rule;
|
||||
|
||||
const inlinableParts: CssNode[] = [];
|
||||
const nonInlinableParts: CssNode[] = [];
|
||||
|
||||
for (const part of ruleCloneInlinable.block.children.toArray()) {
|
||||
if (isPartInlinable(part)) {
|
||||
inlinableParts.push(part);
|
||||
} else {
|
||||
nonInlinableParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
const inlinablePart =
|
||||
inlinableParts.length > 0
|
||||
? {
|
||||
...ruleCloneInlinable,
|
||||
block: {
|
||||
type: 'Block' as const,
|
||||
children: new List<CssNode>().fromArray(inlinableParts),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const nonInlinablePart =
|
||||
nonInlinableParts.length > 0
|
||||
? {
|
||||
...ruleCloneNonInlinable,
|
||||
block: {
|
||||
type: 'Block' as const,
|
||||
children: new List<CssNode>().fromArray(nonInlinableParts),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
return { inlinablePart, nonInlinablePart };
|
||||
}
|
||||
@@ -24,7 +24,10 @@ export function cloneElementWithInlinedStyles(
|
||||
const rule = inlinableRules.get(className);
|
||||
if (rule) {
|
||||
rules.push(rule);
|
||||
} else {
|
||||
}
|
||||
if (nonInlinableRules.has(className)) {
|
||||
residualClasses.push(className);
|
||||
} else if (!rule) {
|
||||
residualClasses.push(className);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user