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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ import { WelcomeEmail } from './welcome-email';
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
`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
116 changes: 116 additions & 0 deletions e2e/integrations.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,89 @@ const renderRequireProbe = `${renderExportSmoke}
process.exit(1);
});`;

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']) {
if (typeof mod[name] !== 'function') {
throw new Error(\`missing solid-email 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',
}),
}),
}),
);

if (!html.includes('<h2') || !html.includes('Solid Email browser condition smoke')) {
throw new Error(\`missing rendered heading: \${html}\`);
}
if (!html.includes('style="width:100%"') || !html.includes('color:blue')) {
throw new Error(\`missing server-rendered style output: \${html}\`);
}

process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0));`;

const solidEmailClientDomProbe = `
const resolved = import.meta.resolve('@akin01/solid-email/client');
const { JSDOM } = await import('jsdom');
const dom = new JSDOM('<!DOCTYPE html><main id="root"></main>', {
url: 'https://solid.email/preview',
});
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;

const mod = await import('@akin01/solid-email/client');

for (const name of ['render', 'compile', 'Tailwind']) {
if (name in mod) {
throw new Error(\`unexpected client export: \${name}\`);
}
}
for (const name of ['Container', 'Heading', 'Text', 'Preview']) {
if (typeof mod[name] !== 'function') {
throw new Error(\`missing client export: \${name}\`);
}
}

const { render: mount } = await import('solid-js/web');
const root = document.getElementById('root');
const dispose = mount(
() =>
mod.Container({
style: { padding: '12px' },
children: mod.Heading({
as: 'h2',
style: { color: 'purple' },
children: mod.Text({ children: 'Client 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 (!root.querySelector('table')) {
throw new Error(\`missing mounted email layout table: \${root.innerHTML}\`);
}

dispose();
process.stdout.write(resolved.replaceAll('\\\\', '/') + '\\n', () => process.exit(0));`;

type PnpmError = ExecFileException & {
stderr?: string;
stdout?: string;
Expand Down Expand Up @@ -318,6 +401,39 @@ describe('published package integration fixtures', () => {
}
});

it('loads solid-email under browser import conditions', async () => {
const { stdout } = await execFileAsync(
'node',
[
'--conditions=browser',
'--input-type=module',
'--eval',
solidEmailBrowserImportProbe,
],
{ cwd: root },
);

expect(stdout.trim().replaceAll('\\', '/')).toContain(
'/packages/solid-email/dist/index.mjs',
);
});

it('mounts the client entrypoint in a Solid DOM preview', async () => {
const { stdout } = await execFileAsync(
'node',
[
'--conditions=browser',
'--input-type=module',
'--eval',
solidEmailClientDomProbe,
],
{ cwd: root },
);

expect(stdout.trim().replaceAll('\\', '/')).toContain(
'/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'], {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@biomejs/biome": "catalog:",
"@solid-email/render": "workspace:*",
"@types/node": "catalog:",
"jsdom": "catalog:",
"solid-js": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion packages/render/src/shared/slots.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JSX } from 'solid-js';
import { ssr } from 'solid-js/web';
import { ssr } from 'solid-js/web/dist/server.js';

const MARKER_PREFIX = '__SM_';
const CONTENT_START = `${MARKER_PREFIX}CNT_`;
Expand Down
7 changes: 6 additions & 1 deletion packages/render/src/shared/solid-js-web-server.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
declare module 'solid-js/web/dist/server.js' {
export { renderToString, renderToStringAsync } from 'solid-js/web';
export {
Dynamic,
renderToString,
renderToStringAsync,
ssr,
} from 'solid-js/web';
}
9 changes: 9 additions & 0 deletions packages/solid-email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ function WelcomeEmail() {
const html = await render(() => <WelcomeEmail />);
```

## 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
`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`.
Expand Down
10 changes: 10 additions & 0 deletions packages/solid-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@
"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": {
Expand Down
1 change: 1 addition & 0 deletions packages/solid-email/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../components/client';
18 changes: 18 additions & 0 deletions packages/solid-email/src/components/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export * from './body/index';
export * from './button/index';
export * from './code-block/index';
export * from './code-inline/index';
export * from './column/index';
export * from './container/index';
export * from './font/index';
export * from './head/index';
export * from './heading/heading.client';
export * from './hr/index';
export * from './html/index';
export * from './img/index';
export * from './link/index';
export * from './markdown/index';
export * from './preview/index';
export * from './row/index';
export * from './section/index';
export * from './text/index';
45 changes: 45 additions & 0 deletions packages/solid-email/src/components/heading/heading.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { splitProps } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import {
cls,
type IntrinsicProps,
normalizeStyle,
styleObject,
withoutClass,
} from '../shared';
import type { As } from './utils/as';
import type { Margin } from './utils/spaces';
import { withMargin } from './utils/spaces';

export type HeadingAs = As<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>;
export type HeadingProps = Readonly<IntrinsicProps<'h1'> & HeadingAs & Margin>;

export function Heading(props: HeadingProps) {
const [local, rest] = splitProps(props, [
'as',
'children',
'style',
'm',
'mx',
'my',
'mt',
'mr',
'mb',
'ml',
'class',
'className',
]);
return (
<Dynamic
component={local.as ?? 'h1'}
{...withoutClass(rest)}
class={cls(local)}
style={normalizeStyle({
...withMargin(local),
...styleObject(local.style),
})}
>
{local.children}
</Dynamic>
);
}
2 changes: 1 addition & 1 deletion packages/solid-email/src/components/heading/heading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { splitProps } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { Dynamic } from 'solid-js/web/dist/server.js';
import {
cls,
type IntrinsicProps,
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-email/src/components/tailwind/tailwind.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type CssNode, generate, List, type StyleSheet } from 'css-tree';
import { createResource, type JSX, Suspense } from 'solid-js';
import { ssr } from 'solid-js/web';
import { ssr } from 'solid-js/web/dist/server.js';
import type { Config } from 'tailwindcss';
import type { SolidStyle } from '../shared';
import { sanitizeStyleSheet } from './sanitize-stylesheet';
Expand Down
54 changes: 54 additions & 0 deletions packages/solid-email/src/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
import { describe, expect, it } from 'vitest';
import * as client 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,
} from './client';
import {
Body,
Button,
Expand Down Expand Up @@ -49,6 +70,27 @@ 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,
};

describe('public entrypoint', () => {
it('exports every public component from the package root', () => {
for (const [name, component] of Object.entries(componentExports)) {
Expand Down Expand Up @@ -98,3 +140,15 @@ describe('public entrypoint', () => {
expect(syncHtml).toContain('Sync template');
});
});

describe('client entrypoint', () => {
it('exports DOM-safe preview components without render utilities or Tailwind', () => {
for (const [name, component] of Object.entries(clientComponentExports)) {
expect(component, name).toBeTypeOf('function');
}

expect('render' in client).toBe(false);
expect('compile' in client).toBe(false);
expect('Tailwind' in client).toBe(false);
});
});
9 changes: 9 additions & 0 deletions packages/solid-email/src/render-shim.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,12 @@ declare module '@solid-email/render' {
options?: CompileSyncOptions,
): CompiledTemplate<TSlots>;
}

declare module 'solid-js/web/dist/server.js' {
export {
Dynamic,
renderToString,
renderToStringAsync,
ssr,
} from 'solid-js/web';
}
Loading
Loading