Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

All notable changes to this project will be documented in this file.

## Unreleased

### Changed

- Browser-condition imports from `@akin01/solid-email` now resolve to the
DOM/CSR preview build, replacing the public `@akin01/solid-email/client`
subpath while keeping server, Workerd, and default imports on the SSR/email
rendering build.
- Browser-condition `require('@akin01/solid-email')` now resolves to the
DOM/CSR preview CJS build.
- Default/root ESM and CJS server entries now share Solid's server runtime for
Tailwind resource rendering, preventing CJS `Tailwind` renders from splitting
Solid SSR context.
- Tailwind's CSS Tree usage now imports the exported browser/dist ESM bundle to
avoid Node `createRequire`/`mdn-data` JSON loading in Workerd bundles.

### Related commits

- Workerd-safe root exports and rendering
- [`691ed9560834`](https://github.com/Akin01/solid-email/commit/691ed95608341c664c8d80335e001faea667cf9a) Added Workerd-safe root export conditions, server/runtime fixes, and Cloudflare TanStack Start coverage.
- [`9c6bb421f37b`](https://github.com/Akin01/solid-email/commit/9c6bb421f37b427bc9f05305a3bac63a886d4857) Added the TypeScript shim for CSS Tree's dist ESM entry used by Workerd-safe Tailwind imports.
- Documentation and bundle metrics
- [`4a38f8e2af11`](https://github.com/Akin01/solid-email/commit/4a38f8e2af117d53a49535844c78e5a184719a40) Updated browser-root documentation, Solid Email skill guidance, and recomputed bundle-size comparisons.

### Verified

- `pnpm test`
- `pnpm test:e2e`
- `pnpm typecheck`
- `pnpm build`
- `pnpm lint`

## 0.1.4 - 2026-06-26

### Changed
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,12 @@ Bundle size compares built ESM entry files after `pnpm build`; gzip uses Node's

| Package entry | Raw size | Gzip size | Comparison |
| --- | ---: | ---: | --- |
| `@akin01/solid-email/dist/index.mjs` | 198.7 KiB | 42.3 KiB | Components entry |
| `@solid-email/render/dist/node/index.mjs` | 13.3 KiB | 3.7 KiB | Renderer entry |
| Solid Email combined entries | 211.9 KiB | 46.0 KiB | 6.8x smaller raw / 7.5x smaller gzip than React Email |
| `react-email/dist/index.mjs` | 1,448.0 KiB | 347.4 KiB | React Email baseline |
| `@akin01/solid-email/dist/index.mjs` | 199.0 KiB | 42.7 KiB | Server/root components and render utility re-exports |
| `@akin01/solid-email/dist/client/index.mjs` | 105.9 KiB | 19.5 KiB | Browser-condition DOM preview build |
| `@solid-email/render/dist/node/index.mjs` | 26.3 KiB | 6.2 KiB | Renderer entry |
| Solid Email server entries | 225.3 KiB | 48.9 KiB | 6.4x smaller raw / 7.1x smaller gzip than React Email |
| Solid Email all ESM condition entries | 331.2 KiB | 68.4 KiB | 4.4x smaller raw / 5.1x smaller gzip than React Email |
| `react-email/dist/index.mjs` | 1,448.0 KiB | 348.6 KiB | React Email baseline |

## Install

Expand Down Expand Up @@ -112,14 +114,14 @@ const html = renderSync(() => <WelcomeEmail />);

## Entrypoints

`@akin01/solid-email` is the SSR/email-rendering entrypoint. It keeps `render`,
`compile`, and all email components available even under browser-like import
conditions, so browser code can still produce email HTML strings.

`@akin01/solid-email/client` is the opt-in DOM/CSR preview entrypoint. It exports
DOM-safe preview components and intentionally excludes `render`, `compile`, and
`@akin01/solid-email` is conditionally exported. Server, Workerd, and default
imports expose `render`, `compile`, and the full email component set, including
`Tailwind`.

Browser-condition imports of the same package root resolve to the DOM/CSR
preview build. That build exports DOM-safe preview components and intentionally
excludes `render`, `compile`, and `Tailwind`.

## Compile for repeated renders

When you render the same template multiple times with different data, `compile()` pre-evaluates the Solid components once and reuses the cached HTML on each render.
Expand Down
26 changes: 26 additions & 0 deletions e2e/cloudflare-tanstack-start/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "solid-email-cloudflare-tanstack-start-e2e",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@solid-email/html-to-text": "file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz",
"@solid-email/render": "file:../.tmp/packs/solid-email-render-0.1.4.tgz",
"@akin01/solid-email": "file:../.tmp/packs/akin01-solid-email-0.1.4.tgz",
"solid-js": "1.9.13"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.43.0",
"@tanstack/solid-router": "1.170.16",
"@tanstack/solid-start": "1.168.26",
"@types/node": "25.0.2",
"typescript": "6.0.3",
"vite": "7.3.1",
"vite-plugin-solid": "2.11.12",
"wrangler": "4.107.0"
}
}
9 changes: 9 additions & 0 deletions e2e/cloudflare-tanstack-start/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
overrides:
'@akin01/solid-email': file:../.tmp/packs/akin01-solid-email-0.1.4.tgz
'@solid-email/html-to-text': file:../.tmp/packs/solid-email-html-to-text-0.1.1.tgz
'@solid-email/render': file:../.tmp/packs/solid-email-render-0.1.4.tgz

allowBuilds:
esbuild: true
sharp: true
workerd: true
6 changes: 6 additions & 0 deletions e2e/cloudflare-tanstack-start/src/email.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createServerFn } from '@tanstack/solid-start';
import { renderCloudflareEmailReport } from './email.server';

