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
8 changes: 8 additions & 0 deletions apps/web/src/content/docs/core/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ $ email check ./emails/Batman.tsx

To check templates using their exported `previewProps`, pass the `--use-preview-props` flag so the check runs against the same sample data as your previews and builds.

To limit compatibility checks to specific email clients, pass `--email-clients` with a comma-separated list of caniemail client names or globs:

```console
$ email check ./emails/Batman.tsx --email-clients gmail.*,outlook.*
```

Default check clients can also be configured in `jsx-email.config.*` with `check.emailClients`. The CLI flag takes precedence over config when both are present.

Example output:

```console
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/content/docs/core/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,32 @@ export const config = defineConfig(async () => {

jsx-email configuration files support the following properties:

```ts
check?: {
emailClients?: string[]
}
```

_Optional_. Allows the configuration file to specify the default email clients used by the CLI's `check` command.

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

export const config = defineConfig({
check: {
emailClients: ['gmail.*', 'outlook.*']
}
});
```

The `emailClients` values use the same client names and glob patterns supported by [caniemail](https://www.caniemail.com/). Exact clients such as `gmail.desktop-webmail`, provider globs such as `outlook.*`, platform globs such as `*.ios`, and `*` for all clients are supported.

The CLI's `--email-clients` flag takes precedence over this config value when provided:

```console
$ email check ./emails/welcome.tsx --email-clients apple-mail.*,gmail.*
```

```ts
esbuild?: {
plugins?: Plugin[]
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/content/docs/v3/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,10 @@ email check ./emails/welcome.tsx --use-preview-props
`email check` can limit compatibility validation to a comma-separated list of email clients.

```sh
email check ./emails/welcome.tsx --email-clients gmail,outlook
email check ./emails/welcome.tsx --email-clients gmail.*,outlook.*
```

The v3 check command remains caniemail-based.
The v3 check command remains caniemail-based. Default check clients can also be configured with `check.emailClients` in `jsx-email.config.*`.

## New Components

Expand Down
2 changes: 1 addition & 1 deletion packages/jsx-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"@vitejs/plugin-react": "catalog:",
"autoprefixer": "catalog:",
"bwip-js": "^4.9.2",
"caniemail": "1.0.0",
"caniemail": "2.0.2",
"chalk": "5.4.1",
"chalk-template": "catalog:",
"classnames": "2.5.1",
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
@@ -1,6 +1,12 @@
import { lstat } from 'node:fs/promises';

import { type IssueGroup, caniemail, groupIssues, sortIssues } from 'caniemail';
import {
type CanIEmailOptions,
type IssueGroup,
caniemail,
groupIssues,
sortIssues
} from 'caniemail';
import chalk from 'chalk';
import chalkTmpl from 'chalk-template';
import stripAnsi from 'strip-ansi';
Expand All @@ -13,6 +19,7 @@ import {
string
} from 'valibot';

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

import { buildTemplates } from './build.js';
Expand All @@ -26,6 +33,7 @@ const CheckOptionsStruct = object({
});

type CheckOptions = Infer<typeof CheckOptionsStruct>;
type EmailClients = CanIEmailOptions['clients'];

const defaultEmailClients = [
'apple-mail.*',
Expand All @@ -34,7 +42,7 @@ const defaultEmailClients = [
'protonmail.*',
'hey.*',
'fastmail.*'
];
] satisfies EmailClients;

export const help = chalkTmpl`
{blue email check}
Expand Down Expand Up @@ -81,10 +89,15 @@ const formatIssue = (group: IssueGroup): string => {
return chalkTmpl`${preamble}${title}:${footnotes}\n${indent}{dim ${clients.join(`\n${indent}`)}}\n`;
};

const runCheck = (fileName: string, html: string, emailClients: string[]) => {
const parseEmailClients = (emailClients?: string): EmailClients =>
emailClients
? (emailClients.split(',') as EmailClients)
: (current().check?.emailClients ?? [...defaultEmailClients]);

const runCheck = (fileName: string, html: string, emailClients: EmailClients) => {
const bytes = Buffer.byteLength(html, 'utf8');
const htmlSize = formatBytes(bytes);
const result = caniemail({ clients: emailClients as any, html });
const result = caniemail({ clients: emailClients, html });
const { issues, success } = result;
const { errors, warnings } = issues;
const counts = {
Expand All @@ -93,7 +106,7 @@ const runCheck = (fileName: string, html: string, emailClients: string[]) => {
warnings: 0
};

if (success && !issues.warnings) return;
if (success && !warnings.size) return;

log(chalkTmpl`{underline ${fileName}} → HTML: ${htmlSize}\n`);

Expand All @@ -105,7 +118,7 @@ const runCheck = (fileName: string, html: string, emailClients: string[]) => {
counts.warnings += 1;
}

if (errors?.size || warnings?.size) {
if (errors.size || warnings.size) {
const groupedErrors = groupIssues(errors);
const groupedWarnings = groupIssues(warnings);
const sorted = sortIssues([...groupedErrors, ...groupedWarnings]);
Expand Down Expand Up @@ -155,7 +168,7 @@ export const command: CommandFn = async (argv: CheckOptions, input) => {

log();

const emailClients = argv.emailClients ? argv.emailClients.split(',') : defaultEmailClients;
const emailClients = parseEmailClients(argv.emailClients);

runCheck(file.fileName, file.html!, emailClients);

Expand Down
8 changes: 8 additions & 0 deletions packages/jsx-email/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { pathToFileURL } from 'node:url';

import type { MethodFactoryLevels } from '@dot/log';
import { AssertionError } from 'assert';
import type { CanIEmailOptions } from 'caniemail';
import chalk from 'chalk-template';
import { lilconfig } from 'lilconfig';

Expand All @@ -11,7 +12,12 @@ import { getPluginLog, log } from './log.js';
import { type JsxEmailPlugin, type PluginInternal, pluginSymbol } from './plugins.js';
import type { ESBuildOptions, RenderOptions } from './types.js';

export interface CheckOptions {
emailClients?: CanIEmailOptions['clients'];
}

export interface JsxEmailConfig {
check?: CheckOptions;
esbuild?: ESBuildOptions;
logLevel?: MethodFactoryLevels;
plugins: JsxEmailPlugin[];
Expand Down Expand Up @@ -293,5 +299,7 @@ export const mergeConfig = async (a: Partial<JsxEmailConfig>, b: Partial<JsxEmai
render: { ...a.render, ...b.render }
} as JsxEmailConfig;

if (a.check || b.check) result.check = { ...a.check, ...b.check };

return result;
};
39 changes: 37 additions & 2 deletions packages/jsx-email/test/cli/check-use-preview-props.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { resolve } from 'node:path';

import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { defaults, setConfig } from '../../src/config.js';
import { previewProps } from './fixtures/cli-preview-props-template.js';

const caniemailMock = vi.fn(() => ({
issues: { errors: new Map(), warnings: undefined },
issues: { errors: new Map(), warnings: new Map() },
success: true
}));

Expand All @@ -18,6 +19,10 @@ vi.mock('caniemail', () => ({
const templatePath = resolve(__dirname, 'fixtures/cli-preview-props-template.tsx');

describe('cli check --use-preview-props', () => {
afterEach(() => {
setConfig(defaults);
});

it('builds using template.previewProps when flag is set', async () => {
(globalThis as any).__jsxEmailCliPreviewProps = undefined;
caniemailMock.mockClear();
Expand All @@ -35,4 +40,34 @@ describe('cli check --use-preview-props', () => {
const [{ html }] = caniemailMock.mock.calls[0] as [{ html: string }];
expect(html).toContain('batman');
});

it('uses check.emailClients from config when the flag is omitted', async () => {
(globalThis as any).__jsxEmailCliPreviewProps = undefined;
setConfig({ ...defaults, check: { emailClients: ['gmx.*'] } });
caniemailMock.mockClear();

const { command } = await import('../../src/cli/commands/check.js');

const result = await command({ usePreviewProps: true } as any, [templatePath]);

expect(result).toBe(true);
expect(caniemailMock).toHaveBeenCalledWith(expect.objectContaining({ clients: ['gmx.*'] }));
});

it('prefers --email-clients over check.emailClients from config', async () => {
setConfig({ ...defaults, check: { emailClients: ['gmx.*'] } });
caniemailMock.mockClear();

const { command } = await import('../../src/cli/commands/check.js');

const result = await command(
{ emailClients: 'gmail.*,outlook.*', usePreviewProps: true } as any,
[templatePath]
);

expect(result).toBe(true);
expect(caniemailMock).toHaveBeenCalledWith(
expect.objectContaining({ clients: ['gmail.*', 'outlook.*'] })
);
});
});
10 changes: 10 additions & 0 deletions packages/jsx-email/test/config/define-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ describe('defineConfig', async () => {
expect(config).toMatchSnapshot();
});

test('check clients', async () => {
const config = await defineConfig({
check: {
emailClients: ['gmail.*', 'outlook.*']
}
});

expect(config.check?.emailClients).toEqual(['gmail.*', 'outlook.*']);
});

test('minify and pretty', async () => {
const config = await defineConfig({
render: {
Expand Down
Loading
Loading