Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/preview/app/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Link, useLocation } from 'react-router-dom';
import { useAppStore } from '../composables/useAppStore';
import type { TemplatePart } from '../lib/types';

import { Logo } from './Logo';
import { Logo } from './logo';
import { Separator } from './ui/Separator';

interface DirectoryTreeProps {
Expand Down
2 changes: 1 addition & 1 deletion apps/preview/app/src/layouts/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Outlet } from 'react-router-dom';

import { Header, Sidebar } from '../components/Sidebar';
import { Header, Sidebar } from '../components/sidebar';

export const Shell = () => (
<>
Expand Down
2 changes: 1 addition & 1 deletion docs/components/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This component is wrapper around [QuickChart API](https://quickchart.io/) for ge
Add the graph component to your email template.

```jsx
import { Html, Body, Section, Graph } from 'jsx-email';
import { Body, Graph, Html, Section } from 'jsx-email';

const Email = () => {
return (
Expand Down
4 changes: 2 additions & 2 deletions docs/core/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ const compiledFiles = await compile({ files: [templatePath], hashFiles: false, o
Once compiled into a bundle, the file can be imported and passed to render such like:

```jsx
import { Template } from './.compiled/batman.js';

import { render } from 'jsx-email';

import { Template } from './.compiled/batman.js';

const html = render(<Template />);
```

Expand Down
1 change: 1 addition & 0 deletions docs/core/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ To instruct a render to use plugins, utilize a [Configuration File](/docs/core/c

```js
import { defineConfig } from 'jsx-email/config';

import { somePlugin } from './plugins/some-plugin';

export const config = defineConfig({
Expand Down
3 changes: 2 additions & 1 deletion packages/create-mail/generators/package.json.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"jsx-email": "^2.0.0"
},
"devDependencies": {
"react": "^19.1.0"{{{ typeDep }}}
"react": "^19.1.0",
"react-dom": "^19.1.0"{{{ typeDep }}}
}
}
2 changes: 1 addition & 1 deletion packages/create-mail/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ tasks:
runDepsInParallel: false

copy:
command: cp -r generators dist
command: rm -rf dist/generators/templates && cp -r generators dist
options:
cache: false

Expand Down
5 changes: 3 additions & 2 deletions packages/jsx-email/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import os from 'node:os';
import chalkTmpl from 'chalk-template';
import { globby } from 'globby';
import micromatch from 'micromatch';
import { basename, dirname, extname, join, posix, resolve, win32 } from 'path';
import { basename, dirname, extname, join, posix, relative, resolve, win32 } from 'path';
import { isWindows } from 'std-env';
import { pathToFileURL } from 'url';
import type { InferOutput as Infer } from 'valibot';
Expand Down Expand Up @@ -121,8 +121,9 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
const templateName = basename(path, fileExt).replace(/-[^-]{8}$/, '');
const component = componentExport(renderProps);
const baseDir = dirname(path);
const relativeBaseDir = outputBasePath ? relative(outputBasePath, baseDir) : '';
const writePath = outputBasePath
? join(out!, baseDir.replace(outputBasePath, ''), templateName)
? join(out!, relativeBaseDir, templateName)
: join(out!, templateName);
// const writePath = outputBasePath
// ? join(out!, baseDir.replace(outputBasePath, ''), templateName + extension)
Expand Down
27 changes: 20 additions & 7 deletions packages/jsx-email/src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type IssueGroup, caniemail, groupIssues, sortIssues } from 'caniemail';
import chalk from 'chalk';
import chalkTmpl from 'chalk-template';
import stripAnsi from 'strip-ansi';
import { type InferOutput as Infer, parse as assert, object } from 'valibot';
import { type InferOutput as Infer, parse as assert, boolean, object, optional } from 'valibot';

import { formatBytes, gmailByteLimit, gmailBytesSafe } from '../helpers.js';

Expand All @@ -13,7 +13,9 @@ import { type CommandFn } from './types.js';

const { error, log } = console;

const CheckOptionsStruct = object({});
const CheckOptionsStruct = object({
usePreviewProps: optional(boolean())
});

type CheckOptions = Infer<typeof CheckOptionsStruct>;

Expand All @@ -32,17 +34,24 @@ export const help = chalkTmpl`
Check jsx-email templates for client compatibility

{underline Usage}
$ email check <template file name>
$ email check <template file name> [...options]

{underline Options}
--use-preview-props
When set, use the \`previewProps\` exported by the template file (if present).

{underline Examples}
$ email check ./emails/Batman.tsx
$ email check ./emails/Batman.tsx --use-preview-props
`;

const formatNotes = (notes: string[], indent: string) => {
if (!notes.length) return '';
const noteLines = (notes as string[]).join(`\n${'.'.repeat(indent.length)}**`);
console.log({ noteLines });
return chalkTmpl`\n${indent}{cyan Notes}:\n${'.'.repeat(indent.length)}asshole\n`;

const noteIndent = `${indent} `;
const noteLines = notes.map((note) => `${noteIndent}${chalk.dim(note)}`).join('\n');

return `\n${indent}${chalk.cyan('Notes')}:\n${noteLines}`;
};

const formatIssue = (group: IssueGroup): string => {
Expand Down Expand Up @@ -129,7 +138,11 @@ export const command: CommandFn = async (argv: CheckOptions, input) => {
log(chalkTmpl`{blue Checking email template for Client Compatibility...}\n`);

const [file] = await buildTemplates({
buildOptions: { showStats: false, writeToFile: false },
buildOptions: {
showStats: false,
usePreviewProps: argv.usePreviewProps,
writeToFile: false
},
targetPath: input[0]
});

Expand Down
36 changes: 30 additions & 6 deletions packages/jsx-email/src/cli/commands/preview.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* eslint-disable no-use-before-define */
import { AssertionError } from 'node:assert';
import { existsSync } from 'node:fs';
import { mkdir, rmdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import os from 'node:os';
import { isAbsolute, join, resolve, win32 } from 'node:path';

import react from '@vitejs/plugin-react';
import chalk from 'chalk-template';
Expand All @@ -11,7 +13,7 @@ import { parse as assert } from 'valibot';
import { type InlineConfig, createServer, build as viteBuild } from 'vite';

import { log } from '../../log.js';
import { buildForPreview, writePreviewDataFiles } from '../helpers.js';
import { buildForPreview, originalCwd, writePreviewDataFiles } from '../helpers.js';
import { reloadPlugin } from '../vite-reload.js';
import { staticPlugin } from '../vite-static.js';
import { watch } from '../watcher.js';
Expand All @@ -37,14 +39,15 @@ Starts the preview server for a directory of email templates
$ email preview <template dir path> [...options]

{underline Options}
--build-path An absolute path. When set, builds the preview as a deployable app and saves to disk
--build-path When set, builds the preview as a deployable app and saves to disk
--exclude A micromatch glob pattern that specifies files to exclude from the preview
--host Allow thew preview server to listen on all addresses (0.0.0.0)
--no-open Do not open a browser tab when the preview server starts
--port The local port number the preview server should run on. Default: 55420

{underline Examples}
$ email preview ./src/templates --port 55420
$ email preview ./src/templates --build-path ./.deploy
$ email preview ./src/templates --build-path /tmp/email-preview
`;

Expand All @@ -55,19 +58,19 @@ const buildDeployable = async ({ argv, targetPath }: PreviewCommonParams) => {
);
}

const { basePath = './', buildPath } = argv;
const { basePath = './', buildPath = './.deploy' } = argv;
const common = { argv, targetPath };
await prepareBuild(common);
const config = await getConfig(common);
const outDir = isAbsolute(buildPath) ? buildPath : resolve(join(originalCwd, buildPath));

await viteBuild({
...config,
base: basePath,
build: {
minify: false,
outDir: buildPath,
outDir,
rollupOptions: {
external: ['react/jsx-runtime'],
output: {
manualChunks: {}
}
Expand All @@ -94,6 +97,22 @@ const getConfig = async ({ argv, targetPath }: PreviewCommonParams) => {

log.debug(`Vite Root: ${root}`);

// On Windows, Vite's import.meta.glob cannot cross drive letters. If the
// preview app root and the temporary build directory are on different
// drives (e.g., D: vs C:), fail fast with a helpful error.
if (os.platform() === 'win32') {
const rootDrive = getDriveLetter(root);
const buildDrive = getDriveLetter(buildPath);
if (rootDrive && buildDrive && rootDrive !== buildDrive) {
log.error(
`jsx-email preview cannot run on Windows when the application root directory and the system temporary directory are on different drive letters. Please consider using WSL`
);
throw new AssertionError({
message: `Temporary directory drive letter different than root directory drive letter`
});
}
}

newline();
log.info(chalk`{blue Starting build...}`);

Expand Down Expand Up @@ -127,6 +146,11 @@ const getConfig = async ({ argv, targetPath }: PreviewCommonParams) => {
return config;
};

const getDriveLetter = (path: string) => {
if (os.platform() !== 'win32') return null;
return win32.parse(path).root.slice(0, 2).toUpperCase();
};

const prepareBuild = async ({ targetPath, argv }: PreviewCommonParams) => {
const buildPath = await getTempPath('preview');
const { exclude } = argv;
Expand Down
4 changes: 2 additions & 2 deletions packages/jsx-email/src/cli/vite-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { extname } from 'node:path';

import { globby } from 'globby';
import mime from 'mime-types';
import type { PluginOption, ViteDevServer } from 'vite';
import { type PluginOption, type ViteDevServer, normalizePath } from 'vite';

interface ViteStaticOptions {
paths: string[];
Expand Down Expand Up @@ -50,7 +50,7 @@ interface MiddlwareParams {
const middleware = async (params: MiddlwareParams) => {
const { options, server } = params;
const { paths } = options;
const files = await globby(paths);
const files = await globby(paths.map((path) => normalizePath(path)));

return () => {
server.middlewares.use(async (req, res, next) => {
Expand Down
54 changes: 24 additions & 30 deletions packages/jsx-email/src/components/conditional.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,32 @@
import React, { Suspense } from 'react';
import React from 'react';

import { jsxToString } from '../renderer/jsx-to-string.js';
import { useData } from '../renderer/suspense.js';
import type { JsxEmailComponent } from '../types.js';

declare module 'react/jsx-runtime' {
namespace JSX {
interface IntrinsicElements {
// @ts-ignore
'jsx-email-cond': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & {
'data-expression'?: string;
'data-head'?: boolean;
'data-mso'?: boolean;
},
HTMLElement
>;
}
}
}

export interface ConditionalProps {
children?: React.ReactNode;
expression?: string;
head?: boolean;
mso?: boolean;
}

const notMso = (html: string) => `<!--[if !mso]><!-->${html}<!--<![endif]-->`;

const comment = (expression: string, html: string) => `<!--[if ${expression}]>${html}<![endif]-->`;

const Renderer = (props: ConditionalProps) => {
const { children, mso, head } = props;
let { expression } = props;
const html = useData(props, () => jsxToString(<>{children}</>));
let innerHtml = '';

if (mso === false) innerHtml = notMso(html);
else if (mso === true && !expression) expression = 'mso';
if (expression) innerHtml = comment(expression, html);

const Component = head ? 'head' : 'jsx-email-cond';

// @ts-ignore
// Note: This is perfectly valid. TS just expects lowercase tag names to match a specific type
return <Component dangerouslySetInnerHTML={{ __html: innerHtml }} />;
};

export const Conditional: JsxEmailComponent<ConditionalProps> = (props) => {
const { children, expression, mso } = props;
const { children, expression, mso, head } = props;

if (typeof expression === 'undefined' && typeof mso === 'undefined')
throw new RangeError(
Expand All @@ -45,12 +38,13 @@ export const Conditional: JsxEmailComponent<ConditionalProps> = (props) => {
'jsx-email: Conditional expects the `expression` or `mso` prop to be defined, not both'
);

// Always render a JSX custom element with data-* markers.
// A rehype plugin will replace this element with proper conditional comments.
// @ts-ignore - lower-case custom element tag is valid
return (
<>
<Suspense fallback={<div>waiting</div>}>
<Renderer {...props}>{children}</Renderer>
</Suspense>
</>
<jsx-email-cond data-mso={mso} data-expression={expression} data-head={head}>
{children}
</jsx-email-cond>
);
};

Expand Down
15 changes: 5 additions & 10 deletions packages/jsx-email/src/components/head.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { BaseProps, JsxEmailComponent } from '../types.js';
import { debug } from '../debug.js';
import type { BaseProps, JsxEmailComponent } from '../types.js';

import { Conditional } from './conditional.js';
import { Raw } from './raw.js';

export interface HeadProps extends BaseProps<'head'> {
enableFormatDetection?: boolean;
Expand All @@ -27,15 +28,9 @@ export const Head: JsxEmailComponent<HeadProps> = ({
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
)}
{children}
<Conditional
head
mso
children={
// prettier-ignore
// @ts-expect-error: element don't exist
<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>
}
/>
<Conditional head mso>
<Raw content="<xml><o:OfficeDocumentSettings><o:AllowPNG /><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml>" />
</Conditional>
</head>
);

Expand Down
2 changes: 1 addition & 1 deletion packages/jsx-email/src/renderer/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const compile = async (options: CompileOptions): Promise<CompileResult[]>
if (!entryPoint) return null;
return {
entryPoint,
path: resolve('/', path)
path: resolve(originalCwd, path)
};
})
.filter<CompileResult>(Boolean as any);
Expand Down
Loading