From a3c2702c7412d808af23cf9e57ffd0834f8c0745 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Mar 2026 19:58:29 +0000 Subject: [PATCH 1/4] feat(web): copy HTML snippet instead of full document for snippet components For all existing components (which are snippets), the HTML copy now shows just the inner HTML fragment instead of the full email document. The full document HTML (with Layout wrapper) is kept for preview iframes and the Send feature. For future full-document components, marking type as 'document' on the Component interface will preserve the existing full-HTML-document behavior. Changes: - Add optional type field ('snippet' | 'document') to Component interface - Generate htmlSnippet by rendering element without Layout wrapper and stripping the DOCTYPE declaration and React hydration markers - ComponentCodeView uses htmlSnippet when available (snippet components), falls back to full html for document components Co-authored-by: Gabriel Miranda --- apps/web/components/structure.ts | 6 +++ .../get-imported-components-for.tsx | 53 +++++++++++++++---- .../src/components/component-code-view.tsx | 2 + 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/components/structure.ts b/apps/web/components/structure.ts index 1637299307..0404cbe309 100644 --- a/apps/web/components/structure.ts +++ b/apps/web/components/structure.ts @@ -11,6 +11,12 @@ export interface Category { export interface Component { slug: string; title: string; + /** + * Snippet components render only the inner HTML fragment when copying HTML. + * Document components render the full HTML email document. + * Defaults to 'snippet'. + */ + type?: 'snippet' | 'document'; } export const getComponentPathFromSlug = (slug: string) => { diff --git a/apps/web/src/app/components/get-imported-components-for.tsx b/apps/web/src/app/components/get-imported-components-for.tsx index ab40d03e6b..7599ad6852 100644 --- a/apps/web/src/app/components/get-imported-components-for.tsx +++ b/apps/web/src/app/components/get-imported-components-for.tsx @@ -19,7 +19,16 @@ import { export type CodeVariant = 'tailwind' | 'inline-styles' | 'react' | 'html'; export interface ImportedComponent extends Component { - code: Partial> & { html: string }; + code: Partial> & { + html: string; + /** + * Present for snippet components (type !== 'document'). + * Contains only the inner HTML fragment without the full document wrapper, + * suitable for copy-pasting into an existing email template. + * Document components use `html` which contains the full HTML email document. + */ + htmlSnippet?: string; + }; } const ComponentModule = z.object({ @@ -60,6 +69,17 @@ const getComponentCodeFrom = (fileContent: string): string => { .join('\n'); }; +/** + * Extracts the inner HTML fragment from a full rendered HTML document, + * stripping the DOCTYPE declaration and React hydration markers. + */ +const extractHtmlSnippet = (fullHtml: string): string => { + return fullHtml + .replace(/]*>\s*/i, '') + .replace(/|||/g, '') + .trim(); +}; + export const getComponentElement = async ( filepath: string, ): Promise => { @@ -80,20 +100,28 @@ export const getImportedComponent = async ( ): Promise => { const dirpath = getComponentPathFromSlug(component.slug); const variantFilenames = await fs.readdir(dirpath); + const isDocument = component.type === 'document'; if (variantFilenames.length === 1 && variantFilenames[0] === 'index.tsx') { const filePath = path.join(dirpath, 'index.tsx'); - const element = {await getComponentElement(filePath)}; - const html = await pretty(await render(element)); + const componentElement = await getComponentElement(filePath); + const layoutElement = {componentElement}; + const html = await pretty(await render(layoutElement)); const fileContent = await fs.readFile(filePath, 'utf8'); const code = getComponentCodeFrom(fileContent); - return { + + const result: ImportedComponent = { ...component, - code: { - react: code, - html, - }, + code: { react: code, html }, }; + + if (!isDocument) { + result.code.htmlSnippet = extractHtmlSnippet( + await pretty(await render(componentElement)), + ); + } + + return result; } const codePerVariant: ImportedComponent['code'] = { html: '' }; @@ -117,9 +145,14 @@ export const getImportedComponent = async ( codePerVariant[variantKey] = getComponentCodeFrom(fileContents[index]); }); - const element = {elements[0]}; + const layoutElement = {elements[0]}; + codePerVariant.html = await pretty(await render(layoutElement)); - codePerVariant.html = await pretty(await render(element)); + if (!isDocument) { + codePerVariant.htmlSnippet = extractHtmlSnippet( + await pretty(await render(elements[0])), + ); + } return { ...component, diff --git a/apps/web/src/components/component-code-view.tsx b/apps/web/src/components/component-code-view.tsx index be81c90d65..7c9ceaff7c 100644 --- a/apps/web/src/components/component-code-view.tsx +++ b/apps/web/src/components/component-code-view.tsx @@ -41,6 +41,8 @@ export function ComponentCodeView({ } else if (component.code.react) { code = component.code.react; } + } else if (component.code.htmlSnippet !== undefined) { + code = component.code.htmlSnippet; } else { code = code.replace(/height\s*:\s*100vh;?/, ''); } From a68ded40be9d671e1e0513f529126c51ced206ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Mar 2026 20:01:36 +0000 Subject: [PATCH 2/4] fix(web): strip all HTML comments in extractHtmlSnippet to satisfy CodeQL Replace targeted React marker regexes with a single pattern that removes all HTML comments. This comprehensively handles React hydration markers ( and ). */ const extractHtmlSnippet = (fullHtml: string): string => { return fullHtml .replace(/]*>\s*/i, '') - .replace(/|||/g, '') + .replace(//g, '') .trim(); }; From a54aeab4a04c8ee633868300b702cd3cdbf0b254 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Mar 2026 20:07:12 +0000 Subject: [PATCH 3/4] fix(web): use renderToStaticMarkup for snippet HTML to resolve CodeQL alert Replace the extractHtmlSnippet regex-stripping approach with React's renderToStaticMarkup, which produces pure static HTML with no DOCTYPE and no React hydration markers (, etc.). This eliminates the need to strip HTML comments entirely, removing the CodeQL 'Incomplete multi-character sanitization' alert at the root. Co-authored-by: Gabriel Miranda --- .../get-imported-components-for.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/components/get-imported-components-for.tsx b/apps/web/src/app/components/get-imported-components-for.tsx index 1af2983f91..e9e789466d 100644 --- a/apps/web/src/app/components/get-imported-components-for.tsx +++ b/apps/web/src/app/components/get-imported-components-for.tsx @@ -3,6 +3,7 @@ import path from 'node:path'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import { pretty, render } from '@react-email/components'; +import { renderToStaticMarkup } from 'react-dom/server'; import { z } from 'zod'; import { Layout } from '../../../components/_components/layout'; import type { Category, Component } from '../../../components/structure'; @@ -70,15 +71,14 @@ const getComponentCodeFrom = (fileContent: string): string => { }; /** - * Extracts the inner HTML fragment from a full rendered HTML document, - * stripping the DOCTYPE declaration and all HTML comments (including - * React hydration markers such as and ). + * Renders a React element to a clean HTML snippet using renderToStaticMarkup, + * which produces pure HTML with no DOCTYPE declaration and no React hydration + * markers — nothing to strip, no regex sanitization needed. */ -const extractHtmlSnippet = (fullHtml: string): string => { - return fullHtml - .replace(/]*>\s*/i, '') - .replace(//g, '') - .trim(); +const renderSnippetHtml = async ( + element: React.ReactElement, +): Promise => { + return pretty(renderToStaticMarkup(element)); }; export const getComponentElement = async ( @@ -117,9 +117,7 @@ export const getImportedComponent = async ( }; if (!isDocument) { - result.code.htmlSnippet = extractHtmlSnippet( - await pretty(await render(componentElement)), - ); + result.code.htmlSnippet = await renderSnippetHtml(componentElement); } return result; @@ -150,9 +148,7 @@ export const getImportedComponent = async ( codePerVariant.html = await pretty(await render(layoutElement)); if (!isDocument) { - codePerVariant.htmlSnippet = extractHtmlSnippet( - await pretty(await render(elements[0])), - ); + codePerVariant.htmlSnippet = await renderSnippetHtml(elements[0]); } return { From 0639ee124ad285965041c4a04fd19eaaa4ce9cba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 16 Mar 2026 20:25:07 +0000 Subject: [PATCH 4/4] fix(web): dynamic import react-dom/server and handle body-wrapped snippets Two fixes: 1. Build failure: Next.js App Router forbids static imports of react-dom/server in Server Components. Switch to a dynamic import (the same pattern used internally by @react-email/render) so the bundler does not flag it. 2. Review: if a snippet component happens to wrap its own output in /, extract the inner body content so the copied HTML stays an embeddable fragment rather than a full document. Co-authored-by: Gabriel Miranda --- .../components/get-imported-components-for.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/components/get-imported-components-for.tsx b/apps/web/src/app/components/get-imported-components-for.tsx index e9e789466d..0c889b2442 100644 --- a/apps/web/src/app/components/get-imported-components-for.tsx +++ b/apps/web/src/app/components/get-imported-components-for.tsx @@ -3,7 +3,6 @@ import path from 'node:path'; import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import { pretty, render } from '@react-email/components'; -import { renderToStaticMarkup } from 'react-dom/server'; import { z } from 'zod'; import { Layout } from '../../../components/_components/layout'; import type { Category, Component } from '../../../components/structure'; @@ -71,14 +70,23 @@ const getComponentCodeFrom = (fileContent: string): string => { }; /** - * Renders a React element to a clean HTML snippet using renderToStaticMarkup, - * which produces pure HTML with no DOCTYPE declaration and no React hydration - * markers — nothing to strip, no regex sanitization needed. + * Renders a React element to a clean HTML snippet. + * + * Uses a dynamic import of renderToStaticMarkup (same pattern as + * @react-email/render) to avoid Next.js App Router's restriction on + * static react-dom/server imports in Server Components. + * + * If the element renders a full document (containing a tag, e.g. + * when a component wraps itself in /), only the body content + * is returned so the snippet stays usable as an embeddable fragment. */ const renderSnippetHtml = async ( element: React.ReactElement, ): Promise => { - return pretty(renderToStaticMarkup(element)); + const { renderToStaticMarkup } = await import('react-dom/server'); + const html = renderToStaticMarkup(element); + const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); + return pretty((bodyMatch?.[1] ?? html).trim()); }; export const getComponentElement = async (