diff --git a/.changeset/dirty-needles-chew.md b/.changeset/dirty-needles-chew.md
new file mode 100644
index 0000000000..acc7dd42e2
--- /dev/null
+++ b/.changeset/dirty-needles-chew.md
@@ -0,0 +1,5 @@
+---
+"react-email": minor
+---
+
+Theme switcher for email template
diff --git a/.changeset/great-parrots-yell.md b/.changeset/great-parrots-yell.md
new file mode 100644
index 0000000000..429c2c034d
--- /dev/null
+++ b/.changeset/great-parrots-yell.md
@@ -0,0 +1,5 @@
+---
+"@react-email/tailwind": minor
+---
+
+Extract tailwind pseudo classes to stylesheet
diff --git a/.changeset/ninety-apes-love.md b/.changeset/ninety-apes-love.md
new file mode 100644
index 0000000000..2a3739da36
--- /dev/null
+++ b/.changeset/ninety-apes-love.md
@@ -0,0 +1,5 @@
+---
+"react-email": patch
+---
+
+update esbuild to 0.25.0
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 496550d323..9b72873f37 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -5,8 +5,10 @@ on:
- main
pull_request:
jobs:
- lint:
+ build:
runs-on: buildjet-4vcpu-ubuntu-2204
+ outputs:
+ cache-hit: ${{ steps.pnpm-cache.outputs.cache-hit }}
container:
image: node:22
steps:
@@ -19,95 +21,114 @@ jobs:
corepack enable
corepack prepare pnpm@9.15.0 --activate
pnpm config set script-shell "/usr/bin/bash"
- echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- name: pnpm Cache
uses: buildjet/cache@v4
with:
- path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ path: |
+ ~/.pnpm-store
+ node_modules
+ */*/node_modules
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ ${{ runner.os }}-pnpm-
- name: Install packages
+ if: steps.pnpm-cache.outputs.cache-hit != 'true'
run: pnpm install --frozen-lockfile
+ - name: turborepo Cache
+ uses: buildjet/cache@v4
+ with:
+ path: |
+ .turbo
+ key: ${{ runner.os }}-turbo-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-
+
- name: Run Build
run: pnpm build
- - name: Run Lint
- run: pnpm lint
-
- test:
+ lint:
runs-on: buildjet-4vcpu-ubuntu-2204
+ needs: [build]
container:
image: node:22
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Enable Corepack
- id: pnpm-setup
+ - name: Setup pnpm
run: |
corepack enable
corepack prepare pnpm@9.15.0 --activate
- pnpm config set script-shell "/usr/bin/bash"
- echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- - name: pnpm Cache
+ - name: Restore dependencies
uses: buildjet/cache@v4
with:
- path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ path: |
+ ~/.pnpm-store
+ node_modules
+ */*/node_modules
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- - name: Install packages
- run: pnpm install --frozen-lockfile
+ - name: turborepo Cache
+ uses: buildjet/cache@v4
+ with:
+ path: |
+ .turbo
+ key: ${{ runner.os }}-turbo-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-
- name: Run Build
run: pnpm build
- - name: Run Tests
- run: pnpm test
- env:
- SPAM_ASSASSIN_HOST: ${{ secrets.SPAM_ASSASSIN_HOST }}
- SPAM_ASSASSIN_PORT: ${{ secrets.SPAM_ASSASSIN_PORT }}
+ - name: Run Lint
+ run: pnpm lint
- build:
+ test:
runs-on: buildjet-4vcpu-ubuntu-2204
+ needs: [build]
container:
image: node:22
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Enable Corepack
- id: pnpm-setup
+ - name: Setup pnpm
run: |
corepack enable
corepack prepare pnpm@9.15.0 --activate
- pnpm config set script-shell "/usr/bin/bash"
- echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- - name: pnpm Cache
+ - name: Restore dependencies
uses: buildjet/cache@v4
with:
- path: ${{ steps.pnpm-setup.outputs.pnpm_cache_dir }}
- key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
- restore-keys: |
- ${{ runner.os }}-pnpm-store-
+ path: |
+ ~/.pnpm-store
+ node_modules
+ */*/node_modules
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
- - name: Install packages
- run: pnpm install --frozen-lockfile
+ - name: turborepo Cache
+ uses: buildjet/cache@v4
+ with:
+ path: |
+ .turbo
+ key: ${{ runner.os }}-turbo-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-
- - name: Run Build
- run: pnpm build
+ - name: Run Tests
+ run: pnpm test
+ env:
+ SPAM_ASSASSIN_HOST: ${{ secrets.SPAM_ASSASSIN_HOST }}
+ SPAM_ASSASSIN_PORT: ${{ secrets.SPAM_ASSASSIN_PORT }}
dependencies:
runs-on: buildjet-4vcpu-ubuntu-2204
container:
- image: node:18
+ image: node:22
steps:
- name: Checkout
uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index d76b05d3b1..26484f7ef8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ node_modules
# testing
coverage
+package-lock.json
# next.js
.next/
diff --git a/apps/docs/utilities/render.mdx b/apps/docs/utilities/render.mdx
index 4adf371a4f..0bd8d234ff 100644
--- a/apps/docs/utilities/render.mdx
+++ b/apps/docs/utilities/render.mdx
@@ -115,6 +115,32 @@ Some title
Click me [https://example.com]
```
+## 5. Customize template rendering further
+With `useRenderingOptions()` you can get the render options from inside your template.
+
+For example, if you want some part of your template to only render when rendering as plain text, you could create your template as:
+
+```jsx email.jsx
+import * as React from 'react';
+import { Html, Button, Hr, Text, useRenderingOptions } from "@react-email/components";
+
+export function MyTemplate() {
+ const options = useRenderingOptions();
+ return (
+
+ {options.plainText &&
+
Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - }); - - it('converts a React component into HTML', async () => { - const actualOutput = await renderAsync(); - - expect(actualOutput).toMatchInlineSnapshot( - `"Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - }); - - // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 - it('should handle characters with a higher byte count gracefully', async () => { - const actualOutput = await renderAsync( - <> -Test Normal 情報Ⅰコース担当者様
-- 平素よりお世話になっております。 情報Ⅰサポートチームです。 - 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '} -
- 今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。 -- 伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 - ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 - 具体的な表示イメージは下記ページをご確認ください。 -
-- 2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 - 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 - 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 - 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。 -
-- また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 - (実際にご指示いただくかは教室判断に委ねさせていただきます。) -
-- 受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 - また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 - 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム -
- >, - ); - - expect(actualOutput).toMatchSnapshot(); - }); - - it('converts a React component into PlainText', async () => { - const actualOutput = await renderAsync(, { - plainText: true, - }); - - expect(actualOutput).toMatchInlineSnapshot(` - "WELCOME, JIM! - - Thanks for trying our product. We're thrilled to have you on board!" - `); - }); - - it('converts to plain text and removes reserved ID', async () => { - const actualOutput = await renderAsync(Thanks for trying our product. We're thrilled to have you on board!
"`, + ); + }); + + it('converts a React component with custom PlainText into the custom PlainText', async () => { + const actualOutput = await render(Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - - vi.resetAllMocks(); - }); - - // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 - it('should handle characters with a higher byte count gracefully in React 18', async () => { - const actualOutput = await renderAsync( - <> -Test Normal 情報Ⅰコース担当者様
-- 平素よりお世話になっております。 情報Ⅰサポートチームです。 - 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '} -
- 今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。 -- 伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 - ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 - 具体的な表示イメージは下記ページをご確認ください。 -
-- 2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 - 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 - 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 - 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。 -
-- また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 - (実際にご指示いただくかは教室判断に委ねさせていただきます。) -
-- 受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 - また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 - 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム -
- >, - ); - - expect(actualOutput).toMatchSnapshot(); - }); - - it('converts a React component into HTML', async () => { - const actualOutput = await renderAsync(); - - expect(actualOutput).toMatchInlineSnapshot( - `"Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - }); - - it('converts a React component into PlainText', async () => { - const actualOutput = await renderAsync(, { - plainText: true, - }); - - expect(actualOutput).toMatchInlineSnapshot(` - "WELCOME, JIM! - - Thanks for trying our product. We're thrilled to have you on board!" - `); - }); - - it('converts to plain text and removes reserved ID', async () => { - const actualOutput = await renderAsync(Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - - vi.resetAllMocks(); - }); - - // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 - it('should handle characters with a higher byte count gracefully in React 18', async () => { - const actualOutput = await renderAsync( - <> -Test Normal 情報Ⅰコース担当者様
-- 平素よりお世話になっております。 情報Ⅰサポートチームです。 - 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '} -
- 今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。 -- 伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 - ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 - 具体的な表示イメージは下記ページをご確認ください。 -
-- 2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 - 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 - 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 - 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。 -
-- また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 - (実際にご指示いただくかは教室判断に委ねさせていただきます。) -
-- 受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 - また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 - 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム -
- >, - ); - - expect(actualOutput).toMatchSnapshot(); - }); - - it('that it properly waits for Suepsense boundaries to resolve before resolving', async () => { - const EmailTemplate = () => { - const html = usePromise( - () => fetch('https://example.com').then((res) => res.text()), - [], - ); - - return ; - }; - - const renderedTemplate = await renderAsync( -Thanks for trying our product. We're thrilled to have you on board!
"`, - ); - }); - - it('converts a React component into PlainText', async () => { - const actualOutput = await renderAsync(, { - plainText: true, - }); - - expect(actualOutput).toMatchInlineSnapshot(` - "WELCOME, JIM! - - Thanks for trying our product. We're thrilled to have you on board!" - `); - }); - - it('converts to plain text and removes reserved ID', async () => { - const actualOutput = await renderAsync(Thanks for trying our product. We're thrilled to have you on board!
"`, + ); + }); + + it('converts a React component with custom PlainText into the custom PlainText', async () => { + const actualOutput = await render(Thanks for trying our product. We're thrilled to have you on board!
"`, + ); + }); + it('converts a React component into PlainText', async () => { const actualOutput = await render(, { plainText: true, @@ -124,6 +132,26 @@ describe('render on node environments', () => { `); }); + it('converts a React component with custom PlainText into HTML', async () => { + const actualOutput = await render(); + + expect(actualOutput).toMatchInlineSnapshot( + `"Thanks for trying our product. We're thrilled to have you on board!
"`, + ); + }); + + it('converts a React component with custom PlainText into the custom PlainText', async () => { + const actualOutput = await render(Thanks for trying our product. We're thrilled to have you on board!
> ); + +export const TemplateWithCustomPlainText: React.FCThanks for trying our product.
+ > + ) + + return ( + <> +Thanks for trying our product. We're thrilled to have you on board!
+ > + ) +}; diff --git a/packages/tailwind/integrations/_tests/nextjs.spec.ts b/packages/tailwind/integrations/_tests/nextjs.spec.ts deleted file mode 100644 index 29e3ab94b6..0000000000 --- a/packages/tailwind/integrations/_tests/nextjs.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import path from 'node:path'; -import { $ } from './utils/run-testing-command'; - -test.sequential("Tailwind works on the Next App's build process", () => { - const nextAppLocation = path.resolve(__dirname, '../nextjs'); - $('npm install', nextAppLocation); - $('npm run build', nextAppLocation); -}); diff --git a/packages/tailwind/integrations/_tests/utils/run-testing-command.ts b/packages/tailwind/integrations/_tests/utils/run-testing-command.ts deleted file mode 100644 index d9683f71f0..0000000000 --- a/packages/tailwind/integrations/_tests/utils/run-testing-command.ts +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'node:path'; -import shell from 'shelljs'; - -/** - * Just a function that runs `shell.exec` and expects it returns code 0, i.e. expects command not to fail - * - * Defaults the CWD to the @react-email/tailwind project's directory - */ -export const $ = ( - command: string, - cwd: string = path.resolve(__dirname, '..'), -) => { - expect( - shell.exec(command, { cwd, fatal: true }).code, - `Expected command "${command}" to work properly but it returned a non-zero exit code`, - ).toBe(0); -}; diff --git a/packages/tailwind/integrations/_tests/vite.spec.ts b/packages/tailwind/integrations/_tests/vite.spec.ts deleted file mode 100644 index c81756e3d2..0000000000 --- a/packages/tailwind/integrations/_tests/vite.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import path from 'node:path'; -import { $ } from './utils/run-testing-command'; - -test.sequential("Tailwind works on the Vite App's build process", () => { - const viteAppLocation = path.resolve(__dirname, '../vite'); - $('npm install', viteAppLocation); - $('npm run build', viteAppLocation); -}); diff --git a/packages/tailwind/integrations/integrations.spec.ts b/packages/tailwind/integrations/integrations.spec.ts new file mode 100644 index 0000000000..5a799c996f --- /dev/null +++ b/packages/tailwind/integrations/integrations.spec.ts @@ -0,0 +1,37 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const $ = (command: string, cwd: string = path.resolve(__dirname, '..')) => { + process.stderr.write(`${cwd} $ ${command}\n`); + const returns = spawnSync(command, { + shell: true, + cwd, + stdio: 'inherit', + }); + expect( + returns.status, + `Expected command "${command}" to work properly but it returned a non-zero exit code`, + ).toBe(0); +}; + +describe('integrations', () => { + beforeAll(() => { + const packageLocation = path.resolve(__dirname, '../'); + $('yalc installations clean @react-email/tailwind', packageLocation); + $('yalc publish', packageLocation); + }); + + const integrationsLocation = __dirname; + + test("Tailwind works on the Next App's build process", () => { + const nextAppLocation = path.resolve(integrationsLocation, 'nextjs'); + $('npm install', nextAppLocation); + $('npm run build', nextAppLocation); + }); + + test("Tailwind works on the Vite App's build process", () => { + const viteAppLocation = path.resolve(integrationsLocation, 'vite'); + $('npm install', viteAppLocation); + $('npm run build', viteAppLocation); + }); +}); diff --git a/packages/tailwind/integrations/nextjs/next-env.d.ts b/packages/tailwind/integrations/nextjs/next-env.d.ts index 40c3d68096..1b3be0840f 100644 --- a/packages/tailwind/integrations/nextjs/next-env.d.ts +++ b/packages/tailwind/integrations/nextjs/next-env.d.ts @@ -2,4 +2,4 @@ ///I am some text
"`; - exports[`Tailwind component > and keep class names 1`] = `""`; + +exports[`non-inlinable styles > should not have duplicate media queries 1`] = `""`; + +exports[`non-inlinable styles > should persist existingI am some text
"`; diff --git a/packages/tailwind/src/tailwind.spec.tsx b/packages/tailwind/src/tailwind.spec.tsx index cc6c894831..cd02fe3cb3 100644 --- a/packages/tailwind/src/tailwind.spec.tsx +++ b/packages/tailwind/src/tailwind.spec.tsx @@ -338,7 +338,7 @@ describe('Tailwind component', () => { }); }); -describe('Responsive styles', () => { +describe('non-inlinable styles', () => { /* This test is because of https://github.com/resend/react-email/issues/1112 which was being caused because we required to, either have our component, @@ -389,8 +389,8 @@ describe('Responsive styles', () => { const output = await render(