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 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
@@ -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.3.3",
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
- walk(declaration.value, {
37819
- visit: "Function",
37820
- leave(func, funcItem, funcList) {
37821
- if (func.name !== "var") return;
37822
- let variableName;
37823
- walk(func, {
37824
- visit: "Identifier",
37825
- enter(identifier) {
37826
- variableName = identifier.name;
37827
- return this.break;
37828
- }
37829
- });
37830
- if (!variableName?.startsWith("--tw-")) return;
37831
- let sawComma = false;
37832
- let hasFallbackContent = false;
37833
- func.children.forEach((child) => {
37834
- if (!sawComma) {
37835
- if (child.type === "Operator" && child.value === ",") sawComma = true;
37836
- return;
37837
- }
37838
- let childValue = generate(child).trim();
37839
- if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37840
- if (childValue.length > 0) hasFallbackContent = true;
37841
- });
37842
- if (!sawComma || hasFallbackContent) return;
37843
- funcList.remove(funcItem);
37844
- }
37845
- });
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
- walk(declaration.value, {
37798
- visit: "Function",
37799
- leave(func, funcItem, funcList) {
37800
- if (func.name !== "var") return;
37801
- let variableName;
37802
- walk(func, {
37803
- visit: "Identifier",
37804
- enter(identifier) {
37805
- variableName = identifier.name;
37806
- return this.break;
37807
- }
37808
- });
37809
- if (!variableName?.startsWith("--tw-")) return;
37810
- let sawComma = false;
37811
- let hasFallbackContent = false;
37812
- func.children.forEach((child) => {
37813
- if (!sawComma) {
37814
- if (child.type === "Operator" && child.value === ",") sawComma = true;
37815
- return;
37816
- }
37817
- let childValue = generate(child).trim();
37818
- if (child.type === "Raw") childValue = childValue.replaceAll(/var\(--tw-[^,()]+,\s*\)/g, "").trim();
37819
- if (childValue.length > 0) hasFallbackContent = true;
37820
- });
37821
- if (!sawComma || hasFallbackContent) return;
37822
- funcList.remove(funcItem);
37823
- }
37824
- });
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-email",
3
- "version": "6.3.3",
3
+ "version": "6.5.0",
4
4
  "description": "A live preview of your emails right in your browser.",
