Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
e97a41c
feat(preview): add @unlayer/elements-preview, a local preview for Ele…
ivoIturrieta Oct 15, 2026
aa1e4ee
ci(release): publish @unlayer/elements-preview after its first version
ivoIturrieta Oct 16, 2026
d675925
fix(preview): say a static build doesn't update as you edit
ivoIturrieta Oct 16, 2026
23dfed4
fix(preview): count what's to fix and what's to check apart
ivoIturrieta Oct 16, 2026
2cdf96c
fix(preview): don't point at CLI commands that aren't released
ivoIturrieta Oct 16, 2026
7ccb583
fix(preview): fix what review found in builds, exports, checks and wa…
ivoIturrieta Oct 16, 2026
b4d38db
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 18, 2026
7105460
fix(preview): request an image's URL when its alt text is empty
ivoIturrieta Oct 20, 2026
7340efe
test(preview): edit text the footer has in the live-reload test
ivoIturrieta Oct 20, 2026
ca6aec8
fix(preview): capture what templates log while they load
ivoIturrieta Oct 20, 2026
8a8fa88
fix(preview): fail a template that doesn't finish rendering
ivoIturrieta Oct 20, 2026
2f39fd2
feat(preview): copy and download from the Code view only
ivoIturrieta Oct 20, 2026
0e540d3
fix(preview): show only the selected template's render
ivoIturrieta Oct 20, 2026
70e7a34
fix(preview): fetch the templates again when the server is back
ivoIturrieta Oct 20, 2026
ee215d0
feat(preview): use Unlayer's logo in the sidebar and the browser tab
ivoIturrieta Oct 20, 2026
003e8e9
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 20, 2026
02e87a0
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 20, 2026
fd6e8c7
fix(preview): write a command's output out before exiting past a stuc…
ivoIturrieta Oct 20, 2026
43926bf
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
f7669e5
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
d447ecc
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
ee2b9e5
ci(preview): fail the preview's publish where its tarball has no LICENSE
ivoIturrieta Oct 22, 2026
ff0d0cf
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
4c4843a
ci(preview): check the packed tarball has its LICENSE
ivoIturrieta Oct 22, 2026
00e03fb
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
8da7976
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 22, 2026
37dd4f0
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
4c6a62e
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
15e91eb
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
d692850
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
e62949d
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
1152fd7
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
4abc17f
test(preview): expect Elements' URL that throws for a javascript: link
ivoIturrieta Oct 23, 2026
e3e9a86
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
6ba7126
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
9ddb9c0
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 23, 2026
e4fe156
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
8ef0b2d
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
dee16ba
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
bfa9a72
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
120f3eb
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
efb78d3
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
ac356cf
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
279145f
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
30a6f5d
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
8e43379
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
57f99b8
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 24, 2026
3508965
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 25, 2026
ebc03bd
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 25, 2026
b0c1977
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 27, 2026
e004c1b
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 27, 2026
effa772
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 27, 2026
96dfa4f
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 28, 2026
97c5719
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 28, 2026
89c86e2
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 28, 2026
2f783e5
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
08a375c
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
0cac6e9
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
75c45b9
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
19a17d6
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
3a3488b
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
8d634e2
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
6b18d93
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
52f37be
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
eaaa173
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
9baf6e9
Merge branch 'feat/react-elements-0.3' into feat/elements-preview
ivoIturrieta Oct 29, 2026
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
163 changes: 160 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
#
Expand Down Expand Up @@ -43,6 +43,7 @@ on:
options:
- react-elements
- migrate
- elements-preview
default: react-elements
bump:
description: 'Version bump type'
Expand Down Expand Up @@ -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)
Expand All @@ -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."
Expand All @@ -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<version>, 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<version>, 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).
Expand Down Expand Up @@ -541,3 +551,150 @@ 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)
tar tzf "$TARBALL" | grep -qx package/LICENSE || { echo "::error::$TARBALL has no LICENSE"; exit 1; }
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 }}
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,14 @@ jobs:
MIGRATE_TARBALL=$(ls /tmp/unlayer-migrate-*.tgz)
tar tzf "$MIGRATE_TARBALL" | grep -qx package/LICENSE || { echo "::error::$MIGRATE_TARBALL has no LICENSE"; exit 1; }

# Nor has elements-preview: the same check.
- name: Pack elements-preview
run: |
cd packages/preview
pnpm pack --pack-destination /tmp
PREVIEW_TARBALL=$(ls /tmp/unlayer-elements-preview-*.tgz)
tar tzf "$PREVIEW_TARBALL" | grep -qx package/LICENSE || { echo "::error::$PREVIEW_TARBALL has no LICENSE"; exit 1; }

- name: Next.js integration build
run: |
cd tests/nextjs-integration
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const html = renderToHtml(<WelcomeEmail />);

**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
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/preview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test/.tmp/
Loading