From fc2f4076aed384c12b554e691d5c5ff9e6941663 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Wed, 6 Aug 2025 14:41:17 -0300 Subject: [PATCH] feat(render): toPlainText function to convert existing HTML to plain text (#2412) --- .changeset/light-rice-strive.md | 5 +++++ .changeset/thick-friends-show.md | 5 +++++ packages/render/src/browser/index.ts | 2 +- .../render/src/browser/render-web.spec.tsx | 4 ++-- packages/render/src/browser/render.tsx | 9 ++------ packages/render/src/node/index.ts | 2 +- packages/render/src/node/render-edge.spec.tsx | 4 ++-- packages/render/src/node/render-node.spec.tsx | 4 ++-- packages/render/src/node/render.tsx | 8 ++----- packages/render/src/shared/options.ts | 7 ++++++ .../render/src/shared/plain-text-selectors.ts | 10 --------- .../render/src/shared/utils/pretty.spec.ts | 2 +- .../shared/utils/{ => testing}/preview.tsx | 0 .../utils/{ => testing}/stripe-email.html | Bin .../shared/utils/{ => testing}/template.tsx | 0 .../render/src/shared/utils/to-plain-text.ts | 21 ++++++++++++++++++ 16 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 .changeset/light-rice-strive.md create mode 100644 .changeset/thick-friends-show.md delete mode 100644 packages/render/src/shared/plain-text-selectors.ts rename packages/render/src/shared/utils/{ => testing}/preview.tsx (100%) rename packages/render/src/shared/utils/{ => testing}/stripe-email.html (100%) rename packages/render/src/shared/utils/{ => testing}/template.tsx (100%) create mode 100644 packages/render/src/shared/utils/to-plain-text.ts diff --git a/.changeset/light-rice-strive.md b/.changeset/light-rice-strive.md new file mode 100644 index 000000000..88a12d25a --- /dev/null +++ b/.changeset/light-rice-strive.md @@ -0,0 +1,5 @@ +--- +"@react-email/render": minor +--- + +add toPlainText utility and deprecate plainText option on `render` diff --git a/.changeset/thick-friends-show.md b/.changeset/thick-friends-show.md new file mode 100644 index 000000000..2e04ba1d4 --- /dev/null +++ b/.changeset/thick-friends-show.md @@ -0,0 +1,5 @@ +--- +"@react-email/components": minor +--- + +add toPlainText utility and deprecate plainText option on `render` diff --git a/packages/render/src/browser/index.ts b/packages/render/src/browser/index.ts index 3d930a1cf..fccaf8ffd 100644 --- a/packages/render/src/browser/index.ts +++ b/packages/render/src/browser/index.ts @@ -9,6 +9,6 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => { }; export * from '../shared/options'; -export * from '../shared/plain-text-selectors'; export * from '../shared/utils/pretty'; +export * from '../shared/utils/to-plain-text'; export * from './render'; diff --git a/packages/render/src/browser/render-web.spec.tsx b/packages/render/src/browser/render-web.spec.tsx index be8c1b4f3..fef282636 100644 --- a/packages/render/src/browser/render-web.spec.tsx +++ b/packages/render/src/browser/render-web.spec.tsx @@ -4,8 +4,8 @@ import { createElement } from 'react'; import usePromise from 'react-promise-suspense'; -import { Preview } from '../shared/utils/preview'; -import { Template } from '../shared/utils/template'; +import { Preview } from '../shared/utils/testing/preview'; +import { Template } from '../shared/utils/testing/template'; import { render } from './render'; type Import = typeof import('react-dom/server') & { diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index 118b6055a..f318e3637 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -1,9 +1,7 @@ -import { convert } from 'html-to-text'; import { Suspense } from 'react'; import type { ReactDOMServerReadableStream } from 'react-dom/server'; -import { pretty } from '../node'; +import { pretty, toPlainText } from '../node'; import type { Options } from '../shared/options'; -import { plainTextSelectors } from '../shared/plain-text-selectors'; const decoder = new TextDecoder('utf-8'); @@ -59,10 +57,7 @@ export const render = async (node: React.ReactNode, options?: Options) => { }); if (options?.plainText) { - return convert(html, { - selectors: plainTextSelectors, - ...options.htmlToTextOptions, - }); + return toPlainText(html, options.htmlToTextOptions); } const doctype = diff --git a/packages/render/src/node/index.ts b/packages/render/src/node/index.ts index 3d930a1cf..fccaf8ffd 100644 --- a/packages/render/src/node/index.ts +++ b/packages/render/src/node/index.ts @@ -9,6 +9,6 @@ export const renderAsync = (element: React.ReactElement, options?: Options) => { }; export * from '../shared/options'; -export * from '../shared/plain-text-selectors'; export * from '../shared/utils/pretty'; +export * from '../shared/utils/to-plain-text'; export * from './render'; diff --git a/packages/render/src/node/render-edge.spec.tsx b/packages/render/src/node/render-edge.spec.tsx index b0484c5c8..b3affd123 100644 --- a/packages/render/src/node/render-edge.spec.tsx +++ b/packages/render/src/node/render-edge.spec.tsx @@ -1,5 +1,5 @@ -import { Preview } from '../shared/utils/preview'; -import { Template } from '../shared/utils/template'; +import { Preview } from '../shared/utils/testing/preview'; +import { Template } from '../shared/utils/testing/template'; import { render } from './render'; type Import = typeof import('react-dom/server') & { diff --git a/packages/render/src/node/render-node.spec.tsx b/packages/render/src/node/render-node.spec.tsx index 46f551e1c..4a72e1eb8 100644 --- a/packages/render/src/node/render-node.spec.tsx +++ b/packages/render/src/node/render-node.spec.tsx @@ -4,8 +4,8 @@ import { Suspense } from 'react'; import usePromise from 'react-promise-suspense'; -import { Preview } from '../shared/utils/preview'; -import { Template } from '../shared/utils/template'; +import { Preview } from '../shared/utils/testing/preview'; +import { Template } from '../shared/utils/testing/template'; import { render } from './render'; type Import = typeof import('react-dom/server') & { diff --git a/packages/render/src/node/render.tsx b/packages/render/src/node/render.tsx index a8adf41b4..6d0a8b943 100644 --- a/packages/render/src/node/render.tsx +++ b/packages/render/src/node/render.tsx @@ -1,8 +1,7 @@ -import { convert } from 'html-to-text'; import { Suspense } from 'react'; import type { Options } from '../shared/options'; -import { plainTextSelectors } from '../shared/plain-text-selectors'; import { pretty } from '../shared/utils/pretty'; +import { toPlainText } from '../shared/utils/to-plain-text'; import { readStream } from './read-stream'; export const render = async (node: React.ReactNode, options?: Options) => { @@ -35,10 +34,7 @@ export const render = async (node: React.ReactNode, options?: Options) => { } if (options?.plainText) { - return convert(html, { - selectors: plainTextSelectors, - ...options.htmlToTextOptions, - }); + return toPlainText(html, options.htmlToTextOptions); } const doctype = diff --git a/packages/render/src/shared/options.ts b/packages/render/src/shared/options.ts index ddc36b352..3cc9b672b 100644 --- a/packages/render/src/shared/options.ts +++ b/packages/render/src/shared/options.ts @@ -1,5 +1,6 @@ import type { HtmlToTextOptions } from 'html-to-text'; import type { pretty } from './utils/pretty'; +import type { toPlainText } from './utils/to-plain-text'; export type Options = { /** @@ -8,9 +9,15 @@ export type Options = { pretty?: boolean; } & ( | { + /** + * @deprecated use {@link toPlainText} instead + */ plainText?: false; } | { + /** + * @deprecated use {@link toPlainText} instead + */ plainText?: true; /** * These are options you can pass down directly to the library we use for diff --git a/packages/render/src/shared/plain-text-selectors.ts b/packages/render/src/shared/plain-text-selectors.ts deleted file mode 100644 index 90b8ce871..000000000 --- a/packages/render/src/shared/plain-text-selectors.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SelectorDefinition } from 'html-to-text'; - -export const plainTextSelectors: SelectorDefinition[] = [ - { selector: 'img', format: 'skip' }, - { selector: '[data-skip-in-text=true]', format: 'skip' }, - { - selector: 'a', - options: { linkBrackets: false }, - }, -]; diff --git a/packages/render/src/shared/utils/pretty.spec.ts b/packages/render/src/shared/utils/pretty.spec.ts index 1b79ca970..6cd8d72da 100644 --- a/packages/render/src/shared/utils/pretty.spec.ts +++ b/packages/render/src/shared/utils/pretty.spec.ts @@ -5,7 +5,7 @@ import { pretty } from './pretty'; describe('pretty', () => { it("should prettify Preview component's complex characters correctly", async () => { const stripeHTML = await fs.readFile( - path.resolve(__dirname, './stripe-email.html'), + path.resolve(__dirname, './testing/stripe-email.html'), 'utf8', ); diff --git a/packages/render/src/shared/utils/preview.tsx b/packages/render/src/shared/utils/testing/preview.tsx similarity index 100% rename from packages/render/src/shared/utils/preview.tsx rename to packages/render/src/shared/utils/testing/preview.tsx diff --git a/packages/render/src/shared/utils/stripe-email.html b/packages/render/src/shared/utils/testing/stripe-email.html similarity index 100% rename from packages/render/src/shared/utils/stripe-email.html rename to packages/render/src/shared/utils/testing/stripe-email.html diff --git a/packages/render/src/shared/utils/template.tsx b/packages/render/src/shared/utils/testing/template.tsx similarity index 100% rename from packages/render/src/shared/utils/template.tsx rename to packages/render/src/shared/utils/testing/template.tsx diff --git a/packages/render/src/shared/utils/to-plain-text.ts b/packages/render/src/shared/utils/to-plain-text.ts new file mode 100644 index 000000000..349947800 --- /dev/null +++ b/packages/render/src/shared/utils/to-plain-text.ts @@ -0,0 +1,21 @@ +import { + convert, + type HtmlToTextOptions, + type SelectorDefinition, +} from 'html-to-text'; + +export const plainTextSelectors: SelectorDefinition[] = [ + { selector: 'img', format: 'skip' }, + { selector: '[data-skip-in-text=true]', format: 'skip' }, + { + selector: 'a', + options: { linkBrackets: false }, + }, +]; + +export function toPlainText(html: string, options?: HtmlToTextOptions) { + return convert(html, { + selectors: plainTextSelectors, + ...options, + }); +}