diff --git a/.changeset/selfish-dogs-deny.md b/.changeset/selfish-dogs-deny.md new file mode 100644 index 0000000000..bd7ddfc664 --- /dev/null +++ b/.changeset/selfish-dogs-deny.md @@ -0,0 +1,5 @@ +--- +"react-email": major +--- + +Add the source for all components, build and export them all unified with `react-email` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 496550d323..8afb2dbf67 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,9 +32,6 @@ jobs: - name: Install packages run: pnpm install --frozen-lockfile - - name: Run Build - run: pnpm build - - name: Run Lint run: pnpm lint diff --git a/apps/demo/emails/notifications/vercel-invite-user.tsx b/apps/demo/emails/notifications/vercel-invite-user.tsx index bc52440011..b4f7de1228 100644 --- a/apps/demo/emails/notifications/vercel-invite-user.tsx +++ b/apps/demo/emails/notifications/vercel-invite-user.tsx @@ -14,7 +14,7 @@ import { Section, Tailwind, Text, -} from '@react-email/components'; +} from 'react-email'; interface VercelInviteUserEmailProps { username?: string; diff --git a/apps/demo/package.json b/apps/demo/package.json index f6a10a122a..813d37d9e5 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "pnpm install --frozen-lockfile && email build", + "build": "email build", "dev": "email dev", "start": "email start", "export": "email export" diff --git a/biome.json b/biome.json index 7e6748b228..809176d6ef 100644 --- a/biome.json +++ b/biome.json @@ -59,6 +59,7 @@ "files": { "ignore": [ "dist", + "patched", "pnpm-lock.yaml", ".next", "public", diff --git a/package.json b/package.json index 9332890414..e4fc371430 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,15 @@ "private": true, "scripts": { "build": "turbo run build --filter=!react-email-starter", - "dev": "turbo run dev --filter=!react-email-starter --parallel --concurrency 25", - "release": "turbo run build --filter=./packages/* --filter=!react-email-starter && pnpm changeset publish", "canary:enter": "changeset pre enter canary", "canary:exit": "changeset pre exit", - "version": "changeset version && pnpm install --no-frozen-lockfile", + "dev": "turbo run dev --filter=!react-email-starter --parallel --concurrency 25", "lint": "biome check", "lint:fix": "biome check --write .", + "release": "turbo run build --filter=./packages/* --filter=!react-email-starter && pnpm changeset publish", "test": "turbo run test", - "test:watch": "turbo run test:watch" + "test:watch": "turbo run test:watch", + "version": "changeset version && pnpm install --no-frozen-lockfile" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/packages/components/package.json b/packages/components/package.json index bc2032248d..86c9484305 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,7 @@ { "name": "@react-email/components", "version": "0.0.34", + "private": true, "description": "A collection of all components React Email.", "sideEffects": false, "main": "./dist/index.js", diff --git a/packages/react-email/.gitignore b/packages/react-email/.gitignore index 390c625106..187e1878a4 100644 --- a/packages/react-email/.gitignore +++ b/packages/react-email/.gitignore @@ -1,8 +1,10 @@ node_modules .next cli +yalc.lock !src/cli # for testing +package-lock.json .for-dependency-graph.ts static diff --git a/packages/react-email/next.config.js b/packages/react-email/next.config.js index 2c15463e00..6dc65694ce 100644 --- a/packages/react-email/next.config.js +++ b/packages/react-email/next.config.js @@ -12,6 +12,9 @@ module.exports = { return config; }, + eslint: { + ignoreDuringBuilds: true, + }, // Noticed an issue with typescript transpilation when going from Next 14.1.1 to 14.1.2 // and I narrowed that down into this PR https://github.com/vercel/next.js/pull/62005 // diff --git a/packages/react-email/package.json b/packages/react-email/package.json index 5454ee7124..098f932bf2 100644 --- a/packages/react-email/package.json +++ b/packages/react-email/package.json @@ -5,14 +5,75 @@ "bin": { "email": "./dist/cli/index.js" }, + "sideEffects": ["./dist/code-block.*"], + "main": "./dist/index.browser.js", + "module": "./dist/index.browser.mjs", + "types": "./dist/index.browser.d.ts", + "exports": { + ".": { + "node": { + "import": { + "types": "./dist/index.node.d.mts", + "default": "./dist/index.node.mjs" + }, + "require": { + "types": "./dist/index.node.d.ts", + "default": "./dist/index.node.js" + } + }, + "deno": { + "import": { + "types": "./dist/index.browser.d.mts", + "default": "./dist/index.browser.mjs" + }, + "require": { + "types": "./dist/index.browser.d.ts", + "default": "./dist/index.browser.js" + } + }, + "worker": { + "import": { + "types": "./dist/index.browser.d.mts", + "default": "./dist/index.browser.mjs" + }, + "require": { + "types": "./dist/index.browser.d.ts", + "default": "./dist/index.browser.js" + } + }, + "browser": { + "import": { + "types": "./dist/index.browser.d.mts", + "default": "./dist/index.browser.mjs" + }, + "require": { + "types": "./dist/index.browser.d.ts", + "default": "./dist/index.browser.js" + } + }, + "default": { + "import": { + "types": "./dist/index.node.d.mts", + "default": "./dist/index.node.mjs" + }, + "require": { + "types": "./dist/index.node.d.ts", + "default": "./dist/index.node.js" + } + } + } + }, "scripts": { - "build": "tsup-node && node ./scripts/build-preview-server.mjs", + "build:preview": "node ./scripts/build-preview-server.mjs", + "build:tsup": "node --max-old-space-size=8192 ./node_modules/tsup/dist/cli-node.js", + "build:package": "pnpm build:tsup && pnpm install --frozen-lockfile", + "build": "pnpm build:package && pnpm build:preview", + "dev": "pnpm build:tsup --watch", + "test": "vitest run", + "test:watch": "vitest", "caniemail:fetch": "node ./scripts/fill-caniemail-data.mjs", "clean": "rm -rf dist", - "dev": "tsup-node --watch", - "dev:preview": "cd ../../apps/demo && tsx ../../packages/react-email/src/cli/index.ts dev", - "test": "vitest run", - "test:watch": "vitest" + "dev:preview": "cd ../../apps/demo && tsx ../../packages/react-email/src/cli/index.ts dev" }, "license": "MIT", "repository": { @@ -31,17 +92,28 @@ "chokidar": "4.0.3", "commander": "11.1.0", "debounce": "2.0.0", - "esbuild": "0.23.0", + "esbuild": "0.24.2", "glob": "10.3.4", + "html-to-text": "9.0.5", "log-symbols": "4.1.0", + "md-to-react-email": "5.0.5", "mime-types": "2.1.35", "next": "15.2.2", "normalize-path": "3.0.0", "ora": "5.4.1", + "prettier": "3.4.2", + "prismjs": "1.29.0", + "postcss": "8.4.40", + "postcss-selector-parser": "6.0.16", + "tailwindcss": "3.4.10", "socket.io": "4.8.1" }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, "devDependencies": { "@babel/core": "7.26.10", + "@edge-runtime/vm": "3.1.8", "@lottiefiles/dotlottie-react": "0.12.3", "@radix-ui/colors": "1.0.1", "@radix-ui/react-collapsible": "1.1.0", @@ -51,44 +123,48 @@ "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toggle-group": "1.1.0", "@radix-ui/react-tooltip": "1.1.2", - "@react-email/render": "workspace:*", + "@responsive-email/react-email": "0.0.3", "@swc/core": "1.4.15", "@types/babel__core": "7.20.5", "@types/babel__traverse": "*", "@types/fs-extra": "11.0.1", + "@types/html-to-text": "9.0.4", "@types/mime-types": "2.1.4", "@types/node": "22.10.2", "@types/normalize-path": "3.0.2", + "@types/prismjs": "1.26.5", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", + "@types/shelljs": "0.8.15", "@types/webpack": "5.28.5", "@vercel/style-guide": "5.1.0", "autoprefixer": "10.4.20", "clsx": "2.1.0", "framer-motion": "12.0.0-alpha.2", "jiti": "2.4.2", + "jsdom": "23.0.1", "json5": "2.2.3", "module-punycode": "npm:punycode@2.3.1", "node-html-parser": "6.1.13", - "postcss": "8.4.40", - "prettier-plugin-tailwindcss": "0.6.6", "pretty-bytes": "6.1.1", - "prism-react-renderer": "2.1.0", + "prism-react-renderer": "2.4.0", "react": "19.0.0", "react-dom": "19.0.0", "sharp": "0.33.3", + "shelljs": "0.8.5", "socket.io-client": "4.8.0", "sonner": "1.7.1", "source-map-js": "1.0.2", "spamc": "0.0.5", "stacktrace-parser": "0.1.10", "tailwind-merge": "2.2.0", - "tailwindcss": "3.4.0", + "tsconfig": "workspace:*", "tsup": "7.2.0", "tsx": "4.9.0", "typescript": "5.8.2", "use-debounce": "10.0.4", - "vitest": "1.1.3", + "vitest": "2.1.8", + "yalc": "1.0.0-pre.53", "zod": "3.24.2" } } diff --git a/packages/react-email/scripts/adjust-package-index.ts b/packages/react-email/scripts/adjust-package-index.ts new file mode 100644 index 0000000000..da2f2df5ba --- /dev/null +++ b/packages/react-email/scripts/adjust-package-index.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export async function adjustPackageIndex() { + const distFiles = await fs.readdir(path.resolve(process.cwd(), 'dist')); + const indexFiles = distFiles.filter((file) => file.startsWith('index')); + + for await (const file of indexFiles) { + const extension = path.extname(file); + + if (extension === '.js' || extension === '.mjs') { + const pathToFile = path.resolve(process.cwd(), 'dist', file); + const originalContents = await fs.readFile(pathToFile, 'utf8'); + + await fs.writeFile( + pathToFile, + originalContents.replaceAll( + /"(?\.\/[^"]+)"/g, + (value, componentPath: string) => { + if (componentPath.startsWith('./render')) { + const newComponentPath = componentPath + .replace('/browser', '.browser') + .replace('/node', '.node'); + return value.replace( + componentPath, + `${newComponentPath}${extension}`, + ); + } + return value.replace(componentPath, `${componentPath}${extension}`); + }, + ), + 'utf8', + ); + } + } +} diff --git a/packages/react-email/src/cli/commands/build.ts b/packages/react-email/src/cli/commands/build.ts index 7556010672..e80ec4e80f 100644 --- a/packages/react-email/src/cli/commands/build.ts +++ b/packages/react-email/src/cli/commands/build.ts @@ -165,6 +165,7 @@ const updatePackageJson = async (builtPreviewAppPath: string) => { ) as { name: string; scripts: Record; + peerDependencies?: Record; dependencies: Record; devDependencies: Record; }; @@ -172,13 +173,21 @@ const updatePackageJson = async (builtPreviewAppPath: string) => { packageJson.scripts.start = 'next start'; packageJson.name = 'preview-server'; - // We remove this one to avoid having resolve issues on our demo build process. - // This is only used in the `export` command so it's irrelevant to have it here. - // - // See `src/actions/render-email-by-path` for more info on how we render the - // email templates without `@react-email/render` being installed. - delete packageJson.devDependencies['@react-email/render']; - delete packageJson.devDependencies['@react-email/components']; + for (const [key, value] of Object.entries(packageJson.dependencies)) { + if (value.startsWith('workspace:')) { + delete packageJson.dependencies[key]; + } + } + for (const [key, value] of Object.entries(packageJson.devDependencies)) { + if (value.startsWith('workspace:')) { + delete packageJson.devDependencies[key]; + } + } + + // This is meant to avoid issues with peer dependencies while the package doesn't upgrade + delete packageJson.devDependencies['@responsive-email/react-email']; + + delete packageJson.peerDependencies; await fs.promises.writeFile( packageJsonPath, JSON.stringify(packageJson), diff --git a/packages/react-email/src/cli/commands/export.ts b/packages/react-email/src/cli/commands/export.ts index 6525c37e12..a66b163e88 100644 --- a/packages/react-email/src/cli/commands/export.ts +++ b/packages/react-email/src/cli/commands/export.ts @@ -1,12 +1,12 @@ import fs, { unlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import type { Options } from '@react-email/render'; import { type BuildFailure, build } from 'esbuild'; import { glob } from 'glob'; import logSymbols from 'log-symbols'; import normalize from 'normalize-path'; import ora from 'ora'; -import type React from 'react'; +import type { createElement } from 'react'; +import type { Options } from '../../package/render/node'; import { renderingUtilitiesExporter } from '../../utils/esbuild/renderring-utilities-exporter'; import { type EmailsDirectory, @@ -117,7 +117,7 @@ export const exportTemplates = async ( element: React.ReactElement, options: Record, ) => Promise; - reactEmailCreateReactElement: typeof React.createElement; + reactEmailCreateReactElement: typeof createElement; }; const rendered = await emailModule.render( emailModule.reactEmailCreateReactElement(emailModule.default, {}), diff --git a/packages/react-email/src/package/body/__snapshots__/body.spec.tsx.snap b/packages/react-email/src/package/body/__snapshots__/body.spec.tsx.snap new file mode 100644 index 0000000000..ca86d77885 --- /dev/null +++ b/packages/react-email/src/package/body/__snapshots__/body.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = `"Lorem ipsum"`; diff --git a/packages/react-email/src/package/body/body.spec.tsx b/packages/react-email/src/package/body/body.spec.tsx new file mode 100644 index 0000000000..289a76ab01 --- /dev/null +++ b/packages/react-email/src/package/body/body.spec.tsx @@ -0,0 +1,26 @@ +import { Body } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('renders children correctly', async () => { + const testMessage = 'Test message'; + const html = await render({testMessage}); + expect(html).toContain(testMessage); + }); + + it('passes style and other props correctly', async () => { + const style = { backgroundColor: 'red' }; + const html = await render( + + Test + , + ); + expect(html).toContain('style="background-color:red"'); + expect(html).toContain('data-testid="body-test"'); + }); + + it('renders correctly', async () => { + const actualOutput = await render(Lorem ipsum); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/body/body.tsx b/packages/react-email/src/package/body/body.tsx new file mode 100644 index 0000000000..dba462e4f3 --- /dev/null +++ b/packages/react-email/src/package/body/body.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export type BodyProps = Readonly>; + +export const Body = React.forwardRef( + ({ children, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +Body.displayName = 'Body'; diff --git a/packages/react-email/src/package/body/index.ts b/packages/react-email/src/package/body/index.ts new file mode 100644 index 0000000000..f046a9e664 --- /dev/null +++ b/packages/react-email/src/package/body/index.ts @@ -0,0 +1 @@ +export * from './body'; diff --git a/packages/react-email/src/package/button/__snapshots__/button.spec.tsx.snap b/packages/react-email/src/package/button/__snapshots__/button.spec.tsx.snap new file mode 100644 index 0000000000..1b9e7cd7ed --- /dev/null +++ b/packages/react-email/src/package/button/__snapshots__/button.spec.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`); + expect(html).toContain(testMessage); + }); + + it('passes style and other props correctly', async () => { + const style = { backgroundColor: 'red' }; + const html = await render( + , + ); + expect(html).toContain('background-color:red'); + expect(html).toContain('data-testid="button-test"'); + }); + + it('renders correctly with padding values from style prop', async () => { + const actualOutput = await render( + + , + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/container/container.tsx b/packages/react-email/src/package/container/container.tsx new file mode 100644 index 0000000000..1a5b4d2261 --- /dev/null +++ b/packages/react-email/src/package/container/container.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +export type ContainerProps = Readonly>; + +export const Container = React.forwardRef( + ({ children, style, ...props }, ref) => { + return ( + + + + + + +
{children}
+ ); + }, +); + +Container.displayName = 'Container'; diff --git a/packages/react-email/src/package/container/index.ts b/packages/react-email/src/package/container/index.ts new file mode 100644 index 0000000000..85ee15b654 --- /dev/null +++ b/packages/react-email/src/package/container/index.ts @@ -0,0 +1 @@ +export * from './container'; diff --git a/packages/react-email/src/package/font/__snapshots__/font.spec.tsx.snap b/packages/react-email/src/package/font/__snapshots__/font.spec.tsx.snap new file mode 100644 index 0000000000..6ce5a111f3 --- /dev/null +++ b/packages/react-email/src/package/font/__snapshots__/font.spec.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = ` +"" +`; diff --git a/packages/react-email/src/package/font/font.spec.tsx b/packages/react-email/src/package/font/font.spec.tsx new file mode 100644 index 0000000000..5b07666837 --- /dev/null +++ b/packages/react-email/src/package/font/font.spec.tsx @@ -0,0 +1,49 @@ +import { Font } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('renders with default props', async () => { + const html = await render( + , + ); + + expect(html).toContain('font-style: normal;'); + expect(html).toContain('font-weight: 400;'); + expect(html).toContain("font-family: 'Arial';"); + }); + + it('renders with webFont prop', async () => { + const webFont = { + url: 'example.com/font.woff', + format: 'woff', + } as const; + + const html = await render( + , + ); + + expect(html).toContain("font-family: 'Example';"); + expect(html).toContain( + `src: url(${webFont.url}) format('${webFont.format}');`, + ); + }); + + it('renders with multiple fallback fonts', async () => { + const html = await render( + , + ); + + expect(html).toContain("font-family: 'Arial', Helvetica, Verdana;"); + }); + + it('renders correctly', async () => { + const actualOutput = await render( + , + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/font/font.tsx b/packages/react-email/src/package/font/font.tsx new file mode 100644 index 0000000000..0aa9c6c533 --- /dev/null +++ b/packages/react-email/src/package/font/font.tsx @@ -0,0 +1,74 @@ +type FallbackFont = + | 'Arial' + | 'Helvetica' + | 'Verdana' + | 'Georgia' + | 'Times New Roman' + | 'serif' + | 'sans-serif' + | 'monospace' + | 'cursive' + | 'fantasy'; + +type FontFormat = + | 'woff' + | 'woff2' + | 'truetype' + | 'opentype' + | 'embedded-opentype' + | 'svg'; + +type FontWeight = React.CSSProperties['fontWeight']; +type FontStyle = React.CSSProperties['fontStyle']; + +export interface FontProps { + /** The font you want to use. NOTE: Do not insert multiple fonts here, use fallbackFontFamily for that */ + fontFamily: string; + /** An array is possible, but the order of the array is the priority order */ + fallbackFontFamily: FallbackFont | FallbackFont[]; + /** Not all clients support web fonts. For support check: https://www.caniemail.com/features/css-at-font-face/ */ + webFont?: { + url: string; + format: FontFormat; + }; + /** Default: 'normal' */ + fontStyle?: FontStyle; + /** Default: 400 */ + fontWeight?: FontWeight; +} + +/** The component MUST be place inside the tag */ +export const Font: React.FC> = ({ + fontFamily, + fallbackFontFamily, + webFont, + fontStyle = 'normal', + fontWeight = 400, +}) => { + const src = webFont + ? `src: url(${webFont.url}) format('${webFont.format}');` + : ''; + + const style = ` + @font-face { + font-family: '${fontFamily}'; + font-style: ${fontStyle}; + font-weight: ${fontWeight}; + mso-font-alt: '${ + Array.isArray(fallbackFontFamily) + ? fallbackFontFamily[0] + : fallbackFontFamily + }'; + ${src} + } + + * { + font-family: '${fontFamily}', ${ + Array.isArray(fallbackFontFamily) + ? fallbackFontFamily.join(', ') + : fallbackFontFamily + }; + } + `; + return " +`; diff --git a/packages/react-email/src/package/head/head.spec.tsx b/packages/react-email/src/package/head/head.spec.tsx new file mode 100644 index 0000000000..7602590609 --- /dev/null +++ b/packages/react-email/src/package/head/head.spec.tsx @@ -0,0 +1,28 @@ +import { Head } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('renders children correctly', async () => { + const testMessage = 'Test message'; + const html = await render({testMessage}); + expect(html).toContain(testMessage); + }); + + it('renders correctly', async () => { + const actualOutput = await render(); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders style tags', async () => { + const actualOutput = await render( + + + , + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/head/head.tsx b/packages/react-email/src/package/head/head.tsx new file mode 100644 index 0000000000..777a5f6c27 --- /dev/null +++ b/packages/react-email/src/package/head/head.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +export type HeadProps = Readonly>; + +export const Head = React.forwardRef( + ({ children, ...props }, ref) => ( + + + + {children} + + ), +); + +Head.displayName = 'Head'; diff --git a/packages/react-email/src/package/head/index.ts b/packages/react-email/src/package/head/index.ts new file mode 100644 index 0000000000..b6f0ab751b --- /dev/null +++ b/packages/react-email/src/package/head/index.ts @@ -0,0 +1 @@ +export * from './head'; diff --git a/packages/react-email/src/package/heading/__snapshots__/heading.spec.tsx.snap b/packages/react-email/src/package/heading/__snapshots__/heading.spec.tsx.snap new file mode 100644 index 0000000000..a790e21f6d --- /dev/null +++ b/packages/react-email/src/package/heading/__snapshots__/heading.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render > renders the component 1`] = `"

Lorem ipsum

"`; diff --git a/packages/react-email/src/package/heading/heading.spec.tsx b/packages/react-email/src/package/heading/heading.spec.tsx new file mode 100644 index 0000000000..0277504168 --- /dev/null +++ b/packages/react-email/src/package/heading/heading.spec.tsx @@ -0,0 +1,30 @@ +import { Heading } from '.'; +import { render } from '../render/node'; + +describe('render', () => { + it('renders children correctly', async () => { + const testMessage = 'Test message'; + const html = await render({testMessage}); + expect(html).toContain(testMessage); + }); + + it('passes style and other props correctly', async () => { + const style = { backgroundColor: 'red' }; + const html = await render( + + Test + , + ); + expect(html).toContain('background-color:red'); + expect(html).toContain('data-testid="heading-test"'); + }); + + it('renders the component', async () => { + const actualOutput = await render( + + Lorem ipsum + , + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/heading/heading.tsx b/packages/react-email/src/package/heading/heading.tsx new file mode 100644 index 0000000000..3d70d9a624 --- /dev/null +++ b/packages/react-email/src/package/heading/heading.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import type { As } from './utils/as'; +import type { Margin } from './utils/spaces'; +import { withMargin } from './utils/spaces'; + +export type HeadingAs = As<'h1', 'h2', 'h3', 'h4', 'h5', 'h6'>; +export type HeadingProps = HeadingAs & Margin; + +export const Heading = React.forwardRef< + HTMLHeadingElement, + Readonly +>( + ( + { as: Tag = 'h1', children, style, m, mx, my, mt, mr, mb, ml, ...props }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +Heading.displayName = 'Heading'; diff --git a/packages/react-email/src/package/heading/index.ts b/packages/react-email/src/package/heading/index.ts new file mode 100644 index 0000000000..3de265cc79 --- /dev/null +++ b/packages/react-email/src/package/heading/index.ts @@ -0,0 +1 @@ +export * from './heading'; diff --git a/packages/react-email/src/package/heading/utils/as.ts b/packages/react-email/src/package/heading/utils/as.ts new file mode 100644 index 0000000000..0452a37899 --- /dev/null +++ b/packages/react-email/src/package/heading/utils/as.ts @@ -0,0 +1,26 @@ +export type As< + DefaultTag extends React.ElementType, + T1 extends React.ElementType, + T2 extends React.ElementType = T1, + T3 extends React.ElementType = T1, + T4 extends React.ElementType = T1, + T5 extends React.ElementType = T1, +> = + | (React.ComponentPropsWithRef & { + as?: DefaultTag; + }) + | (React.ComponentPropsWithRef & { + as: T1; + }) + | (React.ComponentPropsWithRef & { + as: T2; + }) + | (React.ComponentPropsWithRef & { + as: T3; + }) + | (React.ComponentPropsWithRef & { + as: T4; + }) + | (React.ComponentPropsWithRef & { + as: T5; + }); diff --git a/packages/react-email/src/package/heading/utils/spaces.ts b/packages/react-email/src/package/heading/utils/spaces.ts new file mode 100644 index 0000000000..7e6e8b8d28 --- /dev/null +++ b/packages/react-email/src/package/heading/utils/spaces.ts @@ -0,0 +1,48 @@ +import type React from 'react'; + +type MarginCSSProperty = React.CSSProperties[ + | 'margin' + | 'marginLeft' + | 'marginRight' + | 'marginTop' + | 'marginBottom']; + +export interface Margin { + m?: number | string; + mx?: number | string; + my?: number | string; + mt?: number | string; + mr?: number | string; + mb?: number | string; + ml?: number | string; +} + +export const withMargin = (props: Margin) => { + const nonEmptyStyles = [ + withSpace(props.m, ['margin']), + withSpace(props.mx, ['marginLeft', 'marginRight']), + withSpace(props.my, ['marginTop', 'marginBottom']), + withSpace(props.mt, ['marginTop']), + withSpace(props.mr, ['marginRight']), + withSpace(props.mb, ['marginBottom']), + withSpace(props.ml, ['marginLeft']), + ].filter((s) => Object.keys(s).length); + + const mergedStyles = nonEmptyStyles.reduce((acc, style) => { + return { ...acc, ...style }; + }, {}); + return mergedStyles; +}; + +export const withSpace = ( + value: number | string | undefined, + properties: MarginCSSProperty[], +) => { + return properties.reduce((styles, property) => { + // Check to ensure string value is a valid number + if (!Number.isNaN(Number.parseFloat(value as string))) { + return { ...styles, [property as keyof MarginCSSProperty]: `${value}px` }; + } + return styles; + }, {}); +}; diff --git a/packages/react-email/src/package/heading/utils/utils.spec.ts b/packages/react-email/src/package/heading/utils/utils.spec.ts new file mode 100644 index 0000000000..c4efeec9e1 --- /dev/null +++ b/packages/react-email/src/package/heading/utils/utils.spec.ts @@ -0,0 +1,70 @@ +import type { Margin } from './spaces'; +import { withMargin, withSpace } from './spaces'; + +describe('withMargin', () => { + it('should return an empty object for empty input', () => { + const marginProps: Margin = {}; + const result = withMargin(marginProps); + expect(result).toEqual({}); + }); + + it('should apply margin to the top', () => { + const marginProps: Margin = { mt: '10' }; + const result = withMargin(marginProps); + expect(result).toEqual({ marginTop: '10px' }); + }); + + it('should apply margin to the left and right', () => { + const marginProps: Margin = { mx: '20' }; + const result = withMargin(marginProps); + expect(result).toEqual({ marginLeft: '20px', marginRight: '20px' }); + }); + + it('should apply margin to the top and bottom', () => { + const marginProps: Margin = { my: '15' }; + const result = withMargin(marginProps); + expect(result).toEqual({ + marginBottom: '15px', + marginTop: '15px', + }); + }); + + it('should apply margin to all sides', () => { + const marginProps: Margin = { m: '25' }; + const result = withMargin(marginProps); + expect(result).toEqual({ + margin: '25px', + }); + }); + + it('should apply margin to specified sides when provided', () => { + const marginProps: Margin = { mt: '5', mr: '10', mb: '15', ml: '20' }; + const result = withMargin(marginProps); + expect(result).toEqual({ + marginBottom: '15px', + marginLeft: '20px', + marginRight: '10px', + marginTop: '5px', + }); + }); + + it('should ignore invalid margin values', () => { + const marginProps: Margin = { m: 'invalid', mt: '5', mx: 'valid' }; + const result = withMargin(marginProps); + expect(result).toEqual({ + marginTop: '5px', + }); + }); +}); + +describe('withSpace', () => { + it('should return an empty object for undefined value', () => { + const result = withSpace(undefined, ['margin']); + expect(result).toEqual({}); + }); + + it('should apply space to the specified properties', () => { + const result = withSpace(15, ['marginTop', 'marginLeft']); + expect(result).toEqual({ marginTop: '15px', marginLeft: '15px' }); + }); +}); diff --git a/packages/react-email/src/package/hr/__snapshots__/hr.spec.tsx.snap b/packages/react-email/src/package/hr/__snapshots__/hr.spec.tsx.snap new file mode 100644 index 0000000000..575ce7e212 --- /dev/null +++ b/packages/react-email/src/package/hr/__snapshots__/hr.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`
component > renders correctly 1`] = `"
"`; diff --git a/packages/react-email/src/package/hr/hr.spec.tsx b/packages/react-email/src/package/hr/hr.spec.tsx new file mode 100644 index 0000000000..f38b6de6d1 --- /dev/null +++ b/packages/react-email/src/package/hr/hr.spec.tsx @@ -0,0 +1,20 @@ +import { Hr } from '.'; +import { render } from '../render/node'; + +describe('
component', () => { + it('passes styles and other props correctly', async () => { + const style = { + width: '50%', + borderColor: 'black', + }; + const html = await render(
); + expect(html).toContain('width:50%'); + expect(html).toContain('border-color:black'); + expect(html).toContain('data-testid="hr-test"'); + }); + + it('renders correctly', async () => { + const actualOutput = await render(
); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/hr/hr.tsx b/packages/react-email/src/package/hr/hr.tsx new file mode 100644 index 0000000000..f7ebd4ec32 --- /dev/null +++ b/packages/react-email/src/package/hr/hr.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +export type HrProps = Readonly>; + +export const Hr = React.forwardRef( + ({ style, ...props }, ref) => ( +
+ ), +); + +Hr.displayName = 'Hr'; diff --git a/packages/react-email/src/package/hr/index.ts b/packages/react-email/src/package/hr/index.ts new file mode 100644 index 0000000000..d9b0adbced --- /dev/null +++ b/packages/react-email/src/package/hr/index.ts @@ -0,0 +1 @@ +export * from './hr'; diff --git a/packages/react-email/src/package/html/__snapshots__/html.spec.tsx.snap b/packages/react-email/src/package/html/__snapshots__/html.spec.tsx.snap new file mode 100644 index 0000000000..9c90e69afa --- /dev/null +++ b/packages/react-email/src/package/html/__snapshots__/html.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = `""`; diff --git a/packages/react-email/src/package/html/html.spec.tsx b/packages/react-email/src/package/html/html.spec.tsx new file mode 100644 index 0000000000..77d1b76619 --- /dev/null +++ b/packages/react-email/src/package/html/html.spec.tsx @@ -0,0 +1,24 @@ +import { Html } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('renders children correctly', async () => { + const testMessage = 'Test message'; + const html = await render({testMessage}); + expect(html).toContain(testMessage); + }); + + it('passes props correctly', async () => { + const html = await render( + , + ); + expect(html).toContain('lang="fr"'); + expect(html).toContain('dir="rtl"'); + expect(html).toContain('data-testid="html-test"'); + }); + + it('renders correctly', async () => { + const actualOutput = await render(); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/html/html.tsx b/packages/react-email/src/package/html/html.tsx new file mode 100644 index 0000000000..85d4500e6f --- /dev/null +++ b/packages/react-email/src/package/html/html.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +export type HtmlProps = Readonly>; + +export const Html = React.forwardRef( + ({ children, lang = 'en', dir = 'ltr', ...props }, ref) => ( + + {children} + + ), +); + +Html.displayName = 'Html'; diff --git a/packages/react-email/src/package/html/index.ts b/packages/react-email/src/package/html/index.ts new file mode 100644 index 0000000000..f7bfdd142b --- /dev/null +++ b/packages/react-email/src/package/html/index.ts @@ -0,0 +1 @@ +export * from './html'; diff --git a/packages/react-email/src/package/img/__snapshots__/img.spec.tsx.snap b/packages/react-email/src/package/img/__snapshots__/img.spec.tsx.snap new file mode 100644 index 0000000000..d832c4a1c4 --- /dev/null +++ b/packages/react-email/src/package/img/__snapshots__/img.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = `"Cat"`; diff --git a/packages/react-email/src/package/img/img.spec.tsx b/packages/react-email/src/package/img/img.spec.tsx new file mode 100644 index 0000000000..5271bdb87e --- /dev/null +++ b/packages/react-email/src/package/img/img.spec.tsx @@ -0,0 +1,28 @@ +import { Img } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('passes style and other props correctly', async () => { + const style = { backgroundColor: 'red', border: 'solid 1px black' }; + const html = await render( + Cat, + ); + expect(html).toContain('background-color:red'); + expect(html).toContain('border:solid 1px black'); + expect(html).toContain('data-testid="img-test"'); + }); + + it('renders correctly', async () => { + const actualOutput = await render( + Cat, + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/img/img.tsx b/packages/react-email/src/package/img/img.tsx new file mode 100644 index 0000000000..1bc08a7afe --- /dev/null +++ b/packages/react-email/src/package/img/img.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +export type ImgProps = Readonly>; + +export const Img = React.forwardRef( + ({ alt, src, width, height, style, ...props }, ref) => ( + {alt} + ), +); + +Img.displayName = 'Img'; diff --git a/packages/react-email/src/package/img/index.ts b/packages/react-email/src/package/img/index.ts new file mode 100644 index 0000000000..8e2ddeb1d1 --- /dev/null +++ b/packages/react-email/src/package/img/index.ts @@ -0,0 +1 @@ +export * from './img'; diff --git a/packages/react-email/src/package/index.browser.ts b/packages/react-email/src/package/index.browser.ts new file mode 100644 index 0000000000..4db00e71c6 --- /dev/null +++ b/packages/react-email/src/package/index.browser.ts @@ -0,0 +1,20 @@ +export * from './body'; +export * from './button'; +export * from './code-block'; +export * from './code-inline'; +export * from './column'; +export * from './container'; +export * from './font'; +export * from './head'; +export * from './heading'; +export * from './hr'; +export * from './html'; +export * from './img'; +export * from './link'; +export * from './markdown'; +export * from './preview'; +export * from './render/browser'; +export * from './row'; +export * from './section'; +export * from './tailwind'; +export * from './text'; diff --git a/packages/react-email/src/package/index.node.ts b/packages/react-email/src/package/index.node.ts new file mode 100644 index 0000000000..35307aff56 --- /dev/null +++ b/packages/react-email/src/package/index.node.ts @@ -0,0 +1,20 @@ +export * from './body'; +export * from './button'; +export * from './code-block'; +export * from './code-inline'; +export * from './column'; +export * from './container'; +export * from './font'; +export * from './head'; +export * from './heading'; +export * from './hr'; +export * from './html'; +export * from './img'; +export * from './link'; +export * from './markdown'; +export * from './preview'; +export * from './render/node'; +export * from './row'; +export * from './section'; +export * from './tailwind'; +export * from './text'; diff --git a/packages/react-email/src/package/link/__snapshots__/link.spec.tsx.snap b/packages/react-email/src/package/link/__snapshots__/link.spec.tsx.snap new file mode 100644 index 0000000000..b3468b5937 --- /dev/null +++ b/packages/react-email/src/package/link/__snapshots__/link.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = `"Example"`; diff --git a/packages/react-email/src/package/link/index.ts b/packages/react-email/src/package/link/index.ts new file mode 100644 index 0000000000..e33728e03e --- /dev/null +++ b/packages/react-email/src/package/link/index.ts @@ -0,0 +1 @@ +export * from './link'; diff --git a/packages/react-email/src/package/link/link.spec.tsx b/packages/react-email/src/package/link/link.spec.tsx new file mode 100644 index 0000000000..453b0b2ed2 --- /dev/null +++ b/packages/react-email/src/package/link/link.spec.tsx @@ -0,0 +1,35 @@ +import { Link } from '.'; +import { render } from '../render/node'; + +describe(' component', () => { + it('renders children correctly', async () => { + const testMessage = 'Test message'; + const html = await render( + {testMessage}, + ); + expect(html).toContain(testMessage); + }); + + it('passes style and other props correctly', async () => { + const style = { color: 'red' }; + const html = await render( + + Test + , + ); + expect(html).toContain('color:red'); + expect(html).toContain('data-testid="link-test"'); + }); + + it('opens in a new tab', async () => { + const html = await render(Test); + expect(html).toContain(`target="_blank"`); + }); + + it('renders correctly', async () => { + const actualOutput = await render( + Example, + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/link/link.tsx b/packages/react-email/src/package/link/link.tsx new file mode 100644 index 0000000000..99b2b11420 --- /dev/null +++ b/packages/react-email/src/package/link/link.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +export type LinkProps = Readonly>; + +export const Link = React.forwardRef( + ({ target = '_blank', style, ...props }, ref) => ( + + {props.children} + + ), +); + +Link.displayName = 'Link'; diff --git a/packages/react-email/src/package/markdown/__snapshots__/markdown.spec.tsx.snap b/packages/react-email/src/package/markdown/__snapshots__/markdown.spec.tsx.snap new file mode 100644 index 0000000000..f79914132e --- /dev/null +++ b/packages/react-email/src/package/markdown/__snapshots__/markdown.spec.tsx.snap @@ -0,0 +1,50 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component renders correctly > renders links in the correct format for browsers 1`] = ` +"

Link to React-email

+
" +`; + +exports[` component renders correctly > renders lists in the correct format for browsers 1`] = ` +"

Below is a list

    +
  • Item One
  • +
  • Item Two
  • +
  • Item Three
  • +
+
" +`; + +exports[` component renders correctly > renders text in the correct format for browsers 1`] = ` +"

This is sample bold text in markdown and this is italic text

+
" +`; + +exports[` component renders correctly > renders the headers in the correct format for browsers 1`] = `"

Heading 1!

Heading 2!

Heading 3!

Heading 4!

Heading 5!
Heading 6!
"`; + +exports[` component renders correctly > renders the markdown in the correct format for browsers 1`] = ` +"

Markdown Test Document

This is a test document to check the capabilities of a Markdown parser.

+

Headings

Third-Level Heading

Fourth-Level Heading

Fifth-Level Heading
Sixth-Level Heading

Text Formatting

This is some bold text and this is some italic text. You can also use strikethrough and inline code.

+

Lists

    +
  1. Ordered List Item 1
  2. +
  3. Ordered List Item 2
  4. +
  5. Ordered List Item 3
  6. +
+
    +
  • Unordered List Item 1
  • +
  • Unordered List Item 2
  • +
  • Unordered List Item 3
  • +
+

Links

Markdown Guide

+

Images

Markdown Logo

+

Blockquotes

+

This is a blockquote.

+
    +
  • Author
  • +
+
+

Code Blocks

function greet(name) {
+console.log(\`Hello, \${name}!\`);
+}
+
+
" +`; diff --git a/packages/react-email/src/package/markdown/index.ts b/packages/react-email/src/package/markdown/index.ts new file mode 100644 index 0000000000..99334b53de --- /dev/null +++ b/packages/react-email/src/package/markdown/index.ts @@ -0,0 +1 @@ +export * from './markdown'; diff --git a/packages/react-email/src/package/markdown/markdown.spec.tsx b/packages/react-email/src/package/markdown/markdown.spec.tsx new file mode 100644 index 0000000000..1c6c389a1d --- /dev/null +++ b/packages/react-email/src/package/markdown/markdown.spec.tsx @@ -0,0 +1,114 @@ +import { Markdown } from '.'; +import { render } from '../render/node'; + +describe(' component renders correctly', () => { + it('renders the markdown in the correct format for browsers', async () => { + const actualOutput = await render( + + {`# Markdown Test Document + +This is a **test document** to check the capabilities of a Markdown parser. + +## Headings + +### Third-Level Heading + +#### Fourth-Level Heading + +##### Fifth-Level Heading + +###### Sixth-Level Heading + +## Text Formatting + +This is some **bold text** and this is some *italic text*. You can also use ~~strikethrough~~ and \`inline code\`. + +## Lists + +1. Ordered List Item 1 +2. Ordered List Item 2 +3. Ordered List Item 3 + +- Unordered List Item 1 +- Unordered List Item 2 +- Unordered List Item 3 + +## Links + +[Markdown Guide](https://www.markdownguide.org) + +## Images + +![Markdown Logo](https://markdown-here.com/img/icon256.png) + +## Blockquotes + +> This is a blockquote. +> - Author + +## Code Blocks + +\`\`\`javascript +function greet(name) { +console.log(\`Hello, $\{name}!\`); +} +\`\`\``} + , + ); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders the headers in the correct format for browsers', async () => { + const actualOutput = await render( + + {` +# Heading 1! +## Heading 2! +### Heading 3! +#### Heading 4! +##### Heading 5! +###### Heading 6! + `} + , + ); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders text in the correct format for browsers', async () => { + const actualOutput = await render( + + **This is sample bold text in markdown** and *this is italic text* + , + ); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders links in the correct format for browsers', async () => { + const actualOutput = await render( + Link to [React-email](https://react.email), + ); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders lists in the correct format for browsers', async () => { + const actualOutput = await render( + + {` +# Below is a list + +- Item One +- Item Two +- Item Three + `} + , + ); + expect(actualOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/react-email/src/package/markdown/markdown.tsx b/packages/react-email/src/package/markdown/markdown.tsx new file mode 100644 index 0000000000..3759f0f20d --- /dev/null +++ b/packages/react-email/src/package/markdown/markdown.tsx @@ -0,0 +1,33 @@ +import type { StylesType } from 'md-to-react-email'; +import { parseMarkdownToJSX } from 'md-to-react-email'; +import * as React from 'react'; + +export type MarkdownProps = Readonly<{ + children: string; + markdownCustomStyles?: StylesType; + markdownContainerStyles?: React.CSSProperties; +}>; + +export const Markdown = React.forwardRef( + ( + { children, markdownContainerStyles, markdownCustomStyles, ...props }, + ref, + ) => { + const parsedMarkdown = parseMarkdownToJSX({ + markdown: children, + customStyles: markdownCustomStyles, + }); + + return ( +
+ ); + }, +); + +Markdown.displayName = 'Markdown'; diff --git a/packages/react-email/src/package/preview/__snapshots__/preview.spec.tsx.snap b/packages/react-email/src/package/preview/__snapshots__/preview.spec.tsx.snap new file mode 100644 index 0000000000..7d97758230 --- /dev/null +++ b/packages/react-email/src/package/preview/__snapshots__/preview.spec.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` component > renders correctly 1`] = `"
Email preview text
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
"`; + +exports[` component > renders correctly with array text 1`] = `"
Email preview text
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏
"`; + +exports[` component > renders correctly with really long text 1`] = `"
really longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally longreally
"`; diff --git a/packages/react-email/src/package/preview/index.ts b/packages/react-email/src/package/preview/index.ts new file mode 100644 index 0000000000..45799160cd --- /dev/null +++ b/packages/react-email/src/package/preview/index.ts @@ -0,0 +1 @@ +export * from './preview'; diff --git a/packages/react-email/src/package/preview/preview.spec.tsx b/packages/react-email/src/package/preview/preview.spec.tsx new file mode 100644 index 0000000000..237e6a1a56 --- /dev/null +++ b/packages/react-email/src/package/preview/preview.spec.tsx @@ -0,0 +1,45 @@ +import { Preview, renderWhiteSpace } from '.'; +/* eslint-disable no-irregular-whitespace */ +import { render } from '../render/node'; + +describe(' component', () => { + it('renders correctly', async () => { + const actualOutput = await render(Email preview text); + expect(actualOutput).toMatchSnapshot(); + }); + + it('renders correctly with array text', async () => { + const actualOutputArray = await render( + Email preview text, + ); + expect(actualOutputArray).toMatchSnapshot(); + }); + + it('renders correctly with really long text', async () => { + const longText = 'really long'.repeat(100); + const actualOutputLong = await render({longText}); + expect(actualOutputLong).toMatchSnapshot(); + }); +}); + +describe('renderWhiteSpace', () => { + it('renders null when text length is greater than or equal to PREVIEW_MAX_LENGTH (150)', () => { + const text = + 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Tenetur dolore mollitia dignissimos itaque. At excepturi reiciendis iure molestias incidunt. Ab saepe, nostrum dicta dolor maiores tenetur eveniet odio amet ipsum?'; + const html = renderWhiteSpace(text); + expect(html).toBeNull(); + }); + + it('renders white space characters when text length is less than PREVIEW_MAX_LENGTH', () => { + const text = 'Short text'; + const whiteSpaceCharacters = '\xa0\u200C\u200B\u200D\u200E\u200F\uFEFF'; + + const html = renderWhiteSpace(text); + expect(html).not.toBeNull(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const actualTextContent = html?.props.children; + const expectedTextContent = whiteSpaceCharacters.repeat(150 - text.length); + expect(actualTextContent).toBe(expectedTextContent); + }); +}); diff --git a/packages/react-email/src/package/preview/preview.tsx b/packages/react-email/src/package/preview/preview.tsx new file mode 100644 index 0000000000..bcd57a9ec7 --- /dev/null +++ b/packages/react-email/src/package/preview/preview.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +export type PreviewProps = Readonly< + React.ComponentPropsWithoutRef<'div'> & { + children: string | string[]; + } +>; + +const PREVIEW_MAX_LENGTH = 150; + +export const Preview = React.forwardRef( + ({ children = '', ...props }, ref) => { + const text = ( + Array.isArray(children) ? children.join('') : children + ).substring(0, PREVIEW_MAX_LENGTH); + + return ( +
+ {text} + {renderWhiteSpace(text)} +
+ ); + }, +); + +Preview.displayName = 'Preview'; + +const whiteSpaceCodes = '\xa0\u200C\u200B\u200D\u200E\u200F\uFEFF'; +export const renderWhiteSpace = (text: string) => { + if (text.length >= PREVIEW_MAX_LENGTH) { + return null; + } + + return
{whiteSpaceCodes.repeat(PREVIEW_MAX_LENGTH - text.length)}
; +}; diff --git a/packages/react-email/src/package/render/browser/__snapshots__/render-async-web.spec.tsx.snap b/packages/react-email/src/package/render/browser/__snapshots__/render-async-web.spec.tsx.snap new file mode 100644 index 0000000000..873fa93880 --- /dev/null +++ b/packages/react-email/src/package/render/browser/__snapshots__/render-async-web.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderAsync on the browser environment > should handle characters with a higher byte count gracefully 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/react-email/src/package/render/browser/__snapshots__/render-web.spec.tsx.snap b/packages/react-email/src/package/render/browser/__snapshots__/render-web.spec.tsx.snap new file mode 100644 index 0000000000..2a39925b95 --- /dev/null +++ b/packages/react-email/src/package/render/browser/__snapshots__/render-web.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render on the browser environment > should handle characters with a higher byte count gracefully 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/react-email/src/package/render/browser/index.ts b/packages/react-email/src/package/render/browser/index.ts new file mode 100644 index 0000000000..921da737d1 --- /dev/null +++ b/packages/react-email/src/package/render/browser/index.ts @@ -0,0 +1,5 @@ +export * from './render'; +export * from './render-async'; + +export * from '../shared/options'; +export * from '../shared/plain-text-selectors'; diff --git a/packages/react-email/src/package/render/browser/read-stream.ts b/packages/react-email/src/package/render/browser/read-stream.ts new file mode 100644 index 0000000000..20a84c4fed --- /dev/null +++ b/packages/react-email/src/package/render/browser/read-stream.ts @@ -0,0 +1,44 @@ +import type { + PipeableStream, + ReactDOMServerReadableStream, +} from 'react-dom/server.browser'; + +const decoder = new TextDecoder('utf-8'); + +export const readStream = async ( + stream: PipeableStream | ReactDOMServerReadableStream, +) => { + const chunks: Uint8Array[] = []; + + if ('pipeTo' in stream) { + // means it's a readable stream + const writableStream = new WritableStream({ + write(chunk: Uint8Array) { + chunks.push(chunk); + }, + }); + await stream.pipeTo(writableStream); + } else { + throw new Error( + 'For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.', + { + cause: { + stream, + }, + }, + ); + } + + let length = 0; + chunks.forEach((item) => { + length += item.length; + }); + const mergedChunks = new Uint8Array(length); + let offset = 0; + chunks.forEach((item) => { + mergedChunks.set(item, offset); + offset += item.length; + }); + + return decoder.decode(mergedChunks); +}; diff --git a/packages/react-email/src/package/render/browser/render-async-web.spec.tsx b/packages/react-email/src/package/render/browser/render-async-web.spec.tsx new file mode 100644 index 0000000000..7e8b82aa4d --- /dev/null +++ b/packages/react-email/src/package/render/browser/render-async-web.spec.tsx @@ -0,0 +1,125 @@ +/** + * @vitest-environment jsdom + */ + +import { Preview } from '../shared/utils/preview'; +import { Template } from '../shared/utils/template'; +import { renderAsync } from './render-async'; + +type Import = typeof import('react-dom/server') & { + default: typeof import('react-dom/server'); +}; + +describe('renderAsync on the browser environment', () => { + beforeEach(() => { + vi.mock( + 'react-dom/server', + (_importOriginal) => import('react-dom/server.browser'), + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('converts a React component into HTML with Next 14 error stubs', async () => { + vi.mock('react-dom/server', async (_importOriginal) => { + const ReactDOMServerBrowser = await vi.importActual( + 'react-dom/server.browser', + ); + const ERROR_MESSAGE = + 'Internal Error: do not use legacy react-dom/server APIs. If you encountered this error, please open an issue on the Next.js repo.'; + + return { + ...ReactDOMServerBrowser, + default: { + ...ReactDOMServerBrowser, + renderToString() { + throw new Error(ERROR_MESSAGE); + }, + renderToStaticMarkup() { + throw new Error(ERROR_MESSAGE); + }, + }, + renderToString() { + throw new Error(ERROR_MESSAGE); + }, + renderToStaticMarkup() { + throw new Error(ERROR_MESSAGE); + }, + }; + }); + + const actualOutput = await renderAsync(