react-email 6.3.3 → 6.5.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 +16 -0
- package/dist/cli/index.mjs +45 -10
- package/dist/index.cjs +75 -33
- package/dist/index.mjs +75 -33
- package/package.json +1 -1
- package/src/components/body/__snapshots__/body.spec.tsx.snap +11 -11
- package/src/components/body/body.spec.tsx +1 -1
- package/src/components/body/body.tsx +14 -2
- package/src/components/img/img.tsx +1 -1
- package/src/components/markdown/markdown.tsx +1 -1
- package/src/components/preview/preview.spec.tsx +3 -3
- package/src/components/preview/preview.tsx +19 -16
- package/src/components/tailwind/tailwind.spec.tsx +1 -1
- package/src/components/tailwind/utils/css/make-inline-styles-for.ts +2 -62
- package/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts +16 -0
- package/src/components/tailwind/utils/css/resolve-all-css-variables.ts +37 -0
- package/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.spec.ts +22 -1
- package/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.ts +14 -1
- package/src/components/tailwind/utils/css/strip-empty-tailwind-vars.spec.ts +128 -0
- package/src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts +70 -0
- package/tsconfig.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# react-email
|
|
2
2
|
|
|
3
|
+
## 6.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3875d2a: add a `--clients` option to `email dev` and a `COMPATIBILITY_EMAIL_CLIENTS` environment variable to narrow which email clients trigger compatibility warnings. By default the preview still warns for `gmail`, `apple-mail`, `outlook`, and `yahoo`. Teams that only target one or two clients can now skip the noise: `email dev --clients outlook,apple-mail`. The CLI flag wins over the env var; an empty or fully-invalid list falls back to the defaults so warnings can't be silently switched off. Builds on #2797 by @ReemX.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- d47825a: Add accessibility defaults to components: `dir`/`lang` on `Body`, an empty `alt` fallback on `Img`, `role="presentation"` on the `Markdown` table, and a `<title>` from `Preview`.
|
|
12
|
+
|
|
13
|
+
## 6.4.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- ba99365: resolve and strip unresolved `--tw-*` CSS variables in non-inlinable rules so Tailwind media query utilities no longer break Gmail
|
|
18
|
+
|
|
3
19
|
## 6.3.3
|
|
4
20
|
|
|
5
21
|
## 6.3.2
|
package/dist/cli/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { Option, program } from "commander";
|
|
4
|
+
import { InvalidArgumentError, Option, program } from "commander";
|
|
5
5
|
import * as fs$1 from "node:fs";
|
|
6
6
|
import fs, { existsSync, promises, statSync } from "node:fs";
|
|
7
7
|
import * as path$2 from "node:path";
|
|
@@ -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.
|
|
6526
|
+
version: "6.5.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",
|
|
@@ -7226,13 +7226,14 @@ const conf = new Conf({
|
|
|
7226
7226
|
const styleText = nodeUtil.styleText ? nodeUtil.styleText : (_, text) => text;
|
|
7227
7227
|
//#endregion
|
|
7228
7228
|
//#region src/cli/utils/preview/get-env-variables-for-preview-app.ts
|
|
7229
|
-
const getEnvVariablesForPreviewApp = (relativePathToEmailsDirectory, previewServerLocation, cwd, resendApiKey) => {
|
|
7229
|
+
const getEnvVariablesForPreviewApp = (relativePathToEmailsDirectory, previewServerLocation, cwd, resendApiKey, compatibilityClients) => {
|
|
7230
7230
|
return {
|
|
7231
7231
|
REACT_EMAIL_INTERNAL_EMAILS_DIR_RELATIVE_PATH: relativePathToEmailsDirectory,
|
|
7232
7232
|
REACT_EMAIL_INTERNAL_EMAILS_DIR_ABSOLUTE_PATH: path.resolve(cwd, relativePathToEmailsDirectory),
|
|
7233
7233
|
REACT_EMAIL_INTERNAL_PREVIEW_SERVER_LOCATION: previewServerLocation,
|
|
7234
7234
|
REACT_EMAIL_INTERNAL_USER_PROJECT_LOCATION: cwd,
|
|
7235
|
-
REACT_EMAIL_INTERNAL_RESEND_API_KEY: resendApiKey
|
|
7235
|
+
REACT_EMAIL_INTERNAL_RESEND_API_KEY: resendApiKey,
|
|
7236
|
+
...compatibilityClients !== void 0 && { COMPATIBILITY_EMAIL_CLIENTS: compatibilityClients }
|
|
7236
7237
|
};
|
|
7237
7238
|
};
|
|
7238
7239
|
//#endregion
|
|
@@ -7278,7 +7279,7 @@ const safeAsyncServerListen = (server, port) => {
|
|
|
7278
7279
|
});
|
|
7279
7280
|
});
|
|
7280
7281
|
};
|
|
7281
|
-
const startDevServer = async (emailsDirRelativePath, staticBaseDirRelativePath, port) => {
|
|
7282
|
+
const startDevServer = async (emailsDirRelativePath, staticBaseDirRelativePath, port, compatibilityClients) => {
|
|
7282
7283
|
const [majorNodeVersion] = process.versions.node.split(".");
|
|
7283
7284
|
if (majorNodeVersion && Number.parseInt(majorNodeVersion, 10) < 20) {
|
|
7284
7285
|
console.error(` ${logSymbols.error} Node ${majorNodeVersion} is not supported. Please upgrade to Node 20 or higher.`);
|
|
@@ -7312,7 +7313,7 @@ const startDevServer = async (emailsDirRelativePath, staticBaseDirRelativePath,
|
|
|
7312
7313
|
} else {
|
|
7313
7314
|
const nextPortToTry = port + 1;
|
|
7314
7315
|
console.warn(` ${logSymbols.warning} Port ${port} is already in use, trying ${nextPortToTry}`);
|
|
7315
|
-
return startDevServer(emailsDirRelativePath, staticBaseDirRelativePath, nextPortToTry);
|
|
7316
|
+
return startDevServer(emailsDirRelativePath, staticBaseDirRelativePath, nextPortToTry, compatibilityClients);
|
|
7316
7317
|
}
|
|
7317
7318
|
devServer.on("close", async () => {
|
|
7318
7319
|
await app.close();
|
|
@@ -7334,7 +7335,7 @@ const startDevServer = async (emailsDirRelativePath, staticBaseDirRelativePath,
|
|
|
7334
7335
|
process.env = {
|
|
7335
7336
|
NODE_ENV: "development",
|
|
7336
7337
|
...process.env,
|
|
7337
|
-
...getEnvVariablesForPreviewApp(path.normalize(emailsDirRelativePath), previewServerLocation, process.cwd(), conf.get("resendApiKey"))
|
|
7338
|
+
...getEnvVariablesForPreviewApp(path.normalize(emailsDirRelativePath), previewServerLocation, process.cwd(), conf.get("resendApiKey"), compatibilityClients)
|
|
7338
7339
|
};
|
|
7339
7340
|
if (!process.env.ESBUILD_BINARY_PATH) try {
|
|
7340
7341
|
const esbuild = createJiti(previewServer.esmResolve("esbuild"));
|
|
@@ -7433,13 +7434,13 @@ const tree = async (dirPath, depth) => {
|
|
|
7433
7434
|
};
|
|
7434
7435
|
//#endregion
|
|
7435
7436
|
//#region src/cli/commands/dev.ts
|
|
7436
|
-
const dev = async ({ dir: emailsDirRelativePath, port }) => {
|
|
7437
|
+
const dev = async ({ dir: emailsDirRelativePath, port, clients }) => {
|
|
7437
7438
|
try {
|
|
7438
7439
|
if (!fs.existsSync(emailsDirRelativePath)) {
|
|
7439
7440
|
console.error(`Missing ${emailsDirRelativePath} folder`);
|
|
7440
7441
|
process.exit(1);
|
|
7441
7442
|
}
|
|
7442
|
-
await setupHotreloading(await startDevServer(emailsDirRelativePath, emailsDirRelativePath, Number.parseInt(port, 10)), emailsDirRelativePath);
|
|
7443
|
+
await setupHotreloading(await startDevServer(emailsDirRelativePath, emailsDirRelativePath, Number.parseInt(port, 10), clients), emailsDirRelativePath);
|
|
7443
7444
|
} catch (error) {
|
|
7444
7445
|
console.log(error);
|
|
7445
7446
|
process.exit(1);
|
|
@@ -7727,7 +7728,41 @@ const start = async () => {
|
|
|
7727
7728
|
}
|
|
7728
7729
|
};
|
|
7729
7730
|
//#endregion
|
|
7731
|
+
//#region src/cli/utils/email-clients.ts
|
|
7732
|
+
const ALL_EMAIL_CLIENTS = [
|
|
7733
|
+
"gmail",
|
|
7734
|
+
"outlook",
|
|
7735
|
+
"yahoo",
|
|
7736
|
+
"apple-mail",
|
|
7737
|
+
"aol",
|
|
7738
|
+
"thunderbird",
|
|
7739
|
+
"microsoft",
|
|
7740
|
+
"samsung-email",
|
|
7741
|
+
"sfr",
|
|
7742
|
+
"orange",
|
|
7743
|
+
"protonmail",
|
|
7744
|
+
"hey",
|
|
7745
|
+
"mail-ru",
|
|
7746
|
+
"fastmail",
|
|
7747
|
+
"laposte",
|
|
7748
|
+
"t-online-de",
|
|
7749
|
+
"free-fr",
|
|
7750
|
+
"gmx",
|
|
7751
|
+
"web-de",
|
|
7752
|
+
"ionos-1and1",
|
|
7753
|
+
"rainloop",
|
|
7754
|
+
"wp-pl"
|
|
7755
|
+
];
|
|
7756
|
+
//#endregion
|
|
7730
7757
|
//#region src/cli/index.ts
|
|
7758
|
+
const parseClientsOption = (value) => {
|
|
7759
|
+
const requested = value.split(",").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
7760
|
+
if (requested.length === 0) throw new InvalidArgumentError("--clients requires at least one email client.");
|
|
7761
|
+
const known = new Set(ALL_EMAIL_CLIENTS);
|
|
7762
|
+
const invalid = requested.filter((entry) => !known.has(entry));
|
|
7763
|
+
if (invalid.length > 0) throw new InvalidArgumentError(`Unknown email client(s): ${invalid.join(", ")}. Supported: ${ALL_EMAIL_CLIENTS.join(", ")}.`);
|
|
7764
|
+
return requested.join(",");
|
|
7765
|
+
};
|
|
7731
7766
|
const requiredFlags = ["--experimental-vm-modules", "--disable-warning=ExperimentalWarning"];
|
|
7732
7767
|
if (!requiredFlags.every((flag) => process.execArgv.includes(flag))) spawn(process.execPath, [
|
|
7733
7768
|
...requiredFlags,
|
|
@@ -7739,7 +7774,7 @@ if (!requiredFlags.every((flag) => process.execArgv.includes(flag))) spawn(proce
|
|
|
7739
7774
|
});
|
|
7740
7775
|
else {
|
|
7741
7776
|
program.name("react-email").description("A live preview of your emails right in your browser").version(package_default.version);
|
|
7742
|
-
program.command("dev").description("Starts the preview email development app").option("-d, --dir <path>", "Directory with your email templates", "./emails").option("-p --port <port>", "Port to run dev server on", "3000").action(dev);
|
|
7777
|
+
program.command("dev").description("Starts the preview email development app").option("-d, --dir <path>", "Directory with your email templates", "./emails").option("-p --port <port>", "Port to run dev server on", "3000").option("-c, --clients <clients>", "Comma-separated list of email clients to show compatibility warnings for (overrides COMPATIBILITY_EMAIL_CLIENTS)", parseClientsOption).action(dev);
|
|
7743
7778
|
program.command("build").description("Copies the preview app for onto .react-email and builds it").option("-d, --dir <path>", "Directory with your email templates", "./emails").addOption(new Option("-p, --packageManager <name>").hideHelp()).action(build$1);
|
|
7744
7779
|
program.command("start").description("Runs the built preview app that is inside of \".react-email\"").action(start);
|
|
7745
7780
|
program.command("export").description("Build the templates to the `out` directory").option("--outDir <path>", "Output directory", "out").option("-p, --pretty", "Pretty print the output", false).option("-t, --plainText", "Set output format as plain text", false).option("-d, --dir <path>", "Directory with your email templates", "./emails").option("-s, --silent", "To, or not to show a spinner with process information", false).action(({ outDir, pretty, plainText, silent, dir: srcDir }) => exportTemplates(outDir, srcDir, {
|
package/dist/index.cjs
CHANGED
|
@@ -76,6 +76,8 @@ const Body = react.forwardRef(({ children, style, ...props }, ref) => {
|
|
|
76
76
|
if (style) for (const property of [...marginProperties, ...paddingProperties]) bodyStyle[property] = style[property] !== void 0 ? 0 : void 0;
|
|
77
77
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("body", {
|
|
78
78
|
...props,
|
|
79
|
+
dir: props.dir ?? "ltr",
|
|
80
|
+
lang: props.lang ?? "en",
|
|
79
81
|
style: bodyStyle,
|
|
80
82
|
ref,
|
|
81
83
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("table", {
|
|
@@ -86,6 +88,8 @@ const Body = react.forwardRef(({ children, style, ...props }, ref) => {
|
|
|
86
88
|
role: "presentation",
|
|
87
89
|
align: "center",
|
|
88
90
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
91
|
+
dir: props.dir ?? "ltr",
|
|
92
|
+
lang: props.lang ?? "en",
|
|
89
93
|
style,
|
|
90
94
|
children
|
|
91
95
|
}) }) })
|
|
@@ -17601,7 +17605,7 @@ Html.displayName = "Html";
|
|
|
17601
17605
|
//#region src/components/img/img.tsx
|
|
17602
17606
|
const Img = react.forwardRef(({ alt, src, width, height, style, ...props }, ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
17603
17607
|
...props,
|
|
17604
|
-
alt,
|
|
17608
|
+
alt: alt ?? "",
|
|
17605
17609
|
height,
|
|
17606
17610
|
ref,
|
|
17607
17611
|
src,
|
|
@@ -17850,7 +17854,7 @@ const Markdown = react.forwardRef(({ children, markdownContainerStyles, markdown
|
|
|
17850
17854
|
const tbodyRows = rows.map((row) => renderer.tablerow({ text: row.map((cell) => renderer.tablecell(cell)).join("") })).join("");
|
|
17851
17855
|
const thead = `<thead${styleThead ? ` style="${styleThead}"` : ""}>\n${theadRow}</thead>`;
|
|
17852
17856
|
const tbody = `<tbody${styleTbody ? ` style="${styleTbody}"` : ""}>${tbodyRows}</tbody>`;
|
|
17853
|
-
return `<table${styleTable ? ` style="${styleTable}"` : ""}>\n${thead}\n${tbody}</table>\n`;
|
|
17857
|
+
return `<table role="presentation"${styleTable ? ` style="${styleTable}"` : ""}>\n${thead}\n${tbody}</table>\n`;
|
|
17854
17858
|
};
|
|
17855
17859
|
renderer.tablecell = ({ tokens, align, header }) => {
|
|
17856
17860
|
const text = renderer.parser.parseInline(tokens);
|
|
@@ -17877,7 +17881,7 @@ Markdown.displayName = "Markdown";
|
|
|
17877
17881
|
const PREVIEW_MAX_LENGTH = 200;
|
|
17878
17882
|
const Preview = react.forwardRef(({ children = "", ...props }, ref) => {
|
|
17879
17883
|
const text = (Array.isArray(children) ? children.join("") : children).substring(0, PREVIEW_MAX_LENGTH);
|
|
17880
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
17884
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("title", { children: text }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
17881
17885
|
style: {
|
|
17882
17886
|
display: "none",
|
|
17883
17887
|
overflow: "hidden",
|
|
@@ -17890,7 +17894,7 @@ const Preview = react.forwardRef(({ children = "", ...props }, ref) => {
|
|
|
17890
17894
|
...props,
|
|
17891
17895
|
ref,
|
|
17892
17896
|
children: [text, renderWhiteSpace(text)]
|
|
17893
|
-
});
|
|
17897
|
+
})] });
|
|
17894
17898
|
});
|
|
17895
17899
|
Preview.displayName = "Preview";
|
|
17896
17900
|
const whiteSpaceCodes = "\xA0";
|
|
@@ -37771,6 +37775,55 @@ function getReactProperty(prop) {
|
|
|
37771
37775
|
return fromDashCaseToCamelCase(modifiedProp);
|
|
37772
37776
|
}
|
|
37773
37777
|
//#endregion
|
|
37778
|
+
//#region src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts
|
|
37779
|
+
/**
|
|
37780
|
+
* Tailwind v4 emits variant-stacking idioms like
|
|
37781
|
+
* font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
|
|
37782
|
+
* filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
|
|
37783
|
+
* where each var() has an empty fallback so missing variants collapse to nothing.
|
|
37784
|
+
* Tailwind deliberately leaves these variant vars undefined until used, so they
|
|
37785
|
+
* stay in the output here and produce unresolvable custom properties in email HTML
|
|
37786
|
+
* (no email client supports CSS custom properties reliably). Per the CSS spec
|
|
37787
|
+
* (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
|
|
37788
|
+
* "use empty string if the variable is undefined", which is exactly what we want.
|
|
37789
|
+
*
|
|
37790
|
+
* Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
|
|
37791
|
+
* are left untouched.
|
|
37792
|
+
*
|
|
37793
|
+
* Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
|
|
37794
|
+
* correctly after the inner var() has been removed.
|
|
37795
|
+
*/
|
|
37796
|
+
function stripEmptyTailwindVars(node) {
|
|
37797
|
+
walk(node, {
|
|
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
|
+
});
|
|
37825
|
+
}
|
|
37826
|
+
//#endregion
|
|
37774
37827
|
//#region src/components/tailwind/utils/css/unwrap-value.ts
|
|
37775
37828
|
function unwrapValue(value) {
|
|
37776
37829
|
if (value.type === "Value" && value.children.size === 1) return value.children.first ?? value;
|
|
@@ -37815,34 +37868,7 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
|
|
|
37815
37868
|
visit: "Declaration",
|
|
37816
37869
|
enter(declaration) {
|
|
37817
37870
|
if (declaration.property.startsWith("--")) return;
|
|
37818
|
-
|
|
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
|
-
});
|
|
37871
|
+
stripEmptyTailwindVars(declaration.value);
|
|
37846
37872
|
styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
|
|
37847
37873
|
}
|
|
37848
37874
|
});
|
|
@@ -37933,6 +37959,11 @@ function resolveAllCssVariables(node) {
|
|
|
37933
37959
|
hasReplaced = true;
|
|
37934
37960
|
break;
|
|
37935
37961
|
}
|
|
37962
|
+
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)) {
|
|
37963
|
+
use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, definition.definition), { context: "value" });
|
|
37964
|
+
hasReplaced = true;
|
|
37965
|
+
break;
|
|
37966
|
+
}
|
|
37936
37967
|
}
|
|
37937
37968
|
if (!hasReplaced && use.fallback) use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: "value" });
|
|
37938
37969
|
}
|
|
@@ -38498,6 +38529,12 @@ function sanitizeClassName(className) {
|
|
|
38498
38529
|
* What it does:
|
|
38499
38530
|
* 1. Converts all declarations in all rules into important ones
|
|
38500
38531
|
* 2. Sanitizes class selectors of all non-inlinable rules
|
|
38532
|
+
* 3. Removes --tw-* custom property declarations — by this point all CSS
|
|
38533
|
+
* variables have been resolved, so these are dead weight in email HTML.
|
|
38534
|
+
* 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
|
|
38535
|
+
* variant-stacking idioms (filter, font-variant-numeric, etc.) — email
|
|
38536
|
+
* clients can't resolve CSS custom properties reliably, so any --tw-*
|
|
38537
|
+
* left as a bare empty-fallback ref would reach the client broken.
|
|
38501
38538
|
*/
|
|
38502
38539
|
function sanitizeNonInlinableRules(node) {
|
|
38503
38540
|
walk(node, {
|
|
@@ -38509,8 +38546,13 @@ function sanitizeNonInlinableRules(node) {
|
|
|
38509
38546
|
});
|
|
38510
38547
|
walk(rule, {
|
|
38511
38548
|
visit: "Declaration",
|
|
38512
|
-
enter(declaration) {
|
|
38549
|
+
enter(declaration, item, list) {
|
|
38550
|
+
if (declaration.property.startsWith("--tw-")) {
|
|
38551
|
+
list.remove(item);
|
|
38552
|
+
return;
|
|
38553
|
+
}
|
|
38513
38554
|
declaration.important = true;
|
|
38555
|
+
stripEmptyTailwindVars(declaration.value);
|
|
38514
38556
|
}
|
|
38515
38557
|
});
|
|
38516
38558
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -55,6 +55,8 @@ const Body = React$1.forwardRef(({ children, style, ...props }, ref) => {
|
|
|
55
55
|
if (style) for (const property of [...marginProperties, ...paddingProperties]) bodyStyle[property] = style[property] !== void 0 ? 0 : void 0;
|
|
56
56
|
return /* @__PURE__ */ jsx("body", {
|
|
57
57
|
...props,
|
|
58
|
+
dir: props.dir ?? "ltr",
|
|
59
|
+
lang: props.lang ?? "en",
|
|
58
60
|
style: bodyStyle,
|
|
59
61
|
ref,
|
|
60
62
|
children: /* @__PURE__ */ jsx("table", {
|
|
@@ -65,6 +67,8 @@ const Body = React$1.forwardRef(({ children, style, ...props }, ref) => {
|
|
|
65
67
|
role: "presentation",
|
|
66
68
|
align: "center",
|
|
67
69
|
children: /* @__PURE__ */ jsx("tbody", { children: /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", {
|
|
70
|
+
dir: props.dir ?? "ltr",
|
|
71
|
+
lang: props.lang ?? "en",
|
|
68
72
|
style,
|
|
69
73
|
children
|
|
70
74
|
}) }) })
|
|
@@ -17580,7 +17584,7 @@ Html.displayName = "Html";
|
|
|
17580
17584
|
//#region src/components/img/img.tsx
|
|
17581
17585
|
const Img = React$1.forwardRef(({ alt, src, width, height, style, ...props }, ref) => /* @__PURE__ */ jsx("img", {
|
|
17582
17586
|
...props,
|
|
17583
|
-
alt,
|
|
17587
|
+
alt: alt ?? "",
|
|
17584
17588
|
height,
|
|
17585
17589
|
ref,
|
|
17586
17590
|
src,
|
|
@@ -17829,7 +17833,7 @@ const Markdown = React$1.forwardRef(({ children, markdownContainerStyles, markdo
|
|
|
17829
17833
|
const tbodyRows = rows.map((row) => renderer.tablerow({ text: row.map((cell) => renderer.tablecell(cell)).join("") })).join("");
|
|
17830
17834
|
const thead = `<thead${styleThead ? ` style="${styleThead}"` : ""}>\n${theadRow}</thead>`;
|
|
17831
17835
|
const tbody = `<tbody${styleTbody ? ` style="${styleTbody}"` : ""}>${tbodyRows}</tbody>`;
|
|
17832
|
-
return `<table${styleTable ? ` style="${styleTable}"` : ""}>\n${thead}\n${tbody}</table>\n`;
|
|
17836
|
+
return `<table role="presentation"${styleTable ? ` style="${styleTable}"` : ""}>\n${thead}\n${tbody}</table>\n`;
|
|
17833
17837
|
};
|
|
17834
17838
|
renderer.tablecell = ({ tokens, align, header }) => {
|
|
17835
17839
|
const text = renderer.parser.parseInline(tokens);
|
|
@@ -17856,7 +17860,7 @@ Markdown.displayName = "Markdown";
|
|
|
17856
17860
|
const PREVIEW_MAX_LENGTH = 200;
|
|
17857
17861
|
const Preview = React$1.forwardRef(({ children = "", ...props }, ref) => {
|
|
17858
17862
|
const text = (Array.isArray(children) ? children.join("") : children).substring(0, PREVIEW_MAX_LENGTH);
|
|
17859
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
17863
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("title", { children: text }), /* @__PURE__ */ jsxs("div", {
|
|
17860
17864
|
style: {
|
|
17861
17865
|
display: "none",
|
|
17862
17866
|
overflow: "hidden",
|
|
@@ -17869,7 +17873,7 @@ const Preview = React$1.forwardRef(({ children = "", ...props }, ref) => {
|
|
|
17869
17873
|
...props,
|
|
17870
17874
|
ref,
|
|
17871
17875
|
children: [text, renderWhiteSpace(text)]
|
|
17872
|
-
});
|
|
17876
|
+
})] });
|
|
17873
17877
|
});
|
|
17874
17878
|
Preview.displayName = "Preview";
|
|
17875
17879
|
const whiteSpaceCodes = "\xA0";
|
|
@@ -37750,6 +37754,55 @@ function getReactProperty(prop) {
|
|
|
37750
37754
|
return fromDashCaseToCamelCase(modifiedProp);
|
|
37751
37755
|
}
|
|
37752
37756
|
//#endregion
|
|
37757
|
+
//#region src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts
|
|
37758
|
+
/**
|
|
37759
|
+
* Tailwind v4 emits variant-stacking idioms like
|
|
37760
|
+
* font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
|
|
37761
|
+
* filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
|
|
37762
|
+
* where each var() has an empty fallback so missing variants collapse to nothing.
|
|
37763
|
+
* Tailwind deliberately leaves these variant vars undefined until used, so they
|
|
37764
|
+
* stay in the output here and produce unresolvable custom properties in email HTML
|
|
37765
|
+
* (no email client supports CSS custom properties reliably). Per the CSS spec
|
|
37766
|
+
* (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
|
|
37767
|
+
* "use empty string if the variable is undefined", which is exactly what we want.
|
|
37768
|
+
*
|
|
37769
|
+
* Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
|
|
37770
|
+
* are left untouched.
|
|
37771
|
+
*
|
|
37772
|
+
* Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
|
|
37773
|
+
* correctly after the inner var() has been removed.
|
|
37774
|
+
*/
|
|
37775
|
+
function stripEmptyTailwindVars(node) {
|
|
37776
|
+
walk(node, {
|
|
37777
|
+
visit: "Function",
|
|
37778
|
+
leave(func, funcItem, funcList) {
|
|
37779
|
+
if (func.name !== "var") return;
|
|
37780
|
+
let variableName;
|
|
37781
|
+
walk(func, {
|
|
37782
|
+
visit: "Identifier",
|
|
37783
|
+
enter(identifier) {
|
|
37784
|
+
variableName = identifier.name;
|
|
37785
|
+
return this.break;
|
|
37786
|
+
}
|
|
37787
|
+
});
|
|
37788
|
+
if (!variableName?.startsWith("--tw-")) return;
|
|
37789
|
+
let sawComma = false;
|
|
37790
|
+
let hasFallbackContent = false;
|
|
37791
|
+
func.children.forEach((child) => {
|
|
37792
|
+
if (!sawComma) {
|
|
37793
|
+
if (child.type === "Operator" && child.value === ",") sawComma = true;
|
|
37794
|
+
return;
|
|
37795
|
+
}
|
|
37796
|
+
let childValue = generate(child).trim();
|
|
37797
|
+
if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
|
|
37798
|
+
if (childValue.length > 0) hasFallbackContent = true;
|
|
37799
|
+
});
|
|
37800
|
+
if (!sawComma || hasFallbackContent) return;
|
|
37801
|
+
funcList.remove(funcItem);
|
|
37802
|
+
}
|
|
37803
|
+
});
|
|
37804
|
+
}
|
|
37805
|
+
//#endregion
|
|
37753
37806
|
//#region src/components/tailwind/utils/css/unwrap-value.ts
|
|
37754
37807
|
function unwrapValue(value) {
|
|
37755
37808
|
if (value.type === "Value" && value.children.size === 1) return value.children.first ?? value;
|
|
@@ -37794,34 +37847,7 @@ function makeInlineStylesFor(inlinableRules, customProperties) {
|
|
|
37794
37847
|
visit: "Declaration",
|
|
37795
37848
|
enter(declaration) {
|
|
37796
37849
|
if (declaration.property.startsWith("--")) return;
|
|
37797
|
-
|
|
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
|
-
});
|
|
37850
|
+
stripEmptyTailwindVars(declaration.value);
|
|
37825
37851
|
styles[getReactProperty(declaration.property)] = generate(declaration.value).trim() + (declaration.important ? "!important" : "");
|
|
37826
37852
|
}
|
|
37827
37853
|
});
|
|
@@ -37912,6 +37938,11 @@ function resolveAllCssVariables(node) {
|
|
|
37912
37938
|
hasReplaced = true;
|
|
37913
37939
|
break;
|
|
37914
37940
|
}
|
|
37941
|
+
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)) {
|
|
37942
|
+
use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, definition.definition), { context: "value" });
|
|
37943
|
+
hasReplaced = true;
|
|
37944
|
+
break;
|
|
37945
|
+
}
|
|
37915
37946
|
}
|
|
37916
37947
|
if (!hasReplaced && use.fallback) use.declaration.value = parse(generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: "value" });
|
|
37917
37948
|
}
|
|
@@ -38477,6 +38508,12 @@ function sanitizeClassName(className) {
|
|
|
38477
38508
|
* What it does:
|
|
38478
38509
|
* 1. Converts all declarations in all rules into important ones
|
|
38479
38510
|
* 2. Sanitizes class selectors of all non-inlinable rules
|
|
38511
|
+
* 3. Removes --tw-* custom property declarations — by this point all CSS
|
|
38512
|
+
* variables have been resolved, so these are dead weight in email HTML.
|
|
38513
|
+
* 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
|
|
38514
|
+
* variant-stacking idioms (filter, font-variant-numeric, etc.) — email
|
|
38515
|
+
* clients can't resolve CSS custom properties reliably, so any --tw-*
|
|
38516
|
+
* left as a bare empty-fallback ref would reach the client broken.
|
|
38480
38517
|
*/
|
|
38481
38518
|
function sanitizeNonInlinableRules(node) {
|
|
38482
38519
|
walk(node, {
|
|
@@ -38488,8 +38525,13 @@ function sanitizeNonInlinableRules(node) {
|
|
|
38488
38525
|
});
|
|
38489
38526
|
walk(rule, {
|
|
38490
38527
|
visit: "Declaration",
|
|
38491
|
-
enter(declaration) {
|
|
38528
|
+
enter(declaration, item, list) {
|
|
38529
|
+
if (declaration.property.startsWith("--tw-")) {
|
|
38530
|
+
list.remove(item);
|
|
38531
|
+
return;
|
|
38532
|
+
}
|
|
38492
38533
|
declaration.important = true;
|
|
38534
|
+
stripEmptyTailwindVars(declaration.value);
|
|
38493
38535
|
}
|
|
38494
38536
|
});
|
|
38495
38537
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`<Body> component > margin resetting behavior > should reset the margin property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
3
|
+
exports[`<Body> component > margin resetting behavior > should reset the margin property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
4
4
|
|
|
5
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginBlock property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-block:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-block:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
5
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginBlock property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-block:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-block:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
6
6
|
|
|
7
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginBlockEnd property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-block-end:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-block-end:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
7
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginBlockEnd property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-block-end:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-block-end:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
8
8
|
|
|
9
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginBlockStart property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-block-start:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-block-start:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
9
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginBlockStart property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-block-start:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-block-start:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
10
10
|
|
|
11
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginBottom property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-bottom:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-bottom:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
11
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginBottom property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-bottom:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-bottom:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
12
12
|
|
|
13
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginInline property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-inline:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-inline:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
13
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginInline property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-inline:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-inline:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
14
14
|
|
|
15
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginInlineEnd property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-inline-end:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-inline-end:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
15
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginInlineEnd property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-inline-end:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-inline-end:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
16
16
|
|
|
17
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginInlineStart property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-inline-start:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-inline-start:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
17
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginInlineStart property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-inline-start:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-inline-start:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
18
18
|
|
|
19
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginLeft property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-left:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-left:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
19
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginLeft property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-left:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-left:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
20
20
|
|
|
21
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginRight property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-right:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-right:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
21
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginRight property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-right:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-right:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
22
22
|
|
|
23
|
-
exports[`<Body> component > margin resetting behavior > should reset the marginTop property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body style="margin-top:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="margin-top:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
23
|
+
exports[`<Body> component > margin resetting behavior > should reset the marginTop property when it comes from props 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en" style="margin-top:0"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="margin-top:10px">Random text</td></tr></tbody></table><!--/$--></body>"`;
|
|
@@ -23,7 +23,7 @@ describe('<Body> component', () => {
|
|
|
23
23
|
it('renders correctly', async () => {
|
|
24
24
|
const actualOutput = await render(<Body>Lorem ipsum</Body>);
|
|
25
25
|
expect(actualOutput).toMatchInlineSnapshot(
|
|
26
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td>Lorem ipsum</td></tr></tbody></table><!--/$--></body>"`,
|
|
26
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><body dir="ltr" lang="en"><!--$--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en">Lorem ipsum</td></tr></tbody></table><!--/$--></body>"`,
|
|
27
27
|
);
|
|
28
28
|
});
|
|
29
29
|
|
|
@@ -19,7 +19,13 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
return (
|
|
22
|
-
<body
|
|
22
|
+
<body
|
|
23
|
+
{...props}
|
|
24
|
+
dir={props.dir ?? 'ltr'}
|
|
25
|
+
lang={props.lang ?? 'en'}
|
|
26
|
+
style={bodyStyle}
|
|
27
|
+
ref={ref}
|
|
28
|
+
>
|
|
23
29
|
<table
|
|
24
30
|
border={0}
|
|
25
31
|
width="100%"
|
|
@@ -36,7 +42,13 @@ export const Body = React.forwardRef<HTMLBodyElement, BodyProps>(
|
|
|
36
42
|
|
|
37
43
|
See https://github.com/resend/react-email/issues/662.
|
|
38
44
|
*/}
|
|
39
|
-
<td
|
|
45
|
+
<td
|
|
46
|
+
dir={props.dir ?? 'ltr'}
|
|
47
|
+
lang={props.lang ?? 'en'}
|
|
48
|
+
style={style}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</td>
|
|
40
52
|
</tr>
|
|
41
53
|
</tbody>
|
|
42
54
|
</table>
|
|
@@ -191,7 +191,7 @@ export const Markdown = React.forwardRef<HTMLDivElement, MarkdownProps>(
|
|
|
191
191
|
const thead = `<thead${styleThead ? ` style="${styleThead}"` : ''}>\n${theadRow}</thead>`;
|
|
192
192
|
const tbody = `<tbody${styleTbody ? ` style="${styleTbody}"` : ''}>${tbodyRows}</tbody>`;
|
|
193
193
|
|
|
194
|
-
return `<table${styleTable ? ` style="${styleTable}"` : ''}>\n${thead}\n${tbody}</table>\n`;
|
|
194
|
+
return `<table role="presentation"${styleTable ? ` style="${styleTable}"` : ''}>\n${thead}\n${tbody}</table>\n`;
|
|
195
195
|
};
|
|
196
196
|
|
|
197
197
|
renderer.tablecell = ({ tokens, align, header }) => {
|
|
@@ -5,7 +5,7 @@ describe('<Preview> component', () => {
|
|
|
5
5
|
it('renders correctly', async () => {
|
|
6
6
|
const actualOutput = await render(<Preview>Email preview text</Preview>);
|
|
7
7
|
expect(actualOutput).toMatchInlineSnapshot(
|
|
8
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Email preview text<div> </div></div><!--/$-->"`,
|
|
8
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><title>Email preview text</title><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Email preview text<div> </div></div><!--/$-->"`,
|
|
9
9
|
);
|
|
10
10
|
});
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@ describe('<Preview> component', () => {
|
|
|
14
14
|
<Preview>Email preview text</Preview>,
|
|
15
15
|
);
|
|
16
16
|
expect(actualOutputArray).toMatchInlineSnapshot(
|
|
17
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Email preview text<div> </div></div><!--/$-->"`,
|
|
17
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><title>Email preview text</title><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">Email preview text<div> </div></div><!--/$-->"`,
|
|
18
18
|
);
|
|
19
19
|
});
|
|
20
20
|
|
|
@@ -22,7 +22,7 @@ describe('<Preview> component', () => {
|
|
|
22
22
|
const longText = 'really long'.repeat(100);
|
|
23
23
|
const actualOutputLong = await render(<Preview>{longText}</Preview>);
|
|
24
24
|
expect(actualOutputLong).toMatchInlineSnapshot(
|
|
25
|
-
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">really longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longre</div><!--/$-->"`,
|
|
25
|
+
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><title>really longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longre</title><!--$--><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">really longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longre</div><!--/$-->"`,
|
|
26
26
|
);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
@@ -15,22 +15,25 @@ export const Preview = React.forwardRef<HTMLDivElement, PreviewProps>(
|
|
|
15
15
|
).substring(0, PREVIEW_MAX_LENGTH);
|
|
16
16
|
|
|
17
17
|
return (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
<>
|
|
19
|
+
<title>{text}</title>
|
|
20
|
+
<div
|
|
21
|
+
style={{
|
|
22
|
+
display: 'none',
|
|
23
|
+
overflow: 'hidden',
|
|
24
|
+
lineHeight: '1px',
|
|
25
|
+
opacity: 0,
|
|
26
|
+
maxHeight: 0,
|
|
27
|
+
maxWidth: 0,
|
|
28
|
+
}}
|
|
29
|
+
data-skip-in-text={true}
|
|
30
|
+
{...props}
|
|
31
|
+
ref={ref}
|
|
32
|
+
>
|
|
33
|
+
{text}
|
|
34
|
+
{renderWhiteSpace(text)}
|
|
35
|
+
</div>
|
|
36
|
+
</>
|
|
34
37
|
);
|
|
35
38
|
},
|
|
36
39
|
);
|
|
@@ -394,7 +394,7 @@ describe('Tailwind component', () => {
|
|
|
394
394
|
);
|
|
395
395
|
|
|
396
396
|
expect(actualOutput).toMatchInlineSnapshot(
|
|
397
|
-
`"<!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>"`,
|
|
397
|
+
`"<!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" dir="ltr" lang="en"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td dir="ltr" lang="en" style="color:green">this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
|
|
398
398
|
);
|
|
399
399
|
});
|
|
400
400
|
|
|
@@ -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
|
-
|
|
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{
|
|
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