diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0981e..3b7a954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0629521..d60ee9e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -112,14 +114,14 @@ const html = renderSync(() => ); ## 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. diff --git a/e2e/cloudflare-tanstack-start/package.json b/e2e/cloudflare-tanstack-start/package.json new file mode 100644 index 0000000..a78ad7a --- /dev/null +++ b/e2e/cloudflare-tanstack-start/package.json @@ -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" + } +} diff --git a/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml b/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml new file mode 100644 index 0000000..005d266 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/pnpm-workspace.yaml @@ -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 diff --git a/e2e/cloudflare-tanstack-start/src/email.functions.ts b/e2e/cloudflare-tanstack-start/src/email.functions.ts new file mode 100644 index 0000000..f31d33a --- /dev/null +++ b/e2e/cloudflare-tanstack-start/src/email.functions.ts @@ -0,0 +1,6 @@ +import { createServerFn } from '@tanstack/solid-start'; +import { renderCloudflareEmailReport } from './email.server'; + +export const renderCloudflareEmailReportFn = createServerFn({ + method: 'GET', +}).handler(() => renderCloudflareEmailReport()); diff --git a/e2e/cloudflare-tanstack-start/src/email.server.tsx b/e2e/cloudflare-tanstack-start/src/email.server.tsx new file mode 100644 index 0000000..8c06a07 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/src/email.server.tsx @@ -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 ( + + + + + All components e2e preview + + + +
+ + Comprehensive components e2e + + + Cloudflare TanStack Solid email rendered + + + Hello ! + + + Loaded from while running in Workerd. + +
+ + + Solid Email fixture logo + + + + Plain link component + + + + + + Inline code component:{' '} + const solid = true; + + + + {markdownContent} + +
+
+
+ + + ); +} + +export async function renderCloudflareEmailReport(): Promise { + try { + const email = await compile(() => , { + 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(() => ( +
+ Standalone render confirms @solid-email/render in Workerd +
+ )); + + 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}`); + } +} diff --git a/e2e/cloudflare-tanstack-start/src/router.tsx b/e2e/cloudflare-tanstack-start/src/router.tsx new file mode 100644 index 0000000..7e47566 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/src/router.tsx @@ -0,0 +1,6 @@ +import { createRouter } from '@tanstack/solid-router'; +import { routeTree } from './routeTree.gen'; + +export function getRouter() { + return createRouter({ routeTree }); +} diff --git a/e2e/cloudflare-tanstack-start/src/routes/__root.tsx b/e2e/cloudflare-tanstack-start/src/routes/__root.tsx new file mode 100644 index 0000000..ac0bb2c --- /dev/null +++ b/e2e/cloudflare-tanstack-start/src/routes/__root.tsx @@ -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 ( + + + + ); +} + +function RootDocument(props: Readonly<{ children: JSX.Element }>): JSX.Element { + return ( + + + + + + + {props.children} + + + + ); +} diff --git a/e2e/cloudflare-tanstack-start/src/routes/index.tsx b/e2e/cloudflare-tanstack-start/src/routes/index.tsx new file mode 100644 index 0000000..d85f06c --- /dev/null +++ b/e2e/cloudflare-tanstack-start/src/routes/index.tsx @@ -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 ( +
+

Cloudflare TanStack Start Solid route loaded

+

{report().plainText}

+
{report().html}
+
+ ); +} diff --git a/e2e/cloudflare-tanstack-start/tsconfig.json b/e2e/cloudflare-tanstack-start/tsconfig.json new file mode 100644 index 0000000..5160f68 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../packages/tsconfig/solid-vite-app.json", + "include": ["src", "vite.config.ts"], + "exclude": [ + "node_modules", + "dist", + ".output", + ".tanstack", + ".types", + ".wrangler" + ] +} diff --git a/e2e/cloudflare-tanstack-start/vite.config.ts b/e2e/cloudflare-tanstack-start/vite.config.ts new file mode 100644 index 0000000..80413f9 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/vite.config.ts @@ -0,0 +1,12 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { tanstackStart } from '@tanstack/solid-start/plugin/vite'; +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tanstackStart(), + solidPlugin({ ssr: true }), + ], +}); diff --git a/e2e/cloudflare-tanstack-start/wrangler.jsonc b/e2e/cloudflare-tanstack-start/wrangler.jsonc new file mode 100644 index 0000000..0b85389 --- /dev/null +++ b/e2e/cloudflare-tanstack-start/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "solid-email-cloudflare-tanstack-start-e2e", + "compatibility_date": "2026-07-04", + "compatibility_flags": ["nodejs_compat"], + "main": "@tanstack/solid-start/server-entry", + "observability": { + "enabled": true + } +} diff --git a/e2e/integrations.e2e.spec.ts b/e2e/integrations.e2e.spec.ts index 1df6e83..9370b20 100644 --- a/e2e/integrations.e2e.spec.ts +++ b/e2e/integrations.e2e.spec.ts @@ -1,7 +1,9 @@ -import type { ExecFileException } from 'node:child_process'; -import { execFile } from 'node:child_process'; +import type { ChildProcess, ExecFileException } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { mkdir, readFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; import { promisify } from 'node:util'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -140,40 +142,46 @@ const renderRequireProbe = `${renderExportSmoke} process.exit(1); });`; +// These probes use dynamic import because the tests intentionally exercise +// package export resolution under Node's condition flags. const solidEmailBrowserImportProbe = ` const resolved = import.meta.resolve('@akin01/solid-email'); const mod = await import('@akin01/solid-email'); -for (const name of ['render', 'Section', 'Row', 'Heading']) { +for (const name of ['Body', 'Button', 'Container', 'Heading', 'Preview', 'Row', 'Section', 'Text']) { if (typeof mod[name] !== 'function') { - throw new Error(\`missing solid-email export: \${name}\`); + throw new Error(\`missing browser root export: \${name}\`); } } -const html = await mod.render(() => - mod.Section({ - style: { padding: '12px' }, - children: mod.Row({ - children: mod.Heading({ - as: 'h2', - style: { color: 'blue' }, - children: 'Solid Email browser condition smoke', - }), - }), - }), -); +for (const name of ['render', 'renderSync', 'compile', 'compileSync', 'toPlainText', 'pretty', 'Tailwind']) { + if (name in mod) { + throw new Error(\`unexpected server-only browser root export: \${name}\`); + } +} -if (!html.includes(' process.exit(0));`; + +const solidEmailBrowserRequireProbe = ` +const resolved = require.resolve('@akin01/solid-email'); +const mod = require('@akin01/solid-email'); + +for (const name of ['Body', 'Button', 'Container', 'Heading', 'Preview', 'Row', 'Section', 'Text']) { + if (typeof mod[name] !== 'function') { + throw new Error(\`missing browser root require export: \${name}\`); + } } -if (!html.includes('style="width:100%"') || !html.includes('color:blue')) { - throw new Error(\`missing server-rendered style output: \${html}\`); + +for (const name of ['render', 'renderSync', 'compile', 'compileSync', 'toPlainText', 'pretty', 'Tailwind']) { + if (name in mod) { + throw new Error(\`unexpected server-only browser root require export: \${name}\`); + } } process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0));`; -const solidEmailClientDomProbe = ` -const resolved = import.meta.resolve('@akin01/solid-email/client'); +const solidEmailBrowserRootDomProbe = ` +const resolved = import.meta.resolve('@akin01/solid-email'); const { JSDOM } = await import('jsdom'); const dom = new JSDOM('
', { url: 'https://solid.email/preview', @@ -184,16 +192,16 @@ globalThis.Node = dom.window.Node; globalThis.HTMLElement = dom.window.HTMLElement; globalThis.Element = dom.window.Element; -const mod = await import('@akin01/solid-email/client'); +const mod = await import('@akin01/solid-email'); for (const name of ['render', 'compile', 'Tailwind']) { if (name in mod) { - throw new Error(\`unexpected client export: \${name}\`); + throw new Error(\`unexpected browser root export: \${name}\`); } } for (const name of ['Container', 'Heading', 'Text', 'Preview']) { if (typeof mod[name] !== 'function') { - throw new Error(\`missing client export: \${name}\`); + throw new Error(\`missing browser root export: \${name}\`); } } @@ -206,15 +214,15 @@ const dispose = mount( children: mod.Heading({ as: 'h2', style: { color: 'purple' }, - children: mod.Text({ children: 'Client preview mounted' }), + children: mod.Text({ children: 'Browser root preview mounted' }), }), }), root, ); const heading = root.querySelector('h2'); -if (!heading || heading.textContent !== 'Client preview mounted') { - throw new Error(\`missing mounted client heading: \${root.innerHTML}\`); +if (!heading || heading.textContent !== 'Browser root preview mounted') { + throw new Error(\`missing mounted browser root heading: \${root.innerHTML}\`); } if (!root.querySelector('table')) { throw new Error(\`missing mounted email layout table: \${root.innerHTML}\`); @@ -223,6 +231,52 @@ if (!root.querySelector('table')) { dispose(); process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0));`; +const solidEmailServerRootSmoke = ` +async function assertSolidEmailServerRoot(mod, expectedText) { + for (const name of ['render', 'Tailwind', 'Text']) { + if (typeof mod[name] !== 'function') { + throw new Error(\`missing server root export: \${name}\`); + } + } + + const html = await mod.render(() => + mod.Tailwind({ + children: mod.Text({ + class: 'text-blue-600', + children: expectedText, + }), + }), + ); + + if (!html.includes(expectedText)) { + throw new Error(\`missing rendered Tailwind text: \${html}\`); + } + if (html.includes('text-blue-600')) { + throw new Error(\`Tailwind class was not removed: \${html}\`); + } + if (!html.includes('color:rgb(21,93,252)')) { + throw new Error(\`Tailwind class was not inlined: \${html}\`); + } +} +`; + +const solidEmailServerImportProbe = `${solidEmailServerRootSmoke} +const resolved = import.meta.resolve('@akin01/solid-email'); +const mod = await import('@akin01/solid-email'); +await assertSolidEmailServerRoot(mod, 'Solid Email ESM Tailwind safe'); +process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0));`; + +const solidEmailServerRequireProbe = `${solidEmailServerRootSmoke} +(async () => { + const resolved = require.resolve('@akin01/solid-email'); + const mod = require('@akin01/solid-email'); + await assertSolidEmailServerRoot(mod, 'Solid Email CJS Tailwind safe'); + process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0)); +})().catch((error) => { + console.error(error); + process.exit(1); +});`; + type PnpmError = ExecFileException & { stderr?: string; stdout?: string; @@ -232,7 +286,15 @@ function isPnpmError(error: unknown): error is PnpmError { return error instanceof Error; } -async function cleanFixture(fixture: 'vite' | 'tanstack-start'): Promise { +type FixtureName = 'vite' | 'tanstack-start' | 'cloudflare-tanstack-start'; + +type PreviewServer = { + output: () => string; + process: ChildProcess; + stop: () => Promise; +}; + +async function cleanFixture(fixture: FixtureName): Promise { const fixtureRoot = path.join(e2eRoot, fixture); await rm(path.join(fixtureRoot, 'node_modules'), { recursive: true, @@ -246,6 +308,10 @@ async function cleanFixture(fixture: 'vite' | 'tanstack-start'): Promise { recursive: true, force: true, }); + await rm(path.join(fixtureRoot, '.wrangler'), { + recursive: true, + force: true, + }); await rm(path.join(fixtureRoot, 'src/routeTree.gen.ts'), { force: true, }); @@ -299,7 +365,7 @@ async function preparePackedPackages(): Promise { ]); } -async function installAndBuildFixture(fixture: 'vite' | 'tanstack-start') { +async function installAndBuildFixture(fixture: FixtureName) { const fixtureRoot = path.join(e2eRoot, fixture); await cleanFixture(fixture); @@ -322,6 +388,85 @@ async function installAndBuildFixture(fixture: 'vite' | 'tanstack-start') { return fixtureRoot; } +async function getAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const address = server.address(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + + if (typeof address === 'object' && address !== null) { + return address.port; + } + + throw new Error('Unable to allocate a preview port'); +} + +function startFixturePreview(fixtureRoot: string, port: number): PreviewServer { + const child = spawn( + 'pnpm', + ['run', 'preview', '--port', String(port), '--strictPort'], + { + cwd: fixtureRoot, + env: { ...process.env, CI: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + child.stdout?.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + output += chunk.toString(); + }); + + return { + output: () => output, + process: child, + stop: async () => { + if (child.exitCode !== null) { + return; + } + + child.kill('SIGTERM'); + await new Promise((resolve) => { + child.once('exit', () => resolve()); + }); + }, + }; +} + +async function fetchWhenReady( + url: string, + server: PreviewServer, +): Promise { + const deadline = Date.now() + 60_000; + let lastError: unknown; + + while (Date.now() < deadline) { + if (server.process.exitCode !== null) { + throw new Error( + `Preview exited before serving ${url}.\n${server.output()}`, + ); + } + + try { + return await fetch(url); + } catch (error) { + lastError = error; + await delay(250); + } + } + + throw new Error( + `Timed out waiting for ${url}: ${String(lastError)}\n${server.output()}`, + ); +} + async function resolveRenderImport(entry: RenderExportCase): Promise { const { stdout } = await execFileAsync( 'node', @@ -348,7 +493,7 @@ function expectAllComponentsHtml(html: string, label: string): void { expect(html).toContain('padding:1rem'); expect(html).toContain('font-size:0.875rem'); expect(html).toContain('color:rgb(21,93,252)'); - expect(html).not.toContain('class="mx-auto bg-white p-4"'); + expect(html).not.toContain('mx-auto bg-white p-4'); expect(html).toContain(' { }); afterAll(async () => { - await Promise.all([cleanFixture('vite'), cleanFixture('tanstack-start')]); + await Promise.all([ + cleanFixture('vite'), + cleanFixture('tanstack-start'), + cleanFixture('cloudflare-tanstack-start'), + ]); await rm(path.join(e2eRoot, '.tmp'), { recursive: true, force: true }); }); @@ -401,7 +550,27 @@ describe('published package integration fixtures', () => { } }); - it('loads solid-email under browser import conditions', async () => { + it('loads solid-email root server entry in ESM and CJS', async () => { + const esm = await execFileAsync( + 'node', + ['--input-type=module', '--eval', solidEmailServerImportProbe], + { cwd: root }, + ); + const cjs = await execFileAsync( + 'node', + ['--eval', solidEmailServerRequireProbe], + { cwd: root }, + ); + + expect(esm.stdout.trim().replaceAll('\\', '/')).toContain( + '/packages/solid-email/dist/index.mjs', + ); + expect(cjs.stdout.trim().replaceAll('\\', '/')).toContain( + '/packages/solid-email/dist/index.cjs', + ); + }); + + it('loads solid-email root under browser import conditions', async () => { const { stdout } = await execFileAsync( 'node', [ @@ -414,18 +583,30 @@ describe('published package integration fixtures', () => { ); expect(stdout.trim().replaceAll('\\', '/')).toContain( - '/packages/solid-email/dist/index.mjs', + '/packages/solid-email/dist/client/index.mjs', + ); + }); + + it('requires solid-email root under browser conditions', async () => { + const { stdout } = await execFileAsync( + 'node', + ['--conditions=browser', '--eval', solidEmailBrowserRequireProbe], + { cwd: root }, + ); + + expect(stdout.trim().replaceAll('\\', '/')).toContain( + '/packages/solid-email/dist/client/index.cjs', ); }); - it('mounts the client entrypoint in a Solid DOM preview', async () => { + it('mounts the browser root condition in a Solid DOM preview', async () => { const { stdout } = await execFileAsync( 'node', [ '--conditions=browser', '--input-type=module', '--eval', - solidEmailClientDomProbe, + solidEmailBrowserRootDomProbe, ], { cwd: root }, ); @@ -434,6 +615,7 @@ describe('published package integration fixtures', () => { '/packages/solid-email/dist/client/index.mjs', ); }); + it('builds and renders in a Solid Vite SSR fixture', async () => { const fixtureRoot = await installAndBuildFixture('vite'); const html = await execFileAsync('node', ['dist/entry-server.mjs'], { @@ -472,4 +654,46 @@ describe('published package integration fixtures', () => { ); expect(componentTypes).toContain('AllComponentsEmail'); }); + + it('renders solid-email through a Cloudflare TanStack Start Worker route', async () => { + const fixtureRoot = await installAndBuildFixture( + 'cloudflare-tanstack-start', + ); + const routeTree = await readFile( + path.join(fixtureRoot, 'src/routeTree.gen.ts'), + 'utf8', + ); + expect(routeTree).toContain('/'); + + const port = await getAvailablePort(); + const preview = startFixturePreview(fixtureRoot, port); + + try { + const response = await fetchWhenReady( + `http://127.0.0.1:${port}/`, + preview, + ); + const html = await response.text(); + const decodedHtml = html + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); + + expect(response.status, html).toBe(200); + expect(html).toContain('data-email-status="rendered"'); + expect(html).toContain('Cloudflare TanStack Start Solid route loaded'); + expectAllComponentsHtml( + decodedHtml, + 'Cloudflare TanStack Solid email rendered', + ); + expect(html).toContain('Cloudflare TanStack Solid email rendered'); + expect(html).toContain('Hello Cloudflare TanStack Worker!'); + expect(html).toContain('Solid Start route graph'); + expect(html).toContain('href="https://example.com/action"'); + expect(html).toContain('@solid-email/render in Workerd'); + } finally { + await preview.stop(); + } + }); }); diff --git a/packages/render/src/shared/compile.ts b/packages/render/src/shared/compile.ts index 7ee9f1a..6a69c8e 100644 --- a/packages/render/src/shared/compile.ts +++ b/packages/render/src/shared/compile.ts @@ -14,6 +14,7 @@ import { removeSolidResourceScripts, renderOutput, renderSyncOutput, + solidRenderOptions, } from './render'; import { buildMarkerRegex, @@ -137,7 +138,7 @@ export async function compile< options?: CompileOptions, ): Promise> { const html = removeSolidResourceScripts( - await renderToStringAsync(normalizeRenderable(node)), + await renderToStringAsync(normalizeRenderable(node), solidRenderOptions), ); return new CompiledTemplate(html, options); } @@ -149,7 +150,7 @@ export function compileSync< throw new Error('compileSync does not support pretty output; use compile.'); } const html = removeSolidResourceScripts( - renderToString(normalizeRenderable(node)), + renderToString(normalizeRenderable(node), solidRenderOptions), ); return new CompiledTemplate(html, options); } diff --git a/packages/render/src/shared/render.ts b/packages/render/src/shared/render.ts index d53b099..adfa5aa 100644 --- a/packages/render/src/shared/render.ts +++ b/packages/render/src/shared/render.ts @@ -11,6 +11,7 @@ export type Renderable = JSX.Element | (() => JSX.Element); const doctype = ''; +export const solidRenderOptions = { renderId: 'solid-email' } as const; export function normalizeRenderable(node: Renderable) { return typeof node === 'function' ? (node as () => JSX.Element) : () => node; @@ -81,7 +82,7 @@ export async function render( options?: Options, ): Promise { const html = removeSolidResourceScripts( - await renderToStringAsync(normalizeRenderable(node)), + await renderToStringAsync(normalizeRenderable(node), solidRenderOptions), ); return renderOutput(html, options); @@ -96,7 +97,7 @@ export function renderSync( } const html = removeSolidResourceScripts( - renderToString(normalizeRenderable(node)), + renderToString(normalizeRenderable(node), solidRenderOptions), ); return renderSyncOutput(html, options); diff --git a/packages/solid-email/README.md b/packages/solid-email/README.md index dfb68e0..806e31b 100644 --- a/packages/solid-email/README.md +++ b/packages/solid-email/README.md @@ -32,13 +32,14 @@ const html = await render(() => ); ## Entrypoints -Use `@akin01/solid-email` for SSR/email HTML string rendering. It exports -`render`, `compile`, and the full email component set, including `Tailwind`. - -Use `@akin01/solid-email/client` only for DOM/CSR preview mounting. 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`. + ## Components Includes email-safe primitives such as `Html`, `Head`, `Preview`, `Body`, `Container`, `Section`, `Row`, `Column`, `Text`, `Heading`, `Button`, `Link`, `Img`, `Hr`, `Markdown`, `CodeInline`, `CodeBlock`, and `Tailwind`. diff --git a/packages/solid-email/package.json b/packages/solid-email/package.json index b38f1ac..db12714 100644 --- a/packages/solid-email/package.json +++ b/packages/solid-email/package.json @@ -15,6 +15,29 @@ ], "exports": { ".": { + "workerd": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "worker": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "edge-light": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "browser": { + "import": { + "types": "./dist/client/index.d.mts", + "default": "./dist/client/index.mjs" + }, + "require": { + "types": "./dist/client/index.d.cts", + "default": "./dist/client/index.cjs" + }, + "default": "./dist/client/index.mjs" + }, "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" @@ -23,16 +46,6 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } - }, - "./client": { - "import": { - "types": "./dist/client/index.d.mts", - "default": "./dist/client/index.mjs" - }, - "require": { - "types": "./dist/client/index.d.cts", - "default": "./dist/client/index.cjs" - } } }, "scripts": { diff --git a/packages/solid-email/src/components/code-block/prism.ts b/packages/solid-email/src/components/code-block/prism.ts index fa0ab04..1f3c072 100644 --- a/packages/solid-email/src/components/code-block/prism.ts +++ b/packages/solid-email/src/components/code-block/prism.ts @@ -1,17 +1,19 @@ import * as PrismImport from 'prismjs'; -import loadLanguages from 'prismjs/components/index.js'; +import 'prismjs/components/prism-python.js'; +import 'prismjs/components/prism-typescript.js'; + +type PrismModule = typeof PrismImport; // Prism ships as CommonJS. Depending on the bundler/runtime, the module can // appear either as a namespace object or under `default`; normalize that once // so the rest of CodeBlock can use the Prism API directly. -const PrismImportWithDefault = PrismImport as typeof PrismImport & { - default?: typeof PrismImport; +const PrismImportWithDefault = PrismImport as PrismModule & { + default?: PrismModule; }; -const Prism: typeof import('prismjs') = - PrismImportWithDefault.default ?? PrismImport; +const Prism: PrismModule = PrismImportWithDefault.default ?? PrismImport; -// Register Prism component grammars into `Prism.languages`. CodeBlock looks up -// grammars by the `language` prop before calling `Prism.tokenize(...)`. -loadLanguages(); +// Register the grammars covered by CodeBlock's e2e/test surface without using +// prismjs/components/index.js. That loader calls require.resolve at runtime, +// which is not available in Workerd. export { Prism }; diff --git a/packages/solid-email/src/components/tailwind/inline-styles.ts b/packages/solid-email/src/components/tailwind/inline-styles.ts index 515a8b7..c6ec4c5 100644 --- a/packages/solid-email/src/components/tailwind/inline-styles.ts +++ b/packages/solid-email/src/components/tailwind/inline-styles.ts @@ -1,4 +1,4 @@ -import type { StyleSheet } from 'css-tree'; +import type { StyleSheet } from 'css-tree/dist/csstree.esm'; import { extractRulesPerClass } from './utils/css/extract-rules-per-class'; import { getCustomProperties } from './utils/css/get-custom-properties'; import { makeInlineStylesFor } from './utils/css/make-inline-styles-for'; diff --git a/packages/solid-email/src/components/tailwind/sanitize-stylesheet.ts b/packages/solid-email/src/components/tailwind/sanitize-stylesheet.ts index ee046bb..ff57c2a 100644 --- a/packages/solid-email/src/components/tailwind/sanitize-stylesheet.ts +++ b/packages/solid-email/src/components/tailwind/sanitize-stylesheet.ts @@ -1,4 +1,4 @@ -import type { StyleSheet } from 'css-tree'; +import type { StyleSheet } from 'css-tree/dist/csstree.esm'; import { resolveAllCssVariables } from './utils/css/resolve-all-css-variables'; import { resolveCalcExpressions } from './utils/css/resolve-calc-expressions'; import { sanitizeDeclarations } from './utils/css/sanitize-declarations'; diff --git a/packages/solid-email/src/components/tailwind/tailwind.tsx b/packages/solid-email/src/components/tailwind/tailwind.tsx index af82fbb..cd27027 100644 --- a/packages/solid-email/src/components/tailwind/tailwind.tsx +++ b/packages/solid-email/src/components/tailwind/tailwind.tsx @@ -1,5 +1,11 @@ -import { type CssNode, generate, List, type StyleSheet } from 'css-tree'; -import { createResource, type JSX, Suspense } from 'solid-js'; +import { + type CssNode, + generate, + List, + type StyleSheet, +} from 'css-tree/dist/csstree.esm'; +import type { JSX } from 'solid-js'; +import { createResource, Suspense } from 'solid-js/dist/server.js'; import { ssr } from 'solid-js/web/dist/server.js'; import type { Config } from 'tailwindcss'; import type { SolidStyle } from '../shared'; diff --git a/packages/solid-email/src/components/tailwind/utils/css/downlevel-for-email-clients.ts b/packages/solid-email/src/components/tailwind/utils/css/downlevel-for-email-clients.ts index cdf8d34..77b2be6 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/downlevel-for-email-clients.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/downlevel-for-email-clients.ts @@ -25,7 +25,7 @@ import { type Rule, type StyleSheet, walk, -} from 'css-tree'; +} from 'css-tree/dist/csstree.esm'; /** * css-tree 3.x introduced new AST node types for query-related at-rules that diff --git a/packages/solid-email/src/components/tailwind/utils/css/extract-rules-per-class.ts b/packages/solid-email/src/components/tailwind/utils/css/extract-rules-per-class.ts index 6b587f7..cc1c444 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/extract-rules-per-class.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/extract-rules-per-class.ts @@ -1,4 +1,9 @@ -import { type CssNode, type Rule, string, walk } from 'css-tree'; +import { + type CssNode, + type Rule, + string, + walk, +} from 'css-tree/dist/csstree.esm'; import { isRuleInlinable } from './is-rule-inlinable'; import { splitMixedRule } from './split-mixed-rule'; diff --git a/packages/solid-email/src/components/tailwind/utils/css/get-custom-properties.ts b/packages/solid-email/src/components/tailwind/utils/css/get-custom-properties.ts index 1ba184d..c0e4af1 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/get-custom-properties.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/get-custom-properties.ts @@ -1,4 +1,9 @@ -import { type CssNode, type Declaration, generate, walk } from 'css-tree'; +import { + type CssNode, + type Declaration, + generate, + walk, +} from 'css-tree/dist/csstree.esm'; export interface CustomProperty { syntax?: Declaration; diff --git a/packages/solid-email/src/components/tailwind/utils/css/is-part-inlinable.ts b/packages/solid-email/src/components/tailwind/utils/css/is-part-inlinable.ts index 1399cc1..2d0de0e 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/is-part-inlinable.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/is-part-inlinable.ts @@ -1,4 +1,4 @@ -import { type CssNode, find } from 'css-tree'; +import { type CssNode, find } from 'css-tree/dist/csstree.esm'; export function isPartInlinable(part: CssNode): boolean { const hasAtRuleInside = find(part, (node) => node.type === 'Atrule') !== null; diff --git a/packages/solid-email/src/components/tailwind/utils/css/is-rule-inlinable.ts b/packages/solid-email/src/components/tailwind/utils/css/is-rule-inlinable.ts index 9df47ab..2ee61e5 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/is-rule-inlinable.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/is-rule-inlinable.ts @@ -1,4 +1,4 @@ -import { find, type Rule } from 'css-tree'; +import { find, type Rule } from 'css-tree/dist/csstree.esm'; export function isRuleInlinable(rule: Rule): boolean { const hasAtRuleInside = find(rule, (node) => node.type === 'Atrule') !== null; diff --git a/packages/solid-email/src/components/tailwind/utils/css/make-inline-styles-for.ts b/packages/solid-email/src/components/tailwind/utils/css/make-inline-styles-for.ts index c0181c7..622e4a6 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/make-inline-styles-for.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/make-inline-styles-for.ts @@ -1,4 +1,9 @@ -import { type CssNode, type Declaration, generate, walk } from 'css-tree'; +import { + type CssNode, + type Declaration, + generate, + walk, +} from 'css-tree/dist/csstree.esm'; import { getStyleProperty } from '../compatibility/get-style-property'; import type { CustomProperties } from './get-custom-properties'; import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars'; diff --git a/packages/solid-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts b/packages/solid-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts index ad64561..df5aad5 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts @@ -9,7 +9,7 @@ import { type SelectorList, type Value, walk, -} from 'css-tree'; +} from 'css-tree/dist/csstree.esm'; interface VariableUse { declaration: Declaration; diff --git a/packages/solid-email/src/components/tailwind/utils/css/resolve-calc-expressions.ts b/packages/solid-email/src/components/tailwind/utils/css/resolve-calc-expressions.ts index 13b2417..081031d 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/resolve-calc-expressions.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/resolve-calc-expressions.ts @@ -1,4 +1,4 @@ -import { type CssNode, walk } from 'css-tree'; +import { type CssNode, walk } from 'css-tree/dist/csstree.esm'; /** * Intentionally only resolves `*` and `/` operations without dealing with parenthesis, because this is the only thing required to run Tailwind v4 diff --git a/packages/solid-email/src/components/tailwind/utils/css/sanitize-declarations.ts b/packages/solid-email/src/components/tailwind/utils/css/sanitize-declarations.ts index 3ffb64c..d079ea4 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/sanitize-declarations.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/sanitize-declarations.ts @@ -8,7 +8,7 @@ import { type Raw, type Value, walk, -} from 'css-tree'; +} from 'css-tree/dist/csstree.esm'; function rgbNode( r: number, diff --git a/packages/solid-email/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.ts b/packages/solid-email/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.ts index fbb30a3..de9faa5 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/sanitize-non-inlinable-rules.ts @@ -1,4 +1,4 @@ -import { type CssNode, string, walk } from 'css-tree'; +import { type CssNode, string, walk } from 'css-tree/dist/csstree.esm'; import { sanitizeClassName } from '../compatibility/sanitize-class-name'; import { isRuleInlinable } from './is-rule-inlinable'; import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars'; diff --git a/packages/solid-email/src/components/tailwind/utils/css/split-mixed-rule.ts b/packages/solid-email/src/components/tailwind/utils/css/split-mixed-rule.ts index 03b0d98..cb7a1e0 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/split-mixed-rule.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/split-mixed-rule.ts @@ -1,4 +1,10 @@ -import { type CssNode, clone, find, List, type Rule } from 'css-tree'; +import { + type CssNode, + clone, + find, + List, + type Rule, +} from 'css-tree/dist/csstree.esm'; import { isPartInlinable } from './is-part-inlinable'; /** diff --git a/packages/solid-email/src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts b/packages/solid-email/src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts index b7399c4..70c7edc 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/strip-empty-tailwind-vars.ts @@ -1,4 +1,4 @@ -import { type CssNode, generate, walk } from 'css-tree'; +import { type CssNode, generate, walk } from 'css-tree/dist/csstree.esm'; /** * Tailwind v4 emits variant-stacking idioms like diff --git a/packages/solid-email/src/components/tailwind/utils/css/unwrap-value.ts b/packages/solid-email/src/components/tailwind/utils/css/unwrap-value.ts index 81fc7ff..83821e6 100644 --- a/packages/solid-email/src/components/tailwind/utils/css/unwrap-value.ts +++ b/packages/solid-email/src/components/tailwind/utils/css/unwrap-value.ts @@ -1,4 +1,4 @@ -import type { Raw, Value } from 'css-tree'; +import type { Raw, Value } from 'css-tree/dist/csstree.esm'; export function unwrapValue(value: Value | Raw) { if (value.type === 'Value' && value.children.size === 1) { diff --git a/packages/solid-email/src/components/tailwind/utils/tailwindcss/setup-tailwind.ts b/packages/solid-email/src/components/tailwind/utils/tailwindcss/setup-tailwind.ts index 948fed0..9c15652 100644 --- a/packages/solid-email/src/components/tailwind/utils/tailwindcss/setup-tailwind.ts +++ b/packages/solid-email/src/components/tailwind/utils/tailwindcss/setup-tailwind.ts @@ -1,4 +1,4 @@ -import { parse, type StyleSheet } from 'css-tree'; +import { parse, type StyleSheet } from 'css-tree/dist/csstree.esm'; import { compile } from 'tailwindcss'; import type { TailwindConfig } from '../../tailwind'; import indexCss from './tailwind-stylesheets/index'; @@ -15,6 +15,8 @@ interface TailwindCompiler { build(candidates: string[]): string; } +const virtualTailwindBase = 'solid-email://tailwind/'; + async function compileTailwind( baseCss: string, config: TailwindConfig | undefined, @@ -24,8 +26,8 @@ async function compileTailwind( async loadModule(id, base, resourceHint) { if (resourceHint === 'config') { return { - path: id, - base: base, + path: id ?? 'solid-email-tailwind.config.js', + base: base ?? virtualTailwindBase, module: config ?? {}, }; } @@ -36,9 +38,11 @@ async function compileTailwind( }, polyfills: 0, // All async loadStylesheet(id, base) { + const stylesheetBase = base ?? virtualTailwindBase; + if (id === 'tailwindcss') { return { - base, + base: stylesheetBase, path: 'tailwindcss/index.css', content: indexCss, }; @@ -46,7 +50,7 @@ async function compileTailwind( if (id === 'tailwindcss/preflight.css') { return { - base, + base: stylesheetBase, path: id, content: preflightCss, }; @@ -54,7 +58,7 @@ async function compileTailwind( if (id === 'tailwindcss/theme.css') { return { - base, + base: stylesheetBase, path: id, content: themeCss, }; @@ -62,7 +66,7 @@ async function compileTailwind( if (id === 'tailwindcss/utilities.css') { return { - base, + base: stylesheetBase, path: id, content: utilitiesCss, }; @@ -70,7 +74,7 @@ async function compileTailwind( if (id === 'custom-theme.css') { return { - base, + base: stylesheetBase, path: id, content: cssConfigs?.theme ?? '', }; @@ -78,7 +82,7 @@ async function compileTailwind( if (id === 'custom-utilities.css') { return { - base, + base: stylesheetBase, path: id, content: cssConfigs?.utility ?? '', }; diff --git a/packages/solid-email/src/index.spec.tsx b/packages/solid-email/src/index.spec.tsx index 16a91d9..ddfd3ef 100644 --- a/packages/solid-email/src/index.spec.tsx +++ b/packages/solid-email/src/index.spec.tsx @@ -1,24 +1,24 @@ import { describe, expect, it } from 'vitest'; -import * as client from './client'; +import * as browserRoot from './client'; import { - Body as ClientBody, - Button as ClientButton, - CodeBlock as ClientCodeBlock, - CodeInline as ClientCodeInline, - Column as ClientColumn, - Container as ClientContainer, - Font as ClientFont, - Head as ClientHead, - Heading as ClientHeading, - Hr as ClientHr, - Html as ClientHtml, - Img as ClientImg, - Link as ClientLink, - Markdown as ClientMarkdown, - Preview as ClientPreview, - Row as ClientRow, - Section as ClientSection, - Text as ClientText, + Body as BrowserRootBody, + Button as BrowserRootButton, + CodeBlock as BrowserRootCodeBlock, + CodeInline as BrowserRootCodeInline, + Column as BrowserRootColumn, + Container as BrowserRootContainer, + Font as BrowserRootFont, + Head as BrowserRootHead, + Heading as BrowserRootHeading, + Hr as BrowserRootHr, + Html as BrowserRootHtml, + Img as BrowserRootImg, + Link as BrowserRootLink, + Markdown as BrowserRootMarkdown, + Preview as BrowserRootPreview, + Row as BrowserRootRow, + Section as BrowserRootSection, + Text as BrowserRootText, } from './client'; import { Body, @@ -70,25 +70,25 @@ const componentExports = { Text, }; -const clientComponentExports = { - Body: ClientBody, - Button: ClientButton, - CodeBlock: ClientCodeBlock, - CodeInline: ClientCodeInline, - Column: ClientColumn, - Container: ClientContainer, - Font: ClientFont, - Head: ClientHead, - Heading: ClientHeading, - Hr: ClientHr, - Html: ClientHtml, - Img: ClientImg, - Link: ClientLink, - Markdown: ClientMarkdown, - Preview: ClientPreview, - Row: ClientRow, - Section: ClientSection, - Text: ClientText, +const browserRootComponentExports = { + Body: BrowserRootBody, + Button: BrowserRootButton, + CodeBlock: BrowserRootCodeBlock, + CodeInline: BrowserRootCodeInline, + Column: BrowserRootColumn, + Container: BrowserRootContainer, + Font: BrowserRootFont, + Head: BrowserRootHead, + Heading: BrowserRootHeading, + Hr: BrowserRootHr, + Html: BrowserRootHtml, + Img: BrowserRootImg, + Link: BrowserRootLink, + Markdown: BrowserRootMarkdown, + Preview: BrowserRootPreview, + Row: BrowserRootRow, + Section: BrowserRootSection, + Text: BrowserRootText, }; describe('public entrypoint', () => { @@ -141,14 +141,16 @@ describe('public entrypoint', () => { }); }); -describe('client entrypoint', () => { +describe('browser root entrypoint', () => { it('exports DOM-safe preview components without render utilities or Tailwind', () => { - for (const [name, component] of Object.entries(clientComponentExports)) { + for (const [name, component] of Object.entries( + browserRootComponentExports, + )) { expect(component, name).toBeTypeOf('function'); } - expect('render' in client).toBe(false); - expect('compile' in client).toBe(false); - expect('Tailwind' in client).toBe(false); + expect('render' in browserRoot).toBe(false); + expect('compile' in browserRoot).toBe(false); + expect('Tailwind' in browserRoot).toBe(false); }); }); diff --git a/packages/solid-email/src/render-shim.d.ts b/packages/solid-email/src/render-shim.d.ts index 4b74509..0824def 100644 --- a/packages/solid-email/src/render-shim.d.ts +++ b/packages/solid-email/src/render-shim.d.ts @@ -95,3 +95,11 @@ declare module 'solid-js/web/dist/server.js' { ssr, } from 'solid-js/web'; } + +declare module 'solid-js/dist/server.js' { + export { createResource, Suspense } from 'solid-js'; +} + +declare module 'css-tree/dist/csstree.esm' { + export * from 'css-tree'; +} diff --git a/packages/solid-email/tsdown.config.ts b/packages/solid-email/tsdown.config.ts index cb77c89..ae76392 100644 --- a/packages/solid-email/tsdown.config.ts +++ b/packages/solid-email/tsdown.config.ts @@ -18,10 +18,11 @@ const base: Pick = { export default defineConfig([ { ...base, - // Package root stays SSR/email-rendering oriented so render(), compile(), - // and every email component are safe under browser-like import conditions. + // Package root stays SSR/email-rendering oriented. Workerd/Worker package + // conditions point at this same ESM build instead of a second server bundle. entry: ['./src/index.ts'], outDir: './dist', + platform: 'browser', plugins: [ solid({ solid: { @@ -34,9 +35,9 @@ export default defineConfig([ }, { ...base, - // Explicit ./client subpath is a DOM/CSR build for previews. It excludes - // render/compile and server-only components instead of using browser - // conditions, because browser users may still render email HTML strings. + // Browser condition uses this DOM/CSR build for previews. It excludes + // render/compile and Tailwind while keeping the public import specifier at + // the package root. entry: ['./src/client/index.ts'], outDir: './dist/client', platform: 'browser', diff --git a/skills/solid-email/README.md b/skills/solid-email/README.md index 39439fc..1f01aa9 100644 --- a/skills/solid-email/README.md +++ b/skills/solid-email/README.md @@ -24,6 +24,8 @@ skills/ - Using Tailwind utility inlining for email clients. - Writing email-client-safe markup and styles. - Integrating rendering in Node, Vite, TanStack Start, worker, and edge-style runtimes. +- Previewing components in the browser through the `@akin01/solid-email` + browser-condition root, which resolves to the DOM-safe Solid DOM build. - Keeping template code type-safe. ## Usage diff --git a/skills/solid-email/SKILL.md b/skills/solid-email/SKILL.md index a06304d..c89dd6e 100644 --- a/skills/solid-email/SKILL.md +++ b/skills/solid-email/SKILL.md @@ -79,11 +79,13 @@ const html = renderSync(() => ( ## DOM/CSR preview entrypoint -Use `@akin01/solid-email/client` only when mounting email components into the browser DOM for previews. Pair it with Solid's DOM renderer from `solid-js/web`. +Use the `@akin01/solid-email` package root when mounting email components into +the browser DOM for previews. Browser-condition bundlers resolve the root to the +DOM-safe Solid DOM build. Pair it with Solid's DOM renderer from `solid-js/web`. ```tsx import { render as mount } from 'solid-js/web'; -import { Body, Container, Heading, Html, Text } from '@akin01/solid-email/client'; +import { Body, Container, Heading, Html, Text } from '@akin01/solid-email'; mount( () => ( @@ -100,7 +102,9 @@ mount( ); ``` -Do not import `render`, `compile`, or `Tailwind` from the client subpath. Use the package root or `@solid-email/render` for server/email HTML string rendering. +Browser-condition root imports intentionally omit `render`, `compile`, and +`Tailwind`. Use default/server root imports or `@solid-email/render` for +send-ready email HTML strings. ## Compile for repeated renders diff --git a/skills/solid-email/references/RENDERING.md b/skills/solid-email/references/RENDERING.md index 485e462..276f299 100644 --- a/skills/solid-email/references/RENDERING.md +++ b/skills/solid-email/references/RENDERING.md @@ -17,11 +17,14 @@ Use `render()` by default. It supports async Solid rendering and Suspense waitin ## DOM/CSR preview mounting -Use the DOM-safe client subpath only for browser previews that mount components into a real DOM. It intentionally excludes `render`, `compile`, and `Tailwind`. +Use the `@akin01/solid-email` package root for browser previews that mount +components into a real DOM. Browser-condition bundlers resolve the root to the +DOM-safe Solid DOM build, which intentionally excludes `render`, `compile`, and +`Tailwind`. ```tsx import { render as mount } from 'solid-js/web'; -import { Body, Container, Heading, Html, Text } from '@akin01/solid-email/client'; +import { Body, Container, Heading, Html, Text } from '@akin01/solid-email'; mount( () => ( @@ -38,7 +41,8 @@ mount( ); ``` -Keep email HTML generation on the server with `@solid-email/render` or the `@akin01/solid-email` package root. The client subpath is for mounted previews, not send-ready HTML strings. +Keep send-ready email HTML generation on the server with `@solid-email/render` +or default/server imports from the `@akin01/solid-email` package root. ## Pretty HTML