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..0c889b2442 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,26 @@ const getComponentCodeFrom = (fileContent: string): string => { .join('\n'); }; +/** + * 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 => { + 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 ( filepath: string, ): Promise => { @@ -80,20 +109,26 @@ 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 = await renderSnippetHtml(componentElement); + } + + return result; } const codePerVariant: ImportedComponent['code'] = { html: '' }; @@ -117,9 +152,12 @@ 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 = await renderSnippetHtml(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;?/, ''); }