diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f5a3378..c88e59e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,7 +4,7 @@ # 1. PR merged to main → triggers build & test, and the react package's # suite on each React it supports (react-versions.yml) # 2. Each package publishes only when its code changed since its last -# release tag (Elements: v*, migrate: migrate-v*) +# release tag (Elements: v*, migrate: migrate-v*, preview: preview-v*) # 3. A reviewer approves each publish in the Actions UI (environment gate) # 4. The package is published to npm via trusted publishing (OIDC) # @@ -43,6 +43,7 @@ on: options: - react-elements - migrate + - elements-preview default: react-elements bump: description: 'Version bump type' @@ -100,6 +101,7 @@ jobs: outputs: elements: ${{ steps.detect.outputs.elements }} migrate: ${{ steps.detect.outputs.migrate }} + preview: ${{ steps.detect.outputs.preview }} steps: - name: Checkout code (with tags) @@ -124,7 +126,7 @@ jobs: exit 1 fi last() { git tag -l "$1" --sort=-v:refname | head -1; } - for pattern in 'v*' 'migrate-v*'; do + for pattern in 'v*' 'migrate-v*' 'preview-v*'; do tag=$(last "$pattern") if [ -n "$tag" ] && ! git merge-base --is-ancestor "$tag" HEAD; then echo "::notice::$tag isn't an ancestor of this commit: nothing older than it is published." @@ -146,20 +148,28 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then ELEMENTS=$([ "${{ inputs.package }}" = "react-elements" ] && descends 'v*' && echo true || echo false) MIGRATE=$([ "${{ inputs.package }}" = "migrate" ] && descends 'migrate-v*' && echo true || echo false) + PREVIEW=$([ "${{ inputs.package }}" = "elements-preview" ] && descends 'preview-v*' && echo true || echo false) else ELEMENTS=$(changed 'v*' packages/react packages/shared pnpm-workspace.yaml) # Not Elements: migrate requires the Elements range committed in its package.json (the oldest # release the converter supports), not the newest, so an Elements release doesn't republish it. MIGRATE=$(changed 'migrate-v*' packages/migrate packages/from-react-email packages/convert-core pnpm-workspace.yaml pnpm-lock.yaml) + # The preview renders with the project's own Elements (a peer dependency): an Elements release doesn't republish it. + PREVIEW=$(changed 'preview-v*' packages/preview pnpm-workspace.yaml pnpm-lock.yaml) fi # The first version of a new package is published by hand (see above). if [ "$MIGRATE" = "true" ] && ! npm view @unlayer/migrate version >/dev/null 2>&1; then echo "::notice::@unlayer/migrate isn't on npm yet: publish its first version by hand, tag it migrate-v, and add its trusted publisher." MIGRATE=false fi + if [ "$PREVIEW" = "true" ] && ! npm view @unlayer/elements-preview version >/dev/null 2>&1; then + echo "::notice::@unlayer/elements-preview isn't on npm yet: publish its first version by hand, tag it preview-v, and add its trusted publisher." + PREVIEW=false + fi echo "elements=$ELEMENTS" >> $GITHUB_OUTPUT echo "migrate=$MIGRATE" >> $GITHUB_OUTPUT - echo "Elements changed: $ELEMENTS, migrate changed: $MIGRATE" + echo "preview=$PREVIEW" >> $GITHUB_OUTPUT + echo "Elements changed: $ELEMENTS, migrate changed: $MIGRATE, preview changed: $PREVIEW" # The react package's suite on each React it supports, for this run's commit: Elements publishes only when every # one passes (test.yml runs it too, but nothing waits for that run). @@ -538,3 +548,149 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-preview: + name: Publish @unlayer/elements-preview + needs: [build-and-test, changes] + # Not after Elements: it renders with the project's own Elements, a peer dependency (>=0.2.0). + if: needs.changes.outputs.preview == 'true' + runs-on: ubuntu-latest + concurrency: + group: publish-preview + cancel-in-progress: false + environment: npm-publish + permissions: + contents: write + id-token: write + pull-requests: read + + steps: + # The commit this run built and tested, not whatever main is when the approval lands. + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + # Checked again here, where publishes run one at a time (see the Elements job). + - name: Check no newer commit was published + shell: bash + run: | + git fetch --tags --force origin + TAG=$(git tag -l 'preview-v*' --sort=-v:refname | head -1) + if [ -n "$TAG" ] && ! git merge-base --is-ancestor "$TAG" HEAD; then + echo "::error::$TAG was published from a newer commit than this run's: nothing older than it is published." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.7.0 + + - name: Setup Node.js 24 (with npm registry for OIDC) + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Update npm for trusted publishing + run: npm install -g npm@12 + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm build + + - name: Determine version bump + id: bump + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "type=${{ inputs.bump }}" >> $GITHUB_OUTPUT + else + LABELS=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" --jq '.[0].labels[].name' 2>/dev/null || echo "") + if echo "$LABELS" | grep -Fxq "release:major"; then + echo "type=major" >> $GITHUB_OUTPUT + elif echo "$LABELS" | grep -Fxq "release:minor"; then + echo "type=minor" >> $GITHUB_OUTPUT + else + echo "type=patch" >> $GITHUB_OUTPUT + fi + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine version + id: version + working-directory: packages/preview + run: | + COMMITTED=$(node -p "require('./package.json').version") + CURRENT=$(npm view @unlayer/elements-preview version 2>/dev/null || echo "$COMMITTED") + echo "current=$CURRENT" >> $GITHUB_OUTPUT + npm version "$CURRENT" --no-git-tag-version --allow-same-version + VERSION=$(npm version ${{ steps.bump.outputs.type }} --no-git-tag-version) + VERSION="${VERSION#v}" + if [ "$COMMITTED" != "$VERSION" ] && [ "$(printf '%s\n%s\n' "$VERSION" "$COMMITTED" | sort -V | tail -1)" = "$COMMITTED" ]; then + npm version "$COMMITTED" --no-git-tag-version --allow-same-version + VERSION="$COMMITTED" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Pre-publish summary + run: | + echo "## Publish Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| | |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| **Package** | \`@unlayer/elements-preview\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | ${{ steps.version.outputs.current }} → ${{ steps.version.outputs.version }} (${{ steps.bump.outputs.type }}) |" >> $GITHUB_STEP_SUMMARY + echo "| **Trigger** | ${{ github.event_name }} |" >> $GITHUB_STEP_SUMMARY + + - name: Publish to npm (trusted publishing) + working-directory: packages/preview + run: | + pnpm pack --pack-destination /tmp + TARBALL=$(ls /tmp/unlayer-elements-preview-*.tgz) + npm publish "$TARBALL" --access public + + - name: Tag release + run: | + TAG="preview-v${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git ls-remote --tags origin | awk '{print $2}' | grep -Fxq "refs/tags/$TAG"; then + echo "Tag $TAG already exists — skipping" + else + git tag "$TAG" + git push origin "$TAG" + fi + + # Not marked latest: the repository's latest release stays Elements. + - name: Create GitHub Release + run: | + TAG="preview-v${{ steps.version.outputs.version }}" + PREVIOUS=$(git ls-remote --tags --refs origin | sed 's#.*refs/tags/##' | grep -E '^preview-v[0-9]' | grep -vFx "$TAG" | sort -V | tail -1) + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists — skipping" + else + gh release create "$TAG" \ + --title "@unlayer/elements-preview ${{ steps.version.outputs.version }}" \ + --generate-notes \ + --latest=false \ + ${PREVIOUS:+--notes-start-tag "$PREVIOUS"} + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 16698f69..9b0c12fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ cd packages/react && pnpm storybook # Storybook dev server | `@unlayer/from-react-email` | `packages/from-react-email` | No (private, bundled into migrate; API at `@unlayer/migrate/react-email`) | React Email → Elements converter (codemod, runtime with merge tags, check against the original); fidelity benchmark in `bench/`, results in `FIDELITY.md` | | `@unlayer/migrate` | `packages/migrate` | Yes (npm) | React Email converter API at `@unlayer/migrate/react-email`; `npx @unlayer/migrate` CLI: migrate React Email templates, check them, write a report; `compare` for templates migrated by hand | | `@unlayer/convert-core` | `packages/convert-core` | No (private, bundled into the converters) | Elements tree, conversion report, TSX printer and content check that converters share | +| `@unlayer/elements-preview` | `packages/preview` | Yes (npm) | `elements-preview dev` / `export` / `build` / `start` / `check` / `screenshot` (the Unlayer CLI's `unlayer elements …`): a Vite SSR loader renders the project's templates with the project's own Elements and React; preview app in `ui/` (built to `dist/ui`, also served as a static build); caniemail compatibility check that separates the template's CSS from Elements' own; link and image checks; emulated dark mode. `shared/` holds what the app and the server both use: the findings' levels (`check` and the Checks drawer) and the dark mode (`screenshot --dark` loads `dist/dark-mode.js` into the page). `screenshot` drives an installed Chrome/Chromium/Edge/Brave with `puppeteer-core` | ### Component Hierarchy (strict) diff --git a/README.md b/README.md index 960d31a7..aded8613 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ const html = renderToHtml(); **Try it in your browser:** the [Elements Playground](https://examples.unlayer.com/examples/elements-playground/) renders Elements code as an email, page, or document with nothing to install. Write plain JSX, export the HTML or design JSON, and open the result in the Unlayer builder. +**Preview your templates while you edit them:** [`@unlayer/elements-preview`](./packages/preview) lists the templates in your `emails` folder, renders them with your project's Elements as you save, and checks them against email clients. `npm install -D @unlayer/elements-preview && npx elements-preview dev`. + ## Email Client Compatibility Notes ### Targeted Clients @@ -267,6 +269,7 @@ For the full API reference, component props, design patterns, and common mistake |---------|-------------|-----------| | [`@unlayer/react-elements`](./packages/react) | React components and renderers | [![npm](https://img.shields.io/npm/v/@unlayer/react-elements.svg)](https://www.npmjs.com/package/@unlayer/react-elements) | | [`@unlayer/migrate`](./packages/migrate) | Migrate React Email templates to Elements, checked against the originals | Not yet | +| [`@unlayer/elements-preview`](./packages/preview) | Preview templates in the browser as you edit them, check email client support, export to HTML | Not yet | | [`@unlayer/from-react-email`](./packages/from-react-email) | The React Email → Elements converter, as a library | Private (bundled into `@unlayer/migrate`, API at `@unlayer/migrate/react-email`) | | [`@unlayer/convert-core`](./packages/convert-core) | Shared model for converters (Elements tree, report, TSX printer, layout check) | Internal | | [`@unlayer-internal/shared-elements`](./packages/shared) | Framework-agnostic shared logic | Internal | diff --git a/packages/preview/.gitignore b/packages/preview/.gitignore new file mode 100644 index 00000000..acf3bb67 --- /dev/null +++ b/packages/preview/.gitignore @@ -0,0 +1 @@ +test/.tmp/ diff --git a/packages/preview/README.md b/packages/preview/README.md new file mode 100644 index 00000000..8a3bece8 --- /dev/null +++ b/packages/preview/README.md @@ -0,0 +1,327 @@ +# @unlayer/elements-preview + +Preview your [Unlayer Elements](https://github.com/unlayer/elements) templates in the browser while you edit them, check them against email clients (in the browser, or from a terminal, CI or a coding agent), take screenshots, and export them to HTML. + +```bash +npm install -D @unlayer/elements-preview +npx elements-preview dev +``` + +Open http://localhost:3000. Every template in `./emails` is listed, rendered with your project's own Elements and React, and updated as you save. + +## Templates + +A template is a `.tsx`, `.jsx` or `.js` file with an `export default` component (or element). These are the same rules as React Email's preview, so an `emails` folder moved from it works as it is: + +- Folders starting with `_` aren't listed: put shared components in `_components/`. +- Files in `static/` are served at `/static/`: ``. Host them elsewhere for the emails you send. +- `PreviewProps` on the component are the props it previews and exports with: + +```tsx +import { Email, Row, Column, Heading, Button } from "@unlayer/react-elements"; + +export default function Welcome({ name }: { name: string }) { + return ( + + + + Welcome, {name} + + + + + ); +} + +Welcome.PreviewProps = { name: "Ada" }; +``` + +TypeScript, JSX, your `tsconfig.json` path aliases (`@/components/...`) and imports of your other files all work as in your project. + +## The preview + +The top bar has the template's name (its render time and whether it's live are in the tooltip), the views in the middle, and on the right the preview's width and dark mode, **Checks** and **Copy for AI**. Everything the views, the checks and Copy for AI show is the selected template's: right after you pick one that takes a while to render, they say it's rendering until it's done. + +- **Preview**: the template as an email, page or document, at desktop or phone width. Drag the frame's edges for any width (it shows the width as you drag); double-click an edge to go back. +- **Dark mode** (the sun and moon): what email clients do to an email's colors for readers in dark mode. By default every color flips, as in the Gmail apps and Outlook.com; the arrow next to it switches to light colors only, as in Apple Mail and Outlook on phones. It catches unreadable text and logos that vanish. It flips the colors written on the elements themselves, as Elements writes them; a color set only by a stylesheet (text in an Html block with no color of its own, an unstyled link) keeps its light-mode color. +- **Editor**: the template's design JSON in the [Unlayer editor](https://unlayer.com), to check how it opens there. Each render loads it again; changes made in the editor aren't saved to your file. The editor comes from `editor.unlayer.com`, so this view needs an internet connection (the others don't). Images from `static/` are copied into the design it gets, since the editor's page can't load them from localhost. It opens without an Unlayer project; pass `--project-id` (or set `UNLAYER_PROJECT_ID`) to open it in yours, with your project's settings. +- **Code**: the template's source, the HTML, the plain text and the design JSON (it opens in the Unlayer builder with `loadDesign()`), highlighted, each to copy or download. +- **Checks**: the button says "All clear", or how many things there are to fix (something broken) or to check, in the color of the worst. It opens a drawer with three sections: + - **Rendering**: what Elements and React report while rendering (a column too many, a block left out), and a design JSON (the template then won't open in the Unlayer editor) or plain text that can't be made. A template that doesn't compile or throws shows its error, with the file, line and code, in place of the preview, and so does one that doesn't finish rendering in 30 seconds (see [Render timeout](#render-timeout)); the preview keeps running and recovers when you fix the file. + - **Email clients**: the HTML and CSS the email uses that Gmail, Outlook, Apple Mail, Yahoo Mail, Samsung Email or Thunderbird don't support, from [caniemail.com](https://www.caniemail.com), with the block it's on and the template lines it likely comes from. Elements' own email HTML uses some CSS on purpose, with fallbacks for the clients that ignore it; those are listed apart, as handled by Elements. + - **Links & images**: links that go nowhere, relative or `http:` URLs, links to localhost, images without alt text, SVG and `data:` images most clients block, `/static/` images to host before sending, and HTML over Gmail's 102 KB clipping limit. Each URL is requested, so broken links (404), hosts that don't exist, non-images and large images show too. +- **Props** (the drawer's other tab): render with other props than `PreviewProps`, without changing the file. +- **Copy for AI**: the template, its problems, compatibility and link findings as one prompt to paste into an AI assistant. + +`elements-preview check` runs the same checks from a terminal, and `elements-preview screenshot --dark` takes the same dark mode. + +Keyboard shortcuts (also in the tooltips): / filters the templates, 1 2 3 switch to Preview, Editor and Code, D switches between desktop and phone width, T turns dark mode on and off, and . opens and closes the checks. + +The preview follows your system's light or dark appearance. + +## Commands + +### `elements-preview dev` + +| Option | Default | | +| --- | --- | --- | +| `--dir ` | `emails` | The templates folder | +| `--port ` | `3000` | The port. When it's taken, the next free one is used, unless you set it. | +| `--open` | | Open the preview in your browser | +| `--project-id ` | `UNLAYER_PROJECT_ID`, or none | The Unlayer project the Editor view opens designs in | +| `--render-timeout ` | `30` | Seconds a template may take to render before it fails (see [Render timeout](#render-timeout)) | + +The server listens on localhost only and answers only requests addressed to localhost. + +### `elements-preview export` + +Renders every template with its `PreviewProps` to `out/.html` (`emails/billing/receipt.tsx` → `out/billing/receipt.html`), and copies `static/`. Existing files with the same names are replaced; nothing else in the folder is deleted. + +| Option | Default | | +| --- | --- | --- | +| `--dir ` | `emails` | The templates folder | +| `--outDir ` | `out` | Where to write the files | +| `--pretty` | | Format the HTML for reading | +| `--plainText` | | Write plain text instead of HTML | +| `--design` | | Also write each template's design JSON (`.design.json`) for the Unlayer builder | +| `--extension ` | `html` | The file extension, e.g. `blade.php` (`txt` with `--plainText`) | +| `--render-timeout ` | `30` | Seconds a template may take to render before it fails (see [Render timeout](#render-timeout)) | + +It exits with 1 when a template fails, after exporting the others; with `--plainText`, a template whose plain text can't be made fails too, and gets no file. It exits with 2, before writing anything, when two templates would write the same file (`welcome.jsx` and `welcome.tsx` both export to `welcome.html`): rename one of them. Export is for backends that can't run JavaScript, or platforms that only take HTML; when your code can call Elements, render at send time with the real data instead (`renderToHtml`). + +### `elements-preview check` + +Renders templates with their `PreviewProps` and checks them as the preview's **Checks** do, without a browser: for a terminal, a CI job or a coding agent that just edited a template. + +```bash +npx elements-preview check # every template +npx elements-preview check welcome.tsx --json # one, as JSON +``` + +For each template it reports render errors (with the file and line), what Elements and React warn about, a design JSON that can't be made (the template won't open in the Unlayer editor) or plain text that can't, the CSS and HTML the template brings in that email clients don't support (with the template lines it likely comes from), links and images, and HTML over Gmail's clipping size. The levels are the preview's: **to fix** is something broken (a render error, a block Elements left out, no design JSON, a link that goes nowhere or answers 404, a `javascript:` or relative URL); **to check** is worth a look (warnings, compatibility, placeholder or `http:` URLs, Gmail clipping); notes are for information. + +``` +✗ showcase/needs-fixes.tsx (email, 10 KB): 3 to fix, 5 to check, 1 note + fix Paragraph 1 · “terms” → # · This link goes nowhere: give it a URL. + fix Button 1 · “Click me” → javascript:alert(1) · Email clients don't run javascript: links. + check Html 1 · line 13 · `display:flex` isn't supported in Outlook (2 clients) and only partly in Gmail (1 client): remove it or add a fallback. +✓ receipt.tsx (email, 16 KB): 1 note + +Checked 2 templates: 3 to fix, 5 to check. (2 notes: --json lists them) +``` + +Name templates by their path in the templates folder (`welcome.tsx`) or from the working directory (`emails/welcome.tsx`), with or without the extension, or name a folder of them (`billing`). With none, it checks every template. + +| Option | Default | | +| --- | --- | --- | +| `--dir ` | `emails` | The templates folder | +| `--json` | | Print the report as JSON (below) | +| `--strict` | | Fail on what's to check too, not only on what's to fix | +| `--offline` | | Don't request the links' and images' URLs; check the HTML only | +| `--render-timeout ` | `30` | Seconds a template may take to render before it fails (see [Render timeout](#render-timeout)) | + +| Exit code | | +| --- | --- | +| `0` | Nothing to fix (with `--strict`, nothing to check either) | +| `1` | Something to fix (with `--strict`, or to check) | +| `2` | The check couldn't run: an option, folder or template that isn't right, a folder with no templates, or Elements not installed. With `--json`, stderr has `{"error": "…"}` | + +#### The JSON report + +```json +{ + "ok": false, + "strict": false, + "network": true, + "templates": [ + { + "path": "showcase/needs-fixes.tsx", + "file": "emails/showcase/needs-fixes.tsx", + "ok": false, + "mode": "email", + "bytes": 10342, + "toFix": [ + { + "kind": "link", + "level": "error", + "message": "Email clients don't run javascript: links.", + "block": "Button 1", + "url": "javascript:alert(1)", + "label": "Click me" + } + ], + "toCheck": [ + { + "kind": "client", + "level": "warning", + "message": "`display:flex` isn't supported in Outlook (2 clients) and only partly in Gmail (1 client): remove it or add a fallback.", + "block": "Html 1", + "line": 13, + "lines": [13], + "url": "https://www.caniemail.com/features/css-display-flex/", + "feature": "display:flex", + "support": "none", + "clients": [{ "client": "outlook.windows", "label": "Outlook (Windows)", "support": "none" }] + } + ], + "notes": [] + } + ], + "summary": { "templates": 1, "toFix": 3, "toCheck": 5, "notes": 1 } +} +``` + +- `ok`: what the exit code says (`true` is 0). Each template's `ok` is the same for it. +- `templates`: worst first. `path` is in the templates folder, `file` from the working directory. `mode` is `email`, `web` (a `Page`) or `document`, and `bytes` the size of the HTML; both are `null` when the template doesn't render. +- `toFix`, `toCheck` and `notes` hold findings of `level` `error`, `warning` and `info`. Each has a `kind` and a `message`, and when they apply: + +| Field | | +| --- | --- | +| `kind` | `render` (doesn't compile or throws, or Elements left a block out), `warning` (from Elements or React), `log` (other console output: what the template and the modules it imports log, also while they load; it's never printed, so stdout has only the JSON), `design` (no design JSON), `text` (no plain text), `client` (email client compatibility), `link`, `image`, `size` (Gmail clipping) | +| `block` | The Elements block: `Button 1`, `Html 2` (a `client` finding lists every block, comma-separated) | +| `file`, `line`, `column`, `frame` | A `render` error's file (from the working directory), position and surrounding code | +| `line`, `lines` | A `client` finding's first template line, and every line that likely brings it in | +| `url`, `label`, `status` | A link or image: its URL, its text or alt text, and the HTTP status it answered with. For a `client` finding, `url` is its caniemail.com page | +| `feature`, `support`, `clients` | A `client` finding: caniemail's name for it, `none` or `partial`, and each client with its support | + +New fields may be added; existing ones keep their meaning. + +### `elements-preview screenshot` + +Saves full-length PNGs of templates rendered with their `PreviewProps`, to look at the layout without opening the preview: `screenshots/