export const renderCloudflareEmailReportFn = createServerFn({
method: 'GET',
}).handler(() => renderCloudflareEmailReport());
205 changes: 205 additions & 0 deletions e2e/cloudflare-tanstack-start/src/email.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import {
Body,
Button,
CodeBlock,
CodeInline,
Column,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Markdown,
Preview,
Row,
Section,
Tailwind,
Text,
xonokai,
} from '@akin01/solid-email';
import { compile, render, Slot, slot } from '@solid-email/render';
import type { JSX } from 'solid-js';

type WorkerEmailSlots = {
ctaUrl: string;
recipient: string;
routeName: string;
};

const markdownContent = `# Markdown heading

Paragraph with **bold text**, _italic text_, ~~removed text~~, and [a markdown link](https://example.com/markdown).

> Quoted markdown block

1. First ordered item
2. Second ordered item

- First unordered item
- Second unordered item

| Name | Value |
| ---- | ----- |
| Component | Markdown |

![Markdown image](https://example.com/markdown.png "Markdown image title")

\`inline markdown code\`

\`\`\`ts
const answer = 42;
\`\`\`
`;

export type CloudflareEmailReport = {
html: string;
plainText: string;
status: 'rendered';
};

function WorkerRouteEmail(): JSX.Element {
return (
<Html lang="en" dir="ltr">
<Head>
<Font
fallbackFontFamily={['Arial', 'sans-serif']}
fontFamily="InterFixture"
fontStyle="normal"
fontWeight={400}
webFont={{
format: 'woff2',
url: 'https://example.com/inter-fixture.woff2',
}}
/>
</Head>
<Preview>All components e2e preview</Preview>
<Body
style={{
'background-color': '#f6f9fc',
margin: '0',
padding: '24px',
}}
>
<Tailwind>
<Container class="mx-auto bg-white p-4" data-e2e="container">
<Section
class="rounded-lg border border-solid border-slate-200"
style={{ padding: '16px' }}
>
<Heading
as="h1"
class="text-xl font-bold text-slate-900"
mb="8px"
>
Comprehensive components e2e
</Heading>
<Text
class="text-sm text-blue-600"
style={{ margin: '0 0 12px' }}
>
Cloudflare TanStack Solid email rendered
</Text>
<Text>
Hello <Slot name="recipient" />!
</Text>
<Text>
Loaded from <Slot name="routeName" /> while running in Workerd.
</Text>
<Hr style={{ 'border-color': '#e2e8f0', margin: '16px 0' }} />
<Row>
<Column class="w-1/2" data-e2e="left-column">
<Img
alt="Solid Email fixture logo"
height="40"
src="https://example.com/logo.png"
width="120"
/>
</Column>
<Column class="w-1/2 text-right" data-e2e="right-column">
<Link href="https://example.com/link">
Plain link component
</Link>
</Column>
</Row>
<Button
class="rounded bg-blue-600 px-4 py-2 text-white"
href={slot('ctaUrl')}
style={{ padding: '12px 16px' }}
>
Button component
</Button>
<Text>
Inline code component:{' '}
<CodeInline>const solid = true;</CodeInline>
</Text>
<CodeBlock
code={
'function greet(name: string) {\n return "Hello " + name;\n}'
}
fontFamily="Menlo, monospace"
language="typescript"
lineNumbers
theme={xonokai}
/>
<Markdown
class="markdown-fixture"
markdownContainerStyles={{
border: '1px solid #e2e8f0',
padding: '12px',
}}
>
{markdownContent}
</Markdown>
</Section>
</Container>
</Tailwind>
</Body>
</Html>
);
}

