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
59 changes: 59 additions & 0 deletions .charlie/instructions/pull-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Charlie instructions for jsx-email (next/v3 branch)

Repo‑specific rules that keep Charlie aligned with this codebase and review preferences. Keep diffs small, mirror existing patterns, and don’t widen scope beyond the request.

## Scope
Applies to the entire repository, with extra focus on `packages/jsx-email` and its tests.

## Context
- Monorepo using pnpm + Moon. Most tasks are run with `pnpm moon run <project>:<task>`.
- Task inheritance lives in `.moon/tasks.yml`. Each project can add/override tasks in its own `moon.yml` (for example, `packages/jsx-email/moon.yml`). The root `moon.yml` defines repository tasks and is aliased as the `repo` project.
- TypeScript uses ESM with NodeNext resolution; relative imports in source include the `.js` suffix.
- Tests use Vitest; snapshots live in `.snapshots` next to tests (custom resolver).
- GitHub Actions sets `FORCE_COLOR=1` when running tests; local runs must mirror this to avoid ANSI snapshot diffs.
- Conventional Commits are the norm (e.g., `fix(jsx-email): …`). PRs should use the repository template.
- Conventional Commit scopes map to affected project name(s) under `packages/` (e.g., `(jsx-email)`, `(plugin-inline)`, `(create-jsx-email)`). When multiple projects are affected, list each name in the scope, comma‑separated with no spaces (e.g., `(plugin-inline,plugin-pretty)`). PRs should use the repository template.
- For the ongoing `next/v3` alignment work on `<Raw>` / `<Conditional>` behavior and core correctness (tracked in #348), branch from `origin/next/v3` and open pull requests with `base` set to `next/v3` (not `main`). Mention `#348` in the PR description for traceability.

## Rules
- [R1] Stay on task. Do not add refactors, helpers, or style tweaks outside the explicit ask. Keep diffs minimal.
- [R2] Tests and snapshots:
- Run with `FORCE_COLOR=1` to match CI.
- Never change log/ANSI‑colored snapshots to “de‑colorize” or “stabilize” output. If a log snapshot fails, fix the cause or mirror CI settings; do not rewrite the assertion shape.
- Do not introduce “snapshot normalization” helpers (e.g., projecting only parts of config objects) unless a maintainer requests it.
- Place/update snapshots under `.snapshots` alongside the test file; prefer targeted assertions plus snapshots when behavior changes intentionally.
- [R3] Writing tests for `packages/jsx-email`:
- Prefer importing from package source (e.g., `../src/index.ts`) for new tests to avoid prebuild coupling. If an existing suite imports from `dist`, keep that pattern for that suite.
- Avoid adding `// @ts-ignore` to suppress React imports; tests can rely on the React automatic JSX runtime. Don’t add new ignores.
- Follow NodeNext import style in source files (relative imports include `.js`).
- [R4] Verification commands (run locally before marking a PR Ready for changes that affect code, tests, or tooling config):
- Build artifacts used by tests:
- `pnpm moon run plugin-inline:build plugin-minify:build plugin-pretty:build`
- `pnpm moon run jsx-email:build`
- Lint: `pnpm moon run repo:lint`
- Tests (jsx-email package, mirroring the `next/v3` CI setup): `pnpm moon run jsx-email:test`
- TypeScript (package): `pnpm moon run jsx-email:tsc`
- Do not change tool configs or CI to make checks pass.
- For documentation-only or `.charlie`-only changes that do not affect CI, code, or tests, you may skip these commands, but prefer running at least `pnpm moon run repo:lint` when in doubt.
- [R4.1] Linting (must run before pushing any commits):
- Run repo linting via Moon: `pnpm moon run repo:lint`.
- Do not push if linting reports errors anywhere. Fix them or coordinate a follow‑up if they are unrelated but surfaced by your changes.
- If linting reports warnings in files you modified, resolve those warnings before pushing.
- For quick checks while iterating, you may run ESLint scoped to touched files, but always run the full `repo:lint` before the final push.
- [R5] Git & PRs:
- Branch names (from humans): `{type}/{project}/{short-desc}` — for example, `fix/jsx-email/conditional-endif-slash`. Use the package with the most changes (or the intended target) for `{project}`, and let `{short-desc}` be a descriptive phrase or a short slug.
- Conventional Commits scope = affected project name(s) from `packages/`. Examples: `fix(jsx-email): …`, `refactor(plugin-inline,plugin-pretty): …`. For multiple projects, list names comma-separated with no spaces.
- Commit messages use Conventional Commits; ≤ 72 chars in the subject; no emojis.
- PRs must use the repository template as-is — Charlie is not allowed to remove sections from the template.
- Start as Draft when work is in flux; mark Ready only after local verification passes and the description reflects the actual changes.
- Don’t add novel labels. Assign and request review from the human requester when appropriate.

> Conditional/Raw specifics have been moved to a dedicated playbook. See `.charlie/playbooks/conditional-and-raw.md`.

## References
1. CI workflow (test color): `.github/workflows/test-*.yml` (see `FORCE_COLOR=1` in test steps)
2. Vitest shared config: `shared/vitest.config.ts`
3. Moon tasks inheritance: `.moon/tasks.yml`; per-project tasks: `apps/*/moon.yml`, `packages/*/moon.yml`, `test/*/moon.yml`; repository tasks: `moon.yml` (project `repo`)
4. jsx-email package tasks: `packages/jsx-email/moon.yml`
5. Conventional Commits: https://www.conventionalcommits.org/en/v1.0.0/
6. Conditional/Raw playbook: `.charlie/playbooks/conditional-and-raw.md`
33 changes: 33 additions & 0 deletions .charlie/playbooks/conditional-and-raw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Conditional + Raw playbook (renderer and tests) — next/v3 branch

Apply this when changing how `<Conditional>` or `<Raw>` render (MSO/expr tokens, wrappers, or how nested Raw is inlined).

This copy lives on the `next/v3` branch. For the ongoing alignment work on `<Raw>` / `<Conditional>` semantics and tests (tracked in #348):
- Branch from `origin/next/v3`.
- Open pull requests with `base` set to `next/v3` (not `main`).
- Mention `#348` in the PR description so reviewers can tie the change back to the alignment effort.

Rules
- Include targeted tests and keep snapshot updates minimal (only intentional markup diffs).
- Do not introduce token “centralization” helpers or adjacency tests unless a maintainer explicitly requests them.
- Import from source for new tests to avoid prebuild coupling: `import { Conditional, Raw, render } from '../src/index.ts'`.

Test guidelines
- Closer/opener integrity
- Assert exactly one opener and closer for MSO: `<!--[if mso]>` and the current closer `<![endif]/-->`.
- Add a small case for the expression path (e.g., `expression="gte mso 16"`) and assert the same closer.
- No‑duplication when nesting Raw
- For `<Conditional mso><Raw …/></Conditional>`, assert the inner payload appears exactly once and not outside the block.
- Use `lastIndexOf` for closer slicing and allow opener at index `>= 0`.
- Snapshots and colorized logs
- Run with `FORCE_COLOR=1` to match CI. Never “de‑colorize” or normalize log snapshots.

Commands (jsx-email)
- Build artifacts some suites rely on: `pnpm moon run plugin-inline:build plugin-minify:build plugin-pretty:build && pnpm moon run jsx-email:build`
- Tests (CI‑aligned for `next/v3`): `pnpm moon run jsx-email:test` (the Moon task sets `FORCE_COLOR=1` like CI)
- TypeScript: `pnpm moon run jsx-email:tsc`

References
- CI workflow (color): `.github/workflows/test-*.yml` (look for `FORCE_COLOR=1`)
- Shared Vitest config: `shared/vitest.config.ts`
- Package tasks: `packages/jsx-email/moon.yml`
37 changes: 37 additions & 0 deletions .charlie/playbooks/run-jsx-email-tests-like-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Run jsx-email tests locally exactly like CI (Moon + FORCE_COLOR) — next/v3 branch

## Overview
Run the `packages/jsx-email` test suite locally with the same settings the `next/v3` branch uses in CI so snapshots (including ANSI color) match exactly.

For the ongoing `next/v3` alignment work on `<Raw>` / `<Conditional>` behavior and core correctness (tracked in #348), branch from `origin/next/v3`, open PRs with `base` set to `next/v3` (not `main`), and mention `#348` in the PR description.

## Prerequisites
- Capabilities: GitHub + Devbox write access (local or CI).
- Tools: pnpm, Moon tasks (configured in this repo).
- Context: Some suites import from `dist/esm`, so plugin packages must be built first.

## Steps
1. Install deps and build required artifacts
- `pnpm install`
- `pnpm moon run plugin-inline:build plugin-minify:build plugin-pretty:build`
- `pnpm moon run jsx-email:build`
2. Run tests with color forced (matches CI)
- `pnpm moon run jsx-email:test` <!-- task sets FORCE_COLOR=1 in Moon config on next/v3 -->
3. Optional: update snapshots only for intentional HTML/markup changes (never for log/ANSI deltas)
- `cd packages/jsx-email && pnpm vitest -u --config ../../shared/vitest.config.ts && cd -`
4. TypeScript check (package)
- `pnpm moon run jsx-email:tsc`

## Verify
- Expect: `Test Files 44 passed`, `Tests 187 passed` (numbers may grow over time).
- No snapshot diffs for logs when `FORCE_COLOR=1` is set (the `jsx-email:test` Moon task configures this automatically).

## Rollback
- If you mistakenly updated snapshots for log/ANSI output, reset them:
- `git restore --source=origin/$(git rev-parse --abbrev-ref HEAD) -- "packages/jsx-email/test/**/.snapshots/*.snap" || true`
- Or discard all local changes: `git reset --hard`

## References
- CI workflow (color): `.github/workflows/test-*.yml` (look for `FORCE_COLOR=1`)
- Shared Vitest config: `shared/vitest.config.ts`
- Moon project tasks: `packages/jsx-email/moon.yml`
2 changes: 1 addition & 1 deletion apps/preview/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
rel="shortcut icon"
href="data:image/svg+xml;charset=UTF-8,%3csvg width='126' height='113' viewBox='0 0 126 113' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M0.199951 50V109V113H4.19995H121.8H125.8V109V50H117.8V105H8.19995V50H0.199951Z' fill='%23659CC8'/%3e%3cpath d='M0 53.429V47.4258L48.3069 22.8124V32.4176L11.2516 50.2773L11.5517 49.677V51.1778L11.2516 50.5775L48.3069 68.4372V78.0424L0 53.429Z' fill='%23659CC8'/%3e%3cpath d='M79.4367 0L54.6832 92H46.582L71.3356 0H79.4367Z' fill='%23659CC8'/%3e%3cpath d='M126 53.429L77.6931 78.0424V68.4372L114.748 50.5775L114.448 51.1778V49.677L114.748 50.2773L77.6931 32.4176V22.8124L126 47.4258V53.429Z' fill='%23659CC8'/%3e%3c/svg%3e "
/>
<title>Jsx-email Preview</title>
<title>jsx-email Preview</title>
</head>
<body class="dark:text-white">
<div id="root"></div>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/content/docs/core/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ email build ./emails/Welcome.tsx
To view info, flags, and options for the `build` command, run `email help build`.
:::

To render using the sample props you export from a template file, pass the `--use-preview-props` flag. When set, `email build` will use the `previewProps` export (if present) instead of `--props`.

### Check Command

Verify email client compatibility using [caniuse.com](https://caniemail.com/) data:
Expand All @@ -88,6 +90,8 @@ Check Complete: 14 error(s), 20 warning(s)
To view info, flags, and options for the `check` command, run `email help check`.
:::

You can also pass `--use-preview-props` so `email check` builds templates using their exported `previewProps`, keeping compatibility checks aligned with the data used for previews and builds.

### Preview Command

Launch a development server to preview your email templates with these features:
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: 4 additions & 0 deletions docs/core/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ $ cd ~/code/email-app
$ email build ./emails/Batman.tsx
```

To render using the sample props you export from a template file, pass the `--use-preview-props` flag. When set, `email build` will use the `previewProps` export (if present) instead of `--props`.

## Check

::: tip
Expand All @@ -79,6 +81,8 @@ To view info, flags, and options for the `check` command, run `email help check`
$ 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.

Example output:

```console
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: 2 additions & 0 deletions packages/jsx-email/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ tasks:
- src
- test
- package.json
env:
FORCE_COLOR: '1'
options:
cache: false
outputStyle: 'stream'
Expand Down
2 changes: 2 additions & 0 deletions packages/jsx-email/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Builds a template and saves the result
--props A JSON string containing props to be passed to the email template
This is usually only useful when building a single template, unless all of your
templates share the same props.
--use-preview-props
When set, use the \`previewProps\` exported by the template file (if present).

{underline Examples}
$ email build ./src/emails
Expand Down
16 changes: 13 additions & 3 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 @@ -34,6 +36,10 @@ Check jsx-email templates for client compatibility
{underline Usage}
$ email check <template file name>

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

{underline Examples}
$ email check ./emails/Batman.tsx
`;
Expand Down Expand Up @@ -129,7 +135,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,
writeToFile: false,
usePreviewProps: argv.usePreviewProps
},
targetPath: input[0]
});

Expand Down
35 changes: 29 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,7 +39,7 @@ 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. Defaults to .deploy
--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
Expand All @@ -55,19 +57,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 +96,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 +145,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
Loading
Loading