react-email 6.9.0 → 6.9.1
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 +7 -0
- package/dist/cli/index.mjs +15 -6
- package/dist/components/tailwind/utils/css/constants.cjs +4 -0
- package/dist/components/tailwind/utils/css/constants.mjs +4 -0
- package/dist/components/tailwind/utils/css/downlevel-for-email-clients.cjs +2 -1
- package/dist/components/tailwind/utils/css/downlevel-for-email-clients.mjs +2 -1
- package/dist/components/tailwind/utils/css/extract-rules-per-class.cjs +64 -21
- package/dist/components/tailwind/utils/css/extract-rules-per-class.mjs +65 -22
- package/package.json +1 -1
- package/src/components/tailwind/utils/css/constants.ts +5 -0
- package/src/components/tailwind/utils/css/downlevel-for-email-clients.ts +2 -1
- package/src/components/tailwind/utils/css/extract-rules-per-class.spec.ts +93 -1
- package/src/components/tailwind/utils/css/extract-rules-per-class.ts +116 -40
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# react-email
|
|
2
2
|
|
|
3
|
+
## 6.9.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ea02028: Serve static assets with URL-encoded filenames correctly in the preview server while preventing decoded paths from escaping the static directory.
|
|
8
|
+
- 60c96a6: Fix `<Tailwind>` dropping `dark:` and other media-query variants with `tailwindcss@4.3.3`+, where the conditional value was inlined as the base style and the `@media` rule never reached the `<head>`
|
|
9
|
+
|
|
3
10
|
## 6.9.0
|
|
4
11
|
|
|
5
12
|
## 6.8.1
|
package/dist/cli/index.mjs
CHANGED
|
@@ -6533,7 +6533,7 @@ const getTracingRootDir = async (usersProjectLocation) => {
|
|
|
6533
6533
|
//#region package.json
|
|
6534
6534
|
var package_default = {
|
|
6535
6535
|
name: "react-email",
|
|
6536
|
-
version: "6.9.
|
|
6536
|
+
version: "6.9.1",
|
|
6537
6537
|
description: "A live preview of your emails right in your browser.",
|
|
6538
6538
|
bin: { "email": "./dist/cli/index.mjs" },
|
|
6539
6539
|
type: "module",
|
|
@@ -7249,15 +7249,24 @@ const getEnvVariablesForPreviewApp = (relativePathToEmailsDirectory, previewServ
|
|
|
7249
7249
|
//#endregion
|
|
7250
7250
|
//#region src/cli/utils/preview/serve-static-file.ts
|
|
7251
7251
|
const serveStaticFile = async (res, pathname, staticDirRelativePath) => {
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7252
|
+
let decodedPathname;
|
|
7253
|
+
try {
|
|
7254
|
+
decodedPathname = decodeURIComponent(pathname);
|
|
7255
|
+
} catch {
|
|
7256
|
+
res.statusCode = 400;
|
|
7257
|
+
res.end();
|
|
7258
|
+
return;
|
|
7259
|
+
}
|
|
7260
|
+
const staticBaseDir = path.resolve(process.cwd(), staticDirRelativePath, "static");
|
|
7261
|
+
const staticFilePath = decodedPathname.replace(/^\/static\/?/, "");
|
|
7262
|
+
const fileAbsolutePath = path.resolve(staticBaseDir, staticFilePath);
|
|
7263
|
+
const relativeFilePath = path.relative(staticBaseDir, fileAbsolutePath);
|
|
7264
|
+
if (relativeFilePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeFilePath)) {
|
|
7257
7265
|
res.statusCode = 403;
|
|
7258
7266
|
res.end();
|
|
7259
7267
|
return;
|
|
7260
7268
|
}
|
|
7269
|
+
const ext = path.extname(fileAbsolutePath);
|
|
7261
7270
|
try {
|
|
7262
7271
|
const fileHandle = await promises.open(fileAbsolutePath, "r");
|
|
7263
7272
|
const fileData = await promises.readFile(fileHandle);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require("../../../../_virtual/_rolldown/runtime.cjs");
|
|
2
|
+
const require_constants = require("./constants.cjs");
|
|
2
3
|
let css_tree = require("css-tree");
|
|
3
4
|
//#region src/components/tailwind/utils/css/downlevel-for-email-clients.ts
|
|
4
5
|
/**
|
|
@@ -43,7 +44,7 @@ function unnestMediaQueries(styleSheet) {
|
|
|
43
44
|
const nestedAtrules = [];
|
|
44
45
|
const remainingChildren = [];
|
|
45
46
|
rule.block.children.forEach((child) => {
|
|
46
|
-
if (child.type === "Atrule" && (child.name
|
|
47
|
+
if (child.type === "Atrule" && require_constants.NON_INLINABLE_ATRULES.has(child.name.toLowerCase())) nestedAtrules.push(child);
|
|
47
48
|
else remainingChildren.push(child);
|
|
48
49
|
});
|
|
49
50
|
if (nestedAtrules.length > 0) transforms.push({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { NON_INLINABLE_ATRULES } from "./constants.mjs";
|
|
1
2
|
import { List, clone, walk } from "css-tree/dist/csstree.esm";
|
|
2
3
|
//#region src/components/tailwind/utils/css/downlevel-for-email-clients.ts
|
|
3
4
|
/**
|
|
@@ -42,7 +43,7 @@ function unnestMediaQueries(styleSheet) {
|
|
|
42
43
|
const nestedAtrules = [];
|
|
43
44
|
const remainingChildren = [];
|
|
44
45
|
rule.block.children.forEach((child) => {
|
|
45
|
-
if (child.type === "Atrule" && (child.name
|
|
46
|
+
if (child.type === "Atrule" && NON_INLINABLE_ATRULES.has(child.name.toLowerCase())) nestedAtrules.push(child);
|
|
46
47
|
else remainingChildren.push(child);
|
|
47
48
|
});
|
|
48
49
|
if (nestedAtrules.length > 0) transforms.push({
|
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
require("../../../../_virtual/_rolldown/runtime.cjs");
|
|
2
|
+
const require_constants = require("./constants.cjs");
|
|
2
3
|
const require_is_rule_inlinable = require("./is-rule-inlinable.cjs");
|
|
3
4
|
const require_split_mixed_rule = require("./split-mixed-rule.cjs");
|
|
4
5
|
let css_tree = require("css-tree");
|
|
5
6
|
//#region src/components/tailwind/utils/css/extract-rules-per-class.ts
|
|
7
|
+
/**
|
|
8
|
+
* Re-nest the at-rule(s) back inside the rule so the pipeline stays
|
|
9
|
+
* version-agnostic across Tailwind's variant shape change.
|
|
10
|
+
* See https://github.com/resend/react-email/issues/3662
|
|
11
|
+
*/
|
|
12
|
+
function nestAtRulesInsideRule(rule, enclosingAtRules) {
|
|
13
|
+
let children = (0, css_tree.clone)(rule).block.children;
|
|
14
|
+
for (let i = enclosingAtRules.length - 1; i >= 0; i--) {
|
|
15
|
+
const atRule = enclosingAtRules[i];
|
|
16
|
+
const wrapped = {
|
|
17
|
+
type: "Atrule",
|
|
18
|
+
name: atRule.name,
|
|
19
|
+
prelude: atRule.prelude ? (0, css_tree.clone)(atRule.prelude) : null,
|
|
20
|
+
block: {
|
|
21
|
+
type: "Block",
|
|
22
|
+
children
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
children = new css_tree.List().fromArray([wrapped]);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
type: "Rule",
|
|
29
|
+
prelude: (0, css_tree.clone)(rule.prelude),
|
|
30
|
+
block: {
|
|
31
|
+
type: "Block",
|
|
32
|
+
children
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
6
36
|
function extractRulesPerClass(root, classes) {
|
|
7
37
|
const classSet = new Set(classes);
|
|
8
38
|
const inlinableRules = /* @__PURE__ */ new Map();
|
|
@@ -12,28 +42,41 @@ function extractRulesPerClass(root, classes) {
|
|
|
12
42
|
if (existing) existing.push(rule);
|
|
13
43
|
else map.set(className, [rule]);
|
|
14
44
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
selectorClasses.push(css_tree.string.decode(classSelector.name));
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
if (require_is_rule_inlinable.isRuleInlinable(rule)) {
|
|
28
|
-
for (const className of selectorClasses) if (classSet.has(className)) appendRule(inlinableRules, className, rule);
|
|
29
|
-
} else {
|
|
30
|
-
const { inlinablePart, nonInlinablePart } = require_split_mixed_rule.splitMixedRule(rule);
|
|
31
|
-
for (const className of selectorClasses) {
|
|
32
|
-
if (!classSet.has(className)) continue;
|
|
33
|
-
if (inlinablePart) appendRule(inlinableRules, className, inlinablePart);
|
|
34
|
-
if (nonInlinablePart) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
35
|
-
}
|
|
45
|
+
const enclosingAtRules = [];
|
|
46
|
+
const handleRule = (rule) => {
|
|
47
|
+
const firstSelector = rule.prelude.type === "SelectorList" ? rule.prelude.children.first : null;
|
|
48
|
+
if (firstSelector?.type === "Selector" && firstSelector.children.first?.type === "NestingSelector") return;
|
|
49
|
+
const selectorClasses = [];
|
|
50
|
+
(0, css_tree.walk)(rule.prelude, {
|
|
51
|
+
visit: "ClassSelector",
|
|
52
|
+
enter(classSelector) {
|
|
53
|
+
selectorClasses.push(css_tree.string.decode(classSelector.name));
|
|
36
54
|
}
|
|
55
|
+
});
|
|
56
|
+
if (enclosingAtRules.length > 0) {
|
|
57
|
+
const nonInlinablePart = nestAtRulesInsideRule(rule, enclosingAtRules);
|
|
58
|
+
for (const className of selectorClasses) if (classSet.has(className)) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (require_is_rule_inlinable.isRuleInlinable(rule)) {
|
|
62
|
+
for (const className of selectorClasses) if (classSet.has(className)) appendRule(inlinableRules, className, rule);
|
|
63
|
+
} else {
|
|
64
|
+
const { inlinablePart, nonInlinablePart } = require_split_mixed_rule.splitMixedRule(rule);
|
|
65
|
+
for (const className of selectorClasses) {
|
|
66
|
+
if (!classSet.has(className)) continue;
|
|
67
|
+
if (inlinablePart) appendRule(inlinableRules, className, inlinablePart);
|
|
68
|
+
if (nonInlinablePart) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
(0, css_tree.walk)(root, {
|
|
73
|
+
enter(node) {
|
|
74
|
+
if (node.type === "Atrule") {
|
|
75
|
+
if (require_constants.NON_INLINABLE_ATRULES.has(node.name.toLowerCase())) enclosingAtRules.push(node);
|
|
76
|
+
} else if (node.type === "Rule") handleRule(node);
|
|
77
|
+
},
|
|
78
|
+
leave(node) {
|
|
79
|
+
if (node.type === "Atrule" && require_constants.NON_INLINABLE_ATRULES.has(node.name.toLowerCase())) enclosingAtRules.pop();
|
|
37
80
|
}
|
|
38
81
|
});
|
|
39
82
|
return {
|
|
@@ -1,7 +1,37 @@
|
|
|
1
|
+
import { NON_INLINABLE_ATRULES } from "./constants.mjs";
|
|
1
2
|
import { isRuleInlinable } from "./is-rule-inlinable.mjs";
|
|
2
3
|
import { splitMixedRule } from "./split-mixed-rule.mjs";
|
|
3
|
-
import { string, walk } from "css-tree/dist/csstree.esm";
|
|
4
|
+
import { List, clone, string, walk } from "css-tree/dist/csstree.esm";
|
|
4
5
|
//#region src/components/tailwind/utils/css/extract-rules-per-class.ts
|
|
6
|
+
/**
|
|
7
|
+
* Re-nest the at-rule(s) back inside the rule so the pipeline stays
|
|
8
|
+
* version-agnostic across Tailwind's variant shape change.
|
|
9
|
+
* See https://github.com/resend/react-email/issues/3662
|
|
10
|
+
*/
|
|
11
|
+
function nestAtRulesInsideRule(rule, enclosingAtRules) {
|
|
12
|
+
let children = clone(rule).block.children;
|
|
13
|
+
for (let i = enclosingAtRules.length - 1; i >= 0; i--) {
|
|
14
|
+
const atRule = enclosingAtRules[i];
|
|
15
|
+
const wrapped = {
|
|
16
|
+
type: "Atrule",
|
|
17
|
+
name: atRule.name,
|
|
18
|
+
prelude: atRule.prelude ? clone(atRule.prelude) : null,
|
|
19
|
+
block: {
|
|
20
|
+
type: "Block",
|
|
21
|
+
children
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
children = new List().fromArray([wrapped]);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
type: "Rule",
|
|
28
|
+
prelude: clone(rule.prelude),
|
|
29
|
+
block: {
|
|
30
|
+
type: "Block",
|
|
31
|
+
children
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
5
35
|
function extractRulesPerClass(root, classes) {
|
|
6
36
|
const classSet = new Set(classes);
|
|
7
37
|
const inlinableRules = /* @__PURE__ */ new Map();
|
|
@@ -11,28 +41,41 @@ function extractRulesPerClass(root, classes) {
|
|
|
11
41
|
if (existing) existing.push(rule);
|
|
12
42
|
else map.set(className, [rule]);
|
|
13
43
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
selectorClasses.push(string.decode(classSelector.name));
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
if (isRuleInlinable(rule)) {
|
|
27
|
-
for (const className of selectorClasses) if (classSet.has(className)) appendRule(inlinableRules, className, rule);
|
|
28
|
-
} else {
|
|
29
|
-
const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
|
|
30
|
-
for (const className of selectorClasses) {
|
|
31
|
-
if (!classSet.has(className)) continue;
|
|
32
|
-
if (inlinablePart) appendRule(inlinableRules, className, inlinablePart);
|
|
33
|
-
if (nonInlinablePart) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
34
|
-
}
|
|
44
|
+
const enclosingAtRules = [];
|
|
45
|
+
const handleRule = (rule) => {
|
|
46
|
+
const firstSelector = rule.prelude.type === "SelectorList" ? rule.prelude.children.first : null;
|
|
47
|
+
if (firstSelector?.type === "Selector" && firstSelector.children.first?.type === "NestingSelector") return;
|
|
48
|
+
const selectorClasses = [];
|
|
49
|
+
walk(rule.prelude, {
|
|
50
|
+
visit: "ClassSelector",
|
|
51
|
+
enter(classSelector) {
|
|
52
|
+
selectorClasses.push(string.decode(classSelector.name));
|
|
35
53
|
}
|
|
54
|
+
});
|
|
55
|
+
if (enclosingAtRules.length > 0) {
|
|
56
|
+
const nonInlinablePart = nestAtRulesInsideRule(rule, enclosingAtRules);
|
|
57
|
+
for (const className of selectorClasses) if (classSet.has(className)) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (isRuleInlinable(rule)) {
|
|
61
|
+
for (const className of selectorClasses) if (classSet.has(className)) appendRule(inlinableRules, className, rule);
|
|
62
|
+
} else {
|
|
63
|
+
const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
|
|
64
|
+
for (const className of selectorClasses) {
|
|
65
|
+
if (!classSet.has(className)) continue;
|
|
66
|
+
if (inlinablePart) appendRule(inlinableRules, className, inlinablePart);
|
|
67
|
+
if (nonInlinablePart) appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(root, {
|
|
72
|
+
enter(node) {
|
|
73
|
+
if (node.type === "Atrule") {
|
|
74
|
+
if (NON_INLINABLE_ATRULES.has(node.name.toLowerCase())) enclosingAtRules.push(node);
|
|
75
|
+
} else if (node.type === "Rule") handleRule(node);
|
|
76
|
+
},
|
|
77
|
+
leave(node) {
|
|
78
|
+
if (node.type === "Atrule" && NON_INLINABLE_ATRULES.has(node.name.toLowerCase())) enclosingAtRules.pop();
|
|
36
79
|
}
|
|
37
80
|
});
|
|
38
81
|
return {
|
package/package.json
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// At-rules whose contents are conditional, so their declarations can't be
|
|
2
|
+
// inlined onto an element (e.g. `@media (prefers-color-scheme: dark)`). Shared
|
|
3
|
+
// so the "mark non-inlinable" (extract-rules-per-class) and "hoist out of the
|
|
4
|
+
// rule" (downlevel-for-email-clients) passes can't disagree on the set.
|
|
5
|
+
export const NON_INLINABLE_ATRULES = new Set(['media', 'supports']);
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type StyleSheet,
|
|
27
27
|
walk,
|
|
28
28
|
} from 'css-tree';
|
|
29
|
+
import { NON_INLINABLE_ATRULES } from './constants.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* css-tree 3.x introduced new AST node types for query-related at-rules that
|
|
@@ -114,7 +115,7 @@ function unnestMediaQueries(styleSheet: StyleSheet): void {
|
|
|
114
115
|
rule.block.children.forEach((child) => {
|
|
115
116
|
if (
|
|
116
117
|
child.type === 'Atrule' &&
|
|
117
|
-
(child.name
|
|
118
|
+
NON_INLINABLE_ATRULES.has(child.name.toLowerCase())
|
|
118
119
|
) {
|
|
119
120
|
nestedAtrules.push(child);
|
|
120
121
|
} else {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generate, type Rule } from 'css-tree';
|
|
1
|
+
import { generate, parse, type Rule, type StyleSheet } from 'css-tree';
|
|
2
2
|
import { setupTailwind } from '../tailwindcss/setup-tailwind.js';
|
|
3
3
|
import { extractRulesPerClass } from './extract-rules-per-class.js';
|
|
4
4
|
|
|
@@ -207,6 +207,98 @@ describe('extractRulesPerClass()', async () => {
|
|
|
207
207
|
`);
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
// Wrapped shape emitted by Tailwind >=4.3.3.
|
|
211
|
+
// See https://github.com/resend/react-email/issues/3662
|
|
212
|
+
it('treats @media-wrapped rules (Tailwind >=4.3.3) as non-inlinable', () => {
|
|
213
|
+
const classes = [
|
|
214
|
+
'bg-white',
|
|
215
|
+
'text-black',
|
|
216
|
+
'dark:bg-black',
|
|
217
|
+
'dark:text-white',
|
|
218
|
+
];
|
|
219
|
+
const stylesheet = parse(`
|
|
220
|
+
@layer utilities {
|
|
221
|
+
.bg-white { background-color: rgb(255, 255, 255); }
|
|
222
|
+
.text-black { color: rgb(0, 0, 0); }
|
|
223
|
+
@media (prefers-color-scheme: dark) {
|
|
224
|
+
.dark\\:bg-black { background-color: rgb(0, 0, 0); }
|
|
225
|
+
.dark\\:text-white { color: rgb(255, 255, 255); }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
`) as StyleSheet;
|
|
229
|
+
|
|
230
|
+
const { inlinable, nonInlinable } = extractRulesPerClass(
|
|
231
|
+
stylesheet,
|
|
232
|
+
classes,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(convertToComparable(inlinable)).toMatchInlineSnapshot(`
|
|
236
|
+
{
|
|
237
|
+
"bg-white": [
|
|
238
|
+
".bg-white{background-color:rgb(255,255,255)}",
|
|
239
|
+
],
|
|
240
|
+
"text-black": [
|
|
241
|
+
".text-black{color:rgb(0,0,0)}",
|
|
242
|
+
],
|
|
243
|
+
}
|
|
244
|
+
`);
|
|
245
|
+
expect(convertToComparable(nonInlinable)).toMatchInlineSnapshot(`
|
|
246
|
+
{
|
|
247
|
+
"dark:bg-black": [
|
|
248
|
+
".dark\\:bg-black{@media (prefers-color-scheme:dark){background-color:rgb(0,0,0)}}",
|
|
249
|
+
],
|
|
250
|
+
"dark:text-white": [
|
|
251
|
+
".dark\\:text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)}}",
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Nested shape emitted by Tailwind <=4.3.2; must classify identically.
|
|
258
|
+
// See https://github.com/resend/react-email/issues/3662
|
|
259
|
+
it('treats @media-nested rules (Tailwind <=4.3.2) as non-inlinable', () => {
|
|
260
|
+
const classes = [
|
|
261
|
+
'bg-white',
|
|
262
|
+
'text-black',
|
|
263
|
+
'dark:bg-black',
|
|
264
|
+
'dark:text-white',
|
|
265
|
+
];
|
|
266
|
+
const stylesheet = parse(`
|
|
267
|
+
@layer utilities {
|
|
268
|
+
.bg-white { background-color: rgb(255, 255, 255); }
|
|
269
|
+
.text-black { color: rgb(0, 0, 0); }
|
|
270
|
+
.dark\\:bg-black { @media (prefers-color-scheme: dark) { background-color: rgb(0, 0, 0); } }
|
|
271
|
+
.dark\\:text-white { @media (prefers-color-scheme: dark) { color: rgb(255, 255, 255); } }
|
|
272
|
+
}
|
|
273
|
+
`) as StyleSheet;
|
|
274
|
+
|
|
275
|
+
const { inlinable, nonInlinable } = extractRulesPerClass(
|
|
276
|
+
stylesheet,
|
|
277
|
+
classes,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(convertToComparable(inlinable)).toMatchInlineSnapshot(`
|
|
281
|
+
{
|
|
282
|
+
"bg-white": [
|
|
283
|
+
".bg-white{background-color:rgb(255,255,255)}",
|
|
284
|
+
],
|
|
285
|
+
"text-black": [
|
|
286
|
+
".text-black{color:rgb(0,0,0)}",
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
`);
|
|
290
|
+
expect(convertToComparable(nonInlinable)).toMatchInlineSnapshot(`
|
|
291
|
+
{
|
|
292
|
+
"dark:bg-black": [
|
|
293
|
+
".dark\\:bg-black{@media (prefers-color-scheme:dark){background-color:rgb(0,0,0)}}",
|
|
294
|
+
],
|
|
295
|
+
"dark:text-white": [
|
|
296
|
+
".dark\\:text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)}}",
|
|
297
|
+
],
|
|
298
|
+
}
|
|
299
|
+
`);
|
|
300
|
+
});
|
|
301
|
+
|
|
210
302
|
it('does not emit a bare nested rule for group/peer marker classes', async () => {
|
|
211
303
|
const tailwind = await setupTailwind({});
|
|
212
304
|
const classes = ['group', 'group-hover:underline'];
|
|
@@ -1,7 +1,52 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type Atrule,
|
|
3
|
+
type CssNode,
|
|
4
|
+
clone,
|
|
5
|
+
List,
|
|
6
|
+
type Rule,
|
|
7
|
+
string,
|
|
8
|
+
walk,
|
|
9
|
+
} from 'css-tree';
|
|
10
|
+
import { NON_INLINABLE_ATRULES } from './constants.js';
|
|
2
11
|
import { isRuleInlinable } from './is-rule-inlinable.js';
|
|
3
12
|
import { splitMixedRule } from './split-mixed-rule.js';
|
|
4
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Re-nest the at-rule(s) back inside the rule so the pipeline stays
|
|
16
|
+
* version-agnostic across Tailwind's variant shape change.
|
|
17
|
+
* See https://github.com/resend/react-email/issues/3662
|
|
18
|
+
*/
|
|
19
|
+
function nestAtRulesInsideRule(rule: Rule, enclosingAtRules: Atrule[]): Rule {
|
|
20
|
+
const ruleClone = clone(rule) as Rule;
|
|
21
|
+
let children = ruleClone.block.children;
|
|
22
|
+
|
|
23
|
+
// Wrap from the innermost enclosing at-rule outward.
|
|
24
|
+
for (let i = enclosingAtRules.length - 1; i >= 0; i--) {
|
|
25
|
+
const atRule = enclosingAtRules[i]!;
|
|
26
|
+
const wrapped: Atrule = {
|
|
27
|
+
type: 'Atrule',
|
|
28
|
+
name: atRule.name,
|
|
29
|
+
prelude: atRule.prelude
|
|
30
|
+
? (clone(atRule.prelude) as Atrule['prelude'])
|
|
31
|
+
: null,
|
|
32
|
+
block: {
|
|
33
|
+
type: 'Block',
|
|
34
|
+
children,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
children = new List<CssNode>().fromArray([wrapped]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
type: 'Rule',
|
|
42
|
+
prelude: clone(rule.prelude) as Rule['prelude'],
|
|
43
|
+
block: {
|
|
44
|
+
type: 'Block',
|
|
45
|
+
children,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
5
50
|
export function extractRulesPerClass(root: CssNode, classes: string[]) {
|
|
6
51
|
const classSet = new Set(classes);
|
|
7
52
|
|
|
@@ -23,52 +68,83 @@ export function extractRulesPerClass(root: CssNode, classes: string[]) {
|
|
|
23
68
|
}
|
|
24
69
|
};
|
|
25
70
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
71
|
+
// A plain-looking rule inside these wrappers is still conditional and must
|
|
72
|
+
// not be inlined. See https://github.com/resend/react-email/issues/3662
|
|
73
|
+
const enclosingAtRules: Atrule[] = [];
|
|
74
|
+
|
|
75
|
+
const handleRule = (rule: Rule) => {
|
|
76
|
+
// A nested rule (e.g. group/peer's `&:is(:where(.group):hover *)`) belongs
|
|
77
|
+
// to its parent utility; processing it standalone emits a bare, parentless
|
|
78
|
+
// `&` rule into the <style> block, so skip it here.
|
|
79
|
+
const firstSelector =
|
|
80
|
+
rule.prelude.type === 'SelectorList' ? rule.prelude.children.first : null;
|
|
81
|
+
if (
|
|
82
|
+
firstSelector?.type === 'Selector' &&
|
|
83
|
+
firstSelector.children.first?.type === 'NestingSelector'
|
|
84
|
+
) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only the prelude names the class that owns the rule; classes referenced
|
|
89
|
+
// inside the block (e.g. `.group` in `:where(.group)`) must not key it.
|
|
90
|
+
const selectorClasses: string[] = [];
|
|
91
|
+
walk(rule.prelude, {
|
|
92
|
+
visit: 'ClassSelector',
|
|
93
|
+
enter(classSelector) {
|
|
94
|
+
selectorClasses.push(string.decode(classSelector.name));
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (enclosingAtRules.length > 0) {
|
|
99
|
+
const nonInlinablePart = nestAtRulesInsideRule(rule, enclosingAtRules);
|
|
100
|
+
for (const className of selectorClasses) {
|
|
101
|
+
if (classSet.has(className)) {
|
|
102
|
+
appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
103
|
+
}
|
|
41
104
|
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
42
107
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
appendRule(inlinableRules, className, rule);
|
|
56
|
-
}
|
|
108
|
+
if (isRuleInlinable(rule)) {
|
|
109
|
+
for (const className of selectorClasses) {
|
|
110
|
+
if (classSet.has(className)) {
|
|
111
|
+
appendRule(inlinableRules, className, rule);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
|
|
116
|
+
for (const className of selectorClasses) {
|
|
117
|
+
if (!classSet.has(className)) continue;
|
|
118
|
+
if (inlinablePart) {
|
|
119
|
+
appendRule(inlinableRules, className, inlinablePart);
|
|
57
120
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for (const className of selectorClasses) {
|
|
61
|
-
if (!classSet.has(className)) continue;
|
|
62
|
-
if (inlinablePart) {
|
|
63
|
-
appendRule(inlinableRules, className, inlinablePart);
|
|
64
|
-
}
|
|
65
|
-
if (nonInlinablePart) {
|
|
66
|
-
appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
67
|
-
}
|
|
121
|
+
if (nonInlinablePart) {
|
|
122
|
+
appendRule(nonInlinableRules, className, nonInlinablePart);
|
|
68
123
|
}
|
|
69
124
|
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
walk(root, {
|
|
129
|
+
enter(node) {
|
|
130
|
+
if (node.type === 'Atrule') {
|
|
131
|
+
if (NON_INLINABLE_ATRULES.has(node.name.toLowerCase())) {
|
|
132
|
+
enclosingAtRules.push(node);
|
|
133
|
+
}
|
|
134
|
+
} else if (node.type === 'Rule') {
|
|
135
|
+
handleRule(node);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
leave(node) {
|
|
139
|
+
if (
|
|
140
|
+
node.type === 'Atrule' &&
|
|
141
|
+
NON_INLINABLE_ATRULES.has(node.name.toLowerCase())
|
|
142
|
+
) {
|
|
143
|
+
enclosingAtRules.pop();
|
|
144
|
+
}
|
|
70
145
|
},
|
|
71
146
|
});
|
|
147
|
+
|
|
72
148
|
return {
|
|
73
149
|
inlinable: inlinableRules,
|
|
74
150
|
nonInlinable: nonInlinableRules,
|