export async function renderCloudflareEmailReport(): Promise<CloudflareEmailReport> {
try {
const email = await compile<WorkerEmailSlots>(() => <WorkerRouteEmail />, {
withPlainText: true,
});
const slots = {
ctaUrl: 'https://example.com/action',
recipient: 'Cloudflare TanStack Worker',
routeName: 'Solid Start route graph',
} satisfies WorkerEmailSlots;

const html = await email.render(slots);
const plainText = await email.render(slots, { plainText: true });
const renderProbe = await render(() => (
<Section>
<Text>Standalone render confirms @solid-email/render in Workerd</Text>
</Section>
));

if (
!html.includes(slots.recipient) ||
!html.includes(slots.routeName) ||
!html.includes(slots.ctaUrl) ||
!plainText.includes(slots.recipient) ||
!renderProbe.includes('@solid-email/render in Workerd')
) {
throw new Error(
'Cloudflare Worker email render missed expected slot content',
);
}

return {
html: `${html}\n${renderProbe}`,
plainText,
status: 'rendered',
};
} catch (error) {
const message =
error instanceof Error ? (error.stack ?? error.message) : String(error);
throw new Error(`Cloudflare Worker email render failed: ${message}`);
}
}
6 changes: 6 additions & 0 deletions e2e/cloudflare-tanstack-start/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createRouter } from '@tanstack/solid-router';
import { routeTree } from './routeTree.gen';

export function getRouter() {
return createRouter({ routeTree });
}
35 changes: 35 additions & 0 deletions e2e/cloudflare-tanstack-start/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from '@tanstack/solid-router';
import { type JSX, Suspense } from 'solid-js';
import { HydrationScript } from 'solid-js/web';

export const Route = createRootRoute({
component: RootComponent,
});

function RootComponent(): JSX.Element {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}

function RootDocument(props: Readonly<{ children: JSX.Element }>): JSX.Element {
return (
<html lang="en">
<head>
<HydrationScript />
</head>
<body>
<HeadContent />
<Suspense>{props.children}</Suspense>
<Scripts />
</body>
</html>
);
}
18 changes: 18 additions & 0 deletions e2e/cloudflare-tanstack-start/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createFileRoute } from '@tanstack/solid-router';
import { renderCloudflareEmailReportFn } from '../email.functions';

export const Route = createFileRoute('/')({
loader: () => renderCloudflareEmailReportFn(),
component: Home,
});

function Home() {
const report = Route.useLoaderData();
return (
<main data-email-status={report().status}>
<h1>Cloudflare TanStack Start Solid route loaded</h1>
<p>{report().plainText}</p>
<pre data-email-html={report().status}>{report().html}</pre>
</main>
);
}
Loading
Loading