5
5
  "bin": {
6
6
  "email": "./dist/cli/index.mjs"
@@ -1,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 {...props} style={bodyStyle} ref={ref}>
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 style={style}>{children}</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>
@@ -6,7 +6,7 @@ export const Img = React.forwardRef<HTMLImageElement, ImgProps>(
6
6
  ({ alt, src, width, height, style, ...props }, ref) => (
7
7
  <img
8
8
  {...props}
9
- alt={alt}
9
+ alt={alt ?? ''}
10
10
  height={height}
11
11
  ref={ref}
12
12
  src={src}
@@ -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
- <div
19
- style={{
20
- display: 'none',
21
- overflow: 'hidden',
22
- lineHeight: '1px',
23
- opacity: 0,
24
- maxHeight: 0,
25
- maxWidth: 0,
26
- }}
27
- data-skip-in-text={true}
28
- {...props}
29
- ref={ref}
30
- >
31
- {text}
32
- {renderWhiteSpace(text)}
33
- </div>
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
- // Tailwind v4 emits variant-stacking idioms like
63
- // font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
64
- // where each var() has an empty fallback so missing variants collapse to nothing.
65
- // The walker above replaces var() calls with an initialValue when one is defined,
66
- // but Tailwind deliberately leaves these variant vars undefined until used, so they
67
- // stay in the output here and produce unresolvable custom properties in email HTML
68
- // (no email client supports CSS custom properties reliably). Per the CSS spec
69
- // (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
70
- // "use empty string if the variable is undefined", which is exactly what we want at
71
- // inline-style time.
72
- //
73
- // Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
74
- // (even ones used inside tailwind utilities) are left untouched.
75
- walk(declaration.value, {
76
- visit: 'Function',
77
- leave(func, funcItem, funcList) {
78
- if (func.name !== 'var') {
79
- return;
80
- }
81
-
82
- let variableName: string | undefined;
83
- walk(func, {
84
- visit: 'Identifier',
85
- enter(identifier) {
86
- variableName = identifier.name;
87
- return this.break;
88
- },
89
- });
90
- if (!variableName?.startsWith('--tw-')) {
91
- return;
92
- }
93
-
94
- let sawComma = false;
95
- let hasFallbackContent = false;
96
- func.children.forEach((child) => {
97
- if (!sawComma) {
98
- if (child.type === 'Operator' && child.value === ',') {
99
- sawComma = true;
100
- }
101
- return;
102
- }
103
-
104
- let childValue = generate(child).trim();
105
- if (child.type === 'Raw') {
106
- const emptyTailwindVarPattern = /var\(--tw-[^,()]+,\s*\)/g;
107
- childValue = childValue
108
- .replaceAll(emptyTailwindVarPattern, '')
109
- .trim();
110
- }
111
-
112
- if (childValue.length > 0) {
113
- hasFallbackContent = true;
114
- }
115
- });
116
-
117
- if (!sawComma || hasFallbackContent) {
118
- return;
119
- }
120
-
121
- funcList.remove(funcItem);
122
- },
123
- });
63
+ stripEmptyTailwindVars(declaration.value);
124
64
 
125
65
  styles[getReactProperty(declaration.property)] =
126
66
  generate(declaration.value).trim() +
@@ -237,6 +237,22 @@ describe('resolveAllCSSVariables', () => {
237
237
  );
238
238
  });
239
239
 
240
+ it('does not leak variables across sibling nested at-rules within the same rule', () => {
241
+ const root = parse(`.print_invert {
242
+ @media print {
243
+ --tw-invert: invert(100%);
244
+ filter: var(--tw-invert, none);
245
+ }
246
+ @media screen {
247
+ filter: var(--tw-invert, none);
248
+ }
249
+ }`);
250
+ resolveAllCssVariables(root);
251
+ expect(generate(root)).toMatchInlineSnapshot(
252
+ `".print_invert{@media print{--tw-invert: invert(100%);filter:invert(100%)}@media screen{filter:none}}"`,
253
+ );
254
+ });
255
+
240
256
  it('handles selectors with asterisks in attribute selectors and pseudo-functions', () => {
241
257
  const root = parse(`* {
242
258
  --global-color: red;
@@ -187,6 +187,43 @@ export function resolveAllCssVariables(node: CssNode) {
187
187
  hasReplaced = true;
188
188
  break;
189
189
  }
190
+
191
+ // Both use and definition live inside the same nested @media (or other
192
+ // at-rule) block of the same rule — e.g. Tailwind v4's
193
+ // .print_invert { @media print { --tw-invert: invert(100%); filter: var(--tw-invert,) ... } }
194
+ // The previous two checks only cover a Rule directly containing the
195
+ // declaration; this one covers Rule → Block → Atrule → Block → Declaration
196
+ // on both sides.
197
+ if (
198
+ use.path[0]?.type === 'Block' &&
199
+ use.path[1]?.type === 'Atrule' &&
200
+ use.path[2]?.type === 'Block' &&
201
+ use.path[3]?.type === 'Rule' &&
202
+ definition.path[0]?.type === 'Block' &&
203
+ definition.path[1]?.type === 'Atrule' &&
204
+ definition.path[2]?.type === 'Block' &&
205
+ definition.path[3]?.type === 'Rule' &&
206
+ use.path[1].name === definition.path[1].name &&
207
+ (use.path[1].prelude
208
+ ? definition.path[1].prelude
209
+ ? generate(use.path[1].prelude) ===
210
+ generate(definition.path[1].prelude)
211
+ : false
212
+ : definition.path[1].prelude === null) &&
213
+ doSelectorsIntersect(use.path[3].prelude, definition.path[3].prelude)
214
+ ) {
215
+ use.declaration.value = parse(
216
+ generate(use.declaration.value).replaceAll(
217
+ use.raw,
218
+ definition.definition,
219
+ ),
220
+ {
221
+ context: 'value',
222
+ },
223
+ ) as Raw | Value;
224
+ hasReplaced = true;
225
+ break;
226
+ }
190
227
  }
191
228
 
192
229
  if (!hasReplaced && use.fallback) {
@@ -1,4 +1,5 @@
1
1
  import { generate } from 'css-tree';
2
+ import { sanitizeStyleSheet } from '../../sanitize-stylesheet.js';
2
3
  import { setupTailwind } from '../tailwindcss/setup-tailwind.js';
3
4
  import { sanitizeNonInlinableRules } from './sanitize-non-inlinable-rules.js';
4
5
 
@@ -27,7 +28,27 @@ describe('sanitizeNonInlinableRules()', () => {
27
28
 
28
29
  sanitizeNonInlinableRules(stylesheet);
29
30
  expect(generate(stylesheet)).toMatchInlineSnapshot(
30
- `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer theme{:root,:host{--color-sky-600: oklch(58.8% 0.158 241.966)!important;--color-gray-100: oklch(96.7% 0.003 264.542)!important}}@layer utilities{.hover_text-sky-600{&:hover{@media (hover:hover){color:var(--color-sky-600)!important}}}.sm_focus_outline-none{@media (width>=40rem){&:focus{--tw-outline-style: none!important;outline-style:none!important}}}.md_hover_bg-gray-100{@media (width>=48rem){&:hover{@media (hover:hover){background-color:var(--color-gray-100)!important}}}}.lg_focus_underline{@media (width>=64rem){&:focus{text-decoration-line:underline!important}}}}"`,
31
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer theme{:root,:host{--color-sky-600: oklch(58.8% 0.158 241.966)!important;--color-gray-100: oklch(96.7% 0.003 264.542)!important}}@layer utilities{.hover_text-sky-600{&:hover{@media (hover:hover){color:var(--color-sky-600)!important}}}.sm_focus_outline-none{@media (width>=40rem){&:focus{outline-style:none!important}}}.md_hover_bg-gray-100{@media (width>=48rem){&:hover{@media (hover:hover){background-color:var(--color-gray-100)!important}}}}.lg_focus_underline{@media (width>=64rem){&:focus{text-decoration-line:underline!important}}}}"`,
32
+ );
33
+ });
34
+
35
+ it('strips Tailwind v4 variant-stacking var() refs with empty fallbacks inside print: media queries', async () => {
36
+ // Mirrors the inline-style behavior asserted in make-inline-styles-for.spec.ts.
37
+ // `print:invert` compiles to a filter value that is a chain of var(--tw-...,)
38
+ // with empty fallbacks. After resolveAllCssVariables the filter is concrete;
39
+ // sanitizeNonInlinableRules then drops the leftover --tw-* declarations and
40
+ // any remaining empty-fallback var() refs.
41
+ const tailwind = await setupTailwind({});
42
+ tailwind.addUtilities(['md:block', 'print:invert']);
43
+ const stylesheet = tailwind.getStyleSheet();
44
+
45
+ sanitizeStyleSheet(stylesheet);
46
+ sanitizeNonInlinableRules(stylesheet);
47
+ const result = generate(stylesheet);
48
+
49
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
50
+ expect(result).toMatchInlineSnapshot(
51
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer utilities{.md_block{@media (width>=48rem){display:block!important}}.print_invert{@media print{filter:invert(100%)!important}}}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}"`,
31
52
  );
32
53
  });
33
54
 
@@ -1,6 +1,7 @@
1
1
  import { type CssNode, string, walk } from 'css-tree';
2
2
  import { sanitizeClassName } from '../compatibility/sanitize-class-name.js';
3
3
  import { isRuleInlinable } from './is-rule-inlinable.js';
4
+ import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
4
5
 
5
6
  /**
6
7
  * This function goes through a few steps to ensure the best email client support and
@@ -10,6 +11,12 @@ import { isRuleInlinable } from './is-rule-inlinable.js';
10
11
  * What it does:
11
12
  * 1. Converts all declarations in all rules into important ones
12
13
  * 2. Sanitizes class selectors of all non-inlinable rules
14
+ * 3. Removes --tw-* custom property declarations — by this point all CSS
15
+ * variables have been resolved, so these are dead weight in email HTML.
16
+ * 4. Strips empty-fallback var(--tw-*,) refs that Tailwind v4 emits for
17
+ * variant-stacking idioms (filter, font-variant-numeric, etc.) — email
18
+ * clients can't resolve CSS custom properties reliably, so any --tw-*
19
+ * left as a bare empty-fallback ref would reach the client broken.
13
20
  */
14
21
  export function sanitizeNonInlinableRules(node: CssNode) {
15
22
  walk(node, {
@@ -25,8 +32,14 @@ export function sanitizeNonInlinableRules(node: CssNode) {
25
32
 
26
33
  walk(rule, {
27
34
  visit: 'Declaration',
28
- enter(declaration) {
35
+ enter(declaration, item, list) {
36
+ if (declaration.property.startsWith('--tw-')) {
37
+ list.remove(item);
38
+ return;
39
+ }
40
+
29
41
  declaration.important = true;
42
+ stripEmptyTailwindVars(declaration.value);
30
43
  },
31
44
  });
32
45
  }
@@ -0,0 +1,128 @@
1
+ import {
2
+ type Atrule,
3
+ type Declaration,
4
+ generate,
5
+ parse,
6
+ type Rule,
7
+ type StyleSheet,
8
+ walk,
9
+ } from 'css-tree';
10
+ import { sanitizeStyleSheet } from '../../sanitize-stylesheet.js';
11
+ import { setupTailwind } from '../tailwindcss/setup-tailwind.js';
12
+ import { isRuleInlinable } from './is-rule-inlinable.js';
13
+ import { sanitizeNonInlinableRules } from './sanitize-non-inlinable-rules.js';
14
+ import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
15
+
16
+ describe('stripEmptyTailwindVars()', () => {
17
+ it('removes empty-fallback var(--tw-*,) refs from declaration values', () => {
18
+ const stylesheet = parse(`
19
+ .tabular-nums {
20
+ font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,);
21
+ }
22
+ `) as StyleSheet;
23
+
24
+ const rule = stylesheet.children.first as Rule;
25
+ const declaration = rule.block.children.first as Declaration;
26
+ stripEmptyTailwindVars(declaration.value);
27
+
28
+ expect(generate(declaration.value)).toBe('tabular-nums');
29
+ });
30
+
31
+ it('does not remove var() refs with non-empty fallbacks or non --tw- names', () => {
32
+ const stylesheet = parse(`
33
+ .thing {
34
+ line-height: var(--tw-leading, var(--text-lg--line-height));
35
+ color: var(--my-color,);
36
+ }
37
+ `) as StyleSheet;
38
+
39
+ const rule = stylesheet.children.first as Rule;
40
+ const leading = rule.block.children.first as Declaration;
41
+ const color = rule.block.children.last as Declaration;
42
+
43
+ stripEmptyTailwindVars(leading.value);
44
+ stripEmptyTailwindVars(color.value);
45
+
46
+ expect(generate(leading.value)).toBe(
47
+ 'var(--tw-leading, var(--text-lg--line-height))',
48
+ );
49
+ expect(generate(color.value)).toBe('var(--my-color,)');
50
+ });
51
+
52
+ it('does not remove --tw-* custom property declarations (only var() usages in values)', () => {
53
+ const stylesheet = parse(`
54
+ .print_border-solid {
55
+ @media print {
56
+ --tw-border-style: solid;
57
+ border-style: var(--tw-border-style,);
58
+ }
59
+ }
60
+ `) as StyleSheet;
61
+
62
+ const rule = stylesheet.children.first as Rule;
63
+ const atrule = rule.block.children.first as Atrule;
64
+ const twDeclaration = atrule.block!.children.first as Declaration;
65
+ const borderDeclaration = atrule.block!.children.last as Declaration;
66
+
67
+ stripEmptyTailwindVars(borderDeclaration.value);
68
+
69
+ expect(twDeclaration.property).toBe('--tw-border-style');
70
+ expect(generate(twDeclaration.value).trim()).toBe('solid');
71
+ expect(generate(borderDeclaration.value)).toBe('');
72
+ expect(generate(stylesheet)).toContain('--tw-border-style: solid');
73
+ });
74
+ });
75
+
76
+ describe('stripEmptyTailwindVars() with non-inlinable print: rules', () => {
77
+ it('print:border-solid still leaves --tw-* declarations if only stripEmptyTailwindVars runs', async () => {
78
+ const tailwind = await setupTailwind({});
79
+ tailwind.addUtilities(['print:border-solid']);
80
+ const stylesheet = tailwind.getStyleSheet();
81
+
82
+ sanitizeStyleSheet(stylesheet);
83
+
84
+ walkDeclarationsInNonInlinableRules(stylesheet, (declaration) => {
85
+ stripEmptyTailwindVars(declaration.value);
86
+ });
87
+
88
+ const result = generate(stylesheet);
89
+
90
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
91
+ expect(result).toMatch(/--tw-[^:]+:/);
92
+ });
93
+
94
+ it('print:border-solid is clean after sanitizeNonInlinableRules removes resolved --tw-* declarations', async () => {
95
+ const tailwind = await setupTailwind({});
96
+ tailwind.addUtilities(['print:border-solid']);
97
+ const stylesheet = tailwind.getStyleSheet();
98
+
99
+ sanitizeStyleSheet(stylesheet);
100
+ sanitizeNonInlinableRules(stylesheet);
101
+ const result = generate(stylesheet);
102
+
103
+ expect(result).not.toMatch(/var\(--tw-[^,()]+,\s*\)/);
104
+ expect(result).not.toMatch(/--tw-[^:]+:/);
105
+ expect(result).toMatchInlineSnapshot(
106
+ `"/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */@layer theme,base,components,utilities;@layer utilities{.print_border-solid{@media print{border-style:solid!important}}}"`,
107
+ );
108
+ });
109
+ });
110
+
111
+ function walkDeclarationsInNonInlinableRules(
112
+ node: StyleSheet,
113
+ onDeclaration: (declaration: Declaration) => void,
114
+ ) {
115
+ walk(node, {
116
+ visit: 'Rule',
117
+ enter(rule) {
118
+ if (!isRuleInlinable(rule)) {
119
+ walk(rule, {
120
+ visit: 'Declaration',
121
+ enter(declaration) {
122
+ onDeclaration(declaration);
123
+ },
124
+ });
125
+ }
126
+ },
127
+ });
128
+ }
@@ -0,0 +1,70 @@
1
+ import { type CssNode, generate, walk } from 'css-tree';
2
+
3
+ /**
4
+ * Tailwind v4 emits variant-stacking idioms like
5
+ * font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,)
6
+ * filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-invert,) ...
7
+ * where each var() has an empty fallback so missing variants collapse to nothing.
8
+ * Tailwind deliberately leaves these variant vars undefined until used, so they
9
+ * stay in the output here and produce unresolvable custom properties in email HTML
10
+ * (no email client supports CSS custom properties reliably). Per the CSS spec
11
+ * (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means
12
+ * "use empty string if the variable is undefined", which is exactly what we want.
13
+ *
14
+ * Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs
15
+ * are left untouched.
16
+ *
17
+ * Uses post-order traversal so an outer var(--tw-X, var(--tw-Y,)) collapses
18
+ * correctly after the inner var() has been removed.
19
+ */
20
+ export function stripEmptyTailwindVars(node: CssNode) {
21
+ walk(node, {
22
+ visit: 'Function',
23
+ leave(func, funcItem, funcList) {
24
+ if (func.name !== 'var') {
25
+ return;
26
+ }
27
+
28
+ let variableName: string | undefined;
29
+ walk(func, {
30
+ visit: 'Identifier',
31
+ enter(identifier) {
32
+ variableName = identifier.name;
33
+ return this.break;
34
+ },
35
+ });
36
+ if (!variableName?.startsWith('--tw-')) {
37
+ return;
38
+ }
39
+
40
+ let sawComma = false;
41
+ let hasFallbackContent = false;
42
+ func.children.forEach((child) => {
43
+ if (!sawComma) {
44
+ if (child.type === 'Operator' && child.value === ',') {
45
+ sawComma = true;
46
+ }
47
+ return;
48
+ }
49
+
50
+ let childValue = generate(child).trim();
51
+ if (child.type === 'Raw') {
52
+ const emptyTailwindVarPattern = /var\(--tw-[^,()]+,\s*\)/g;
53
+ childValue = childValue
54
+ .replaceAll(emptyTailwindVarPattern, '')
55
+ .trim();
56
+ }
57
+
58
+ if (childValue.length > 0) {
59
+ hasFallbackContent = true;
60
+ }
61
+ });
62
+
63
+ if (!sawComma || hasFallbackContent) {
64
+ return;
65
+ }
66
+
67
+ funcList.remove(funcItem);
68
+ },
69
+ });
70
+ }
package/tsconfig.json CHANGED
@@ -35,5 +35,10 @@
35
35
  "outDir": "dist"
36
36
  },
37
37
  "include": ["src/**/*.ts", "src/**/*.tsx"],
38
- "exclude": ["dist", "node_modules"]
38
+ "exclude": [
39
+ "dist",
40
+ "node_modules",
41
+ "src/cli/utils/preview/hot-reloading/test",
42
+ "src/components/tailwind/e2e"
43
+ ]
39
44
  }