Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/components/structure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
50 changes: 40 additions & 10 deletions apps/web/src/app/components/get-imported-components-for.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,7 +20,16 @@ import {
export type CodeVariant = 'tailwind' | 'inline-styles' | 'react' | 'html';

export interface ImportedComponent extends Component {
code: Partial<Record<CodeVariant, string>> & { html: string };
code: Partial<Record<CodeVariant, string>> & {
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({
Expand Down Expand Up @@ -60,6 +70,17 @@ const getComponentCodeFrom = (fileContent: string): string => {
.join('\n');
};

/**
* 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 renderSnippetHtml = async (
element: React.ReactElement,
): Promise<string> => {
return pretty(renderToStaticMarkup(element));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
};

export const getComponentElement = async (
filepath: string,
): Promise<React.ReactElement> => {
Expand All @@ -80,20 +101,26 @@ export const getImportedComponent = async (
): Promise<ImportedComponent> => {
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 = <Layout>{await getComponentElement(filePath)}</Layout>;
const html = await pretty(await render(element));
const componentElement = await getComponentElement(filePath);
const layoutElement = <Layout>{componentElement}</Layout>;
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: '' };
Expand All @@ -117,9 +144,12 @@ export const getImportedComponent = async (
codePerVariant[variantKey] = getComponentCodeFrom(fileContents[index]);
});

const element = <Layout>{elements[0]}</Layout>;
const layoutElement = <Layout>{elements[0]}</Layout>;
codePerVariant.html = await pretty(await render(layoutElement));

codePerVariant.html = await pretty(await render(element));
if (!isDocument) {
codePerVariant.htmlSnippet = await renderSnippetHtml(elements[0]);
}

return {
...component,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/component-code-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;?/, '');
}
Expand Down
Loading