Skip to content
Closed
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 }}}
}
}
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
17 changes: 17 additions & 0 deletions test/cli/create-mail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ describe('create-mail', async () => {

expect(plain).toMatchSnapshot();

type PackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};

const packageJson = JSON.parse(
await readFile(join(__dirname, '.test/new/package.json'), 'utf8')
) as PackageJson;

expect(packageJson.devDependencies).toMatchObject({
react: expect.any(String),
'react-dom': expect.any(String)
});

expect(packageJson.dependencies ?? {}).not.toHaveProperty('react');
expect(packageJson.dependencies ?? {}).not.toHaveProperty('react-dom');

const contents = await readFile(join(__dirname, '.test/new/templates/email.tsx'), 'utf8');
expect(contents).toMatchSnapshot();

Expand Down
51 changes: 51 additions & 0 deletions test/cli/preview-build-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { access, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import { join, resolve } from 'node:path';

import { execa } from 'execa';

describe('cli: preview --build-path', async () => {
const outRelational = './.test/build-path';
const outAbsolute = resolve(__dirname, outRelational);
const templatePath = './.test/.deploy/emails';
const isWindows = os.platform() === 'win32';

beforeAll(async () => {
await rm(outAbsolute, { force: true, recursive: true });
});

afterAll(async () => {
await rm(outAbsolute, { force: true, recursive: true });
});

if (!isWindows) {
test('relative build path writes to an absolute path derived from the original cwd', async () => {
await execa({ cwd: __dirname, shell: true })`email create BatmanEmail --out ${templatePath} `;
const { stdout } = await execa({
cwd: __dirname,
shell: true
})`email preview ${templatePath} --build-path ${outRelational}`;

console.log(stdout);

await access(join(outAbsolute, 'index.html'));

const html = await readFile(join(outAbsolute, 'index.html'), 'utf8');
expect(html).toContain('<div id="root"></div>');
}, 60e3);
Comment on lines +23 to +35
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CLI test prints command output unconditionally via console.log(stdout). This adds noise to test runs/CI logs and can mask failures by making logs harder to read. Tests should only emit output when failing, or behind a debug flag.

Suggestion

Remove the console.log(stdout) line, or only print it when an assertion fails (e.g., wrap assertions in a try/catch and log on catch), or gate it behind an env var like DEBUG_CLI_TESTS.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

}

if (isWindows) {
test('errors when Vite root and temp build path are on different drives (Windows)', async () => {
await execa({ cwd: __dirname, shell: true })`email create BatmanEmail --out ${templatePath} `;
await expect(
execa({
cwd: __dirname,
shell: true
})`email preview ${templatePath} --build-path ${outRelational}`
).rejects.toThrow(
/Temporary directory drive letter different than root directory drive letter/
);
}, 60e3);
}
});