Skip to content

@unlayer/elements-preview: a local preview for Elements templates - #72

Open
ivoIturrieta wants to merge 57 commits into
feat/react-elements-0.3from
feat/elements-preview
Open

ivoIturrieta wants to merge 57 commits into
feat/react-elements-0.3from
feat/elements-preview

Conversation

@ivoIturrieta

@ivoIturrieta ivoIturrieta commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the 0.3.0 library PR (#69): it shows only the preview. Merge it after #69, with its base changed to main first. The repo's tests only run on pull requests into main or develop, so CI starts here once the base is main; until then, the validation below was run locally, with #69's head 06a1825 merged in.

What

@unlayer/elements-preview 0.1.0 is a local preview for Elements templates, like React Email's email dev. It's a dev dependency of a project: npm install -D @unlayer/elements-preview && npx elements-preview dev.

  • dev:
    • previews a folder of templates with live reload, rendering them with the project's own Elements and React (Vite SSR with the project as root, so its imports, aliases and React resolve as in the app);
    • one top bar with Preview · Editor · Code, desktop/phone, dark mode, Checks and Copy for AI; the HTML, plain text and design JSON are copied and downloaded from their Code tabs (there's no Export menu);
    • every view, the checks and Copy for AI show only the selected template's render: right after a switch to a slow template they say it's rendering, so nothing of the previous template is copied, downloaded or checked under the new name;
    • when the server comes back after a restart, the page fetches the templates again and renders the selected one;
    • the Editor view opens the design in the Unlayer editor;
    • the checks drawer covers render problems, email-client support with source lines (caniemail), and links and images (an image with empty alt text is still requested, so a broken one shows);
    • the sidebar and the browser tab show Unlayer's logo, inline, so a static build under any path needs nothing more.
  • Render timeout: a template has 30 seconds to load and render (--render-timeout <seconds> on dev, check, render, export, screenshot and build). One that waits forever fails as a template that throws (the preview and a build show it, check counts it as to fix, and check, render, export and screenshot exit with 1), and the next template renders right away instead of the whole preview freezing. A one-off command that's done while such a render still runs exits once its output is written out, rather than waiting for the render.
  • Console capture: what a template, or a module it imports, logs while it loads (dotenv does) is reported with its other console output, so check --json and render --json print only JSON.
  • export: HTML, plain text and design JSON files.
  • build / start: a static preview to share or host. A build replaces its previous build's files (listed in .elements-preview-build.json) and leaves others alone; it refuses a non-empty folder that isn't a preview build.
  • check [--json]: a "to fix" / "to check" report with exit codes, for agents and CI.
  • screenshot: desktop, phone and dark PNGs using the system browser (puppeteer-core; no download).
  • render --json: one template's HTML and design.
  • Copy for AI: a button next to Checks that turns a template and its problems into a prompt.
  • Security: binds to localhost and checks Host and Origin.

It's a package of its own, not part of the Unlayer CLI: it has to run in the project, with the project's Elements and React, and its dependencies (Vite, a Chrome driver, email-client data: about 110 MB) would quadruple the CLI's install for everyone.

Publishing

  • Peers: @unlayer/react-elements >= 0.2.0 and React 18 or 19.
  • The first version is published by hand (npm's trusted publishing needs the package to exist), tagged preview-v0.1.0, with its trusted publisher added. After that, the new publish-preview job in publish.yml publishes a change under packages/preview after a reviewer approves, without waiting for an Elements release.

Validation

With #69's head b35cc79 merged in (the merges had no conflicts; since 11ec4e2 they bring #69's fixes from four more review rounds (an email service's tags kept whole in rich text, background URLs and plain text's headings; display conditions written around rows and blocks; rich text's sanitizer tightened; a Table's cells from data written as text; style values' double quotes kept), and before them: every timing test in the react and shared packages through one helper (a cost at two sizes timed again where it looks slow, each size's fastest of three samples of 20 ms at least; a cost against a limit run twice more where it's over, held to the limit times the machine's measured slowness), after one more CI flake on a 5 ms baseline; and the streamed-context test's page data released once its shell is written, not on a timer that React 18's setImmediate-scheduled shell could lose to; the round before: Tailwind's stylesheet, which each build reads again whole, read in time linear in its size on Node 20 (a compiler's of 2,400 classes, 114 KB, took 118 ms a build, now 5.6 ms); the Tailwind class growth fuzz retimed, with the one variant shape Tailwind's own compiler reads in time squared left out and read at the 512-character cap under 100 ms; the round before: tests that hold on CI's runners (a turn of the event loop between tests, so vitest's report of them can't time out; the Tailwind swap test's stylesheets read where Elements reads them; the growth tests time a shape that looks slow again, then at ten times the size, and each quote against a quarter of its size; the deep code test within Node 20's stack; 30 s a test), and docs that say what ships (moduleResolution: "node" needs skipLibCheck, an image's source is written as given, a prop leaves out a url() in double quotes); the round before: markup's inline styles hold what an escaped style can (a theme's double-quoted font names, url("…"), a string's ;), and refuse only what could end the attribute or start a tag once read, or a URL a component can't take (url(javascript:…)); a render's shared work is kept across copies of Elements and in all the code it runs; plain text leaves out blocked URLs and keeps its placeholders from text that decodes to them; text placed straight in a Row or Column is said once per render; the main entry's budget is 221 KB; the round before: a handle keeps a fresh Tailwind compiler ready, so a synchronous loop of renders gets one at once, and past 8,000 classes on one compiler with none ready a render that needs a new class fails saying to yield or use the async renderers; markup inlines no class value a prop can't take; a render's shared work is kept to its own render (template wrappers, nested renders) and Markdown's table cells count against it; code themes are read by their own names; plain text's skip names are read as written; the main entry's budget is 219.5 KB; the round before: Tailwind's shared compiler is given only classes it builds in time (512 characters, 8 variants, 4 & in a class's variants, brackets 8 deep, a render's 64 KB of classes), and a class its build throws for without keeping it is found and left out; a Markdown's headings' and rich text children's classes are compiled with the render's others; an image's size string is read once; Markdown's and code's work is shared across a render, not per block; plain text's tag names are indexed by their parts; the main entry's budget is 218 KB; the round before: a content prop given as null renders nothing, as in 0.2.1; a logo's height class keeps its shape; a Video's id can't add markup; Tailwind classes, rich text's markup, HTML's plain text, code's trailing whitespace, and Markdown's would-be tables, link text, list items, paragraph lines and references are read in time linear in their size; the main entry's budget is 216 KB; the round before: a Tailwind plugin's error is tied to its class by the value Tailwind gave the function, so a user's class can't fail the app's own; Tailwind classes in a Heading's or a Button's children and on a lone <Link> are inlined; a prop given as null is one not given; a <script>'s or <style>'s text in rich text children is left out; a Row's anchor, a Video's link and thumbnail and a Social icon's image can't add markup; a Font's javascript: URL loads nothing; highlight.js's time is bounded; Markdown's quotes and task lists and designToElements() take time linear in their size; the main entry's budget is 207 KB; the round before: Markdown's inline syntax from data is read in time linear in its size; a Tailwind plugin's function that gives nothing, or an @apply Tailwind can't compile, no longer breaks the shared handle, and an error that can't be tied to a class fails only its render; an async render that renders again lists and says each part it leaves out once; the docs say a Table's cells are HTML and style props are written as given, and the Markdown guide gives its densest shapes' output, time and memory; the round before: a block's link is read with its character references decoded (&#106;avascript:, javascript&colon;), in every shape, so no javascript: link the editor's exporters write runs; a Tailwind plugin's error is pinned on its class where another utility gives the class CSS too and inside compound variants, in every render; an async render whose own class makes Tailwind compile again renders again once it has; the Markdown guide gives memory for its worst shape, deeply nested quotes; the round before: a Tailwind plugin that throws for one class fails only the component whose class it is and changes nothing in other renders; the links the editor's exporters write get a javascript: URL as one that throws; a null Menu item or Social icon is skipped; and before that: a javascript: URL in the links Elements writes is one that throws with React 18 too; a render reads its first 2,000 different Tailwind classes; only the Tailwind class built alone and seen to fail is refused; a <Tailwind>'s own classes are built at once; a cid: or base64 image url() is kept; a link of null is none (none of them touch the preview's code); and before them, fixes for hostile inputs, Markdown's size limits, very long Markdown, Markdown and Tailwind classes from data rendering in time linear in their size, and what a context reads around an app's renders):

  • Elements now writes a javascript: link's URL (the needs-fixes template's Button) as the one react-dom 19 writes, which throws: check still finds it to fix, with that URL, and its tests and render --check's expect it.
  • preview 99 tests and tsc pass, and so do the other packages' tests (react 1,894, from-react-email 344, convert-core 212, migrate 212, shared 142). New coverage: a template that never finishes (dev, check, export), console output while a template loads (check, render), an image with empty alt text requested, the live-reload test editing text the footer has, the app driven in a browser (only the selected template's render shown, the templates fetched again when the server is back), and output written out to a slow pipe before a command exits.
  • A replay of the repo's CI workflow on a clean checkout passes every step (the preview's tests run in its pnpm test; the packed preview has its LICENSE).
  • @unlayer/react-elements 0.3.0: Tailwind, Markdown, code, fonts, async, codegen and editor-parity fixes #69's last fixes are packaging and types (its LICENSE, CommonJS and node10 types, no highlight.js in the code entry's types, and its optional peers tailwindcss and prettier of any version, so a project's Tailwind CSS 3 or prettier 2 doesn't fail npm install); the preview's publish job and CI's pack steps now fail where its tarball has no LICENSE, as Elements' and migrate's do (the packed preview has one). With this head's packed Elements and preview, the fresh project's check --json gives the same results and export byte-identical HTML.
  • End to end with the packed Elements 0.3.0 and preview in fresh projects, with React 19 and React 18 (identical HTML and designs), on the showcase templates (Tailwind, fonts, Markdown, code, shared components) and templates broken on purpose: check --json (findings, the failing component's RenderError with its source line, exit codes), render --json (designs carry displayMode, paragraphs are the editor's text block), export --design and build.
  • Before the merge, on an earlier 0.3 build: screenshot --all (PNGs looked at), start, and dev in a browser (live reload, Preview / Editor / Code, desktop/phone, dark mode, checks, the Editor view in the Unlayer editor with local images; no console errors).

ivoIturrieta and others added 6 commits October 16, 2026 08:38
…ments templates

`elements-preview dev` previews a folder of templates in the browser with live reload, rendering
them with the project's own Elements and React (Vite SSR with the project as root, so its
imports, aliases and React resolve as in the app). The UI shows desktop and phone widths, dark
mode as Gmail and Apple Mail apply it, the code, an Editor view that opens the design in the
Unlayer editor, and a checks drawer: render problems, email-client support with source lines
(caniemail), and links and images.

Commands: dev, export (HTML, plain text, design JSON), build and start (a static preview to
share), check (--json, exit codes for agents and CI), screenshot (desktop, phone, dark PNGs with
the system browser) and render (--json). Copy for AI turns a template and its problems into a
prompt. Binds to localhost and checks Host and Origin.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Like migrate: the first version is published by hand (npm's trusted
publishing needs the package to exist), tagged preview-v<version>; after
that, a change under packages/preview publishes it after a reviewer
approves. It doesn't wait for Elements: it renders with the project's
own Elements, a peer dependency.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A template's error in a build said to fix the file and save, as in dev,
but a build never updates: it now says to build the preview again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The checks' labels counted every finding and named them by the worst
("4 to fix" for 3 to fix and 1 to check). They now say both.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
An empty templates folder suggested `npx @unlayer/cli elements init`,
which the released CLI doesn't have, and the READMEs named its
`unlayer elements` commands. The empty folder now says how to write a
template, and the READMEs give the preview's own commands. The README
also says what the dark-mode preview doesn't flip: a color set only by
a stylesheet.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…rnings

- A build records the files it writes (.elements-preview-build.json) and
  removes the previous build's before writing, so a deleted template's
  source and PreviewProps don't stay in a hosted build. It refuses, with
  exit 2, a non-empty folder that isn't a preview build.
- Two templates that would write the same file (welcome.jsx and
  welcome.tsx) stop export and screenshot with exit 2, naming both,
  instead of one silently replacing the other.
- A URL that doesn't decode (/static/%) is a finding, not an error that
  ends check or build; the dev server answers it with 400, and a static
  build's link to it opens the first template.
- Only Elements' own console.error messages are "to fix". React's
  development warnings (a list without keys) and a template's own errors
  are "to check", as the README says, so check no longer fails on them.
- check on a folder with no templates exits 2.
- export --plainText fails a template whose plain text couldn't be made
  instead of writing an empty file.
- The dev server reuses its last render for the checks until a file
  changes: one render per save instead of three.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
@ivoIturrieta
ivoIturrieta marked this pull request as ready for review October 16, 2026 15:24
ivoIturrieta and others added 12 commits October 18, 2026 22:38
An image with empty alt text (Elements writes alt="" for an Image
without alt) got the "Empty alt text" note and was never requested, so
a 404 image showed nothing to fix in the Checks or in check. A note no
longer stands for a check of the URL: only links and images with
something to fix, merge tags and placeholder addresses aren't
requested.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The test replaced "Sent by", which the fixture's footer doesn't say
("Sent with care by"), and then looked for the text the footer already
had, so it passed with a template that never loaded the new file. It
now renders the template first, changes the footer's text, and checks
that the new text is there and the old one is gone. With the server
keeping each template's first loaded modules, it fails.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
A template, or a module it imports, that logs at its top level
(dotenv does by default) was loaded before the console was captured,
so check --json and render --json printed that line before the JSON.
The console is now captured while the template loads too, and what it
logs is reported with its other console output: a note (or a warning,
from console.warn) in check, a warning in render. console.debug is
captured as well.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Templates render one at a time, so one that waits for something that
never comes (a fetch that doesn't answer, a promise that never
settles) held up every render after it: the preview froze for every
template until a restart, even after the file was fixed, and check and
export printed nothing and exited with 0.

A template now has 30 seconds to load and render (--render-timeout
<seconds> on dev, check, render, export, screenshot and build;
renderTimeoutMs in the API). One that takes longer fails like a
template that throws: the preview shows "The template didn't finish
rendering" with what it logged so far, check counts it as to fix, and
the commands exit with 1. The next template renders right away, and a
fixed file renders on the next change.

The render it gave up on goes on in the background, since nothing can
stop it. What it returns is dropped, and its console output is told
apart by async context, so it lands in its own list rather than in a
later render's or on the console. A one-off command that's done while
such a render still runs exits instead of waiting for it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The Export menu downloaded or copied what the Code view's tabs already
do. It's gone: the HTML, plain text and design JSON are copied and
downloaded from their Code tabs, which now say the design JSON opens in
the Unlayer builder, and leave Copy and Download off when there's no
plain text or design JSON. Copy for AI is a button of its own next to
Checks. The export command doesn't change.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Right after switching to a template that takes a while to render, the
title, the Code view's file names and Copy for AI already named the new
template while the preview, the code and the checks still held the
previous one's render, so a download saved the previous template under
the new name.

The app now keeps each render with the template it's of, and every
view, the checks and Copy for AI use only the selected template's.
Until it comes they say it's rendering (after a moment, so a quick
switch doesn't flash it), and there's nothing to copy, download or
check. A test drives the app in a browser (it skips without a browser
or the built app).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
After the preview server restarted, the open page said it was live
again but kept the templates and the render from before until another
file changed: what changed while the server was away came with no
event. When the connection comes back after it dropped, the page now
fetches the template list and the project again, which renders the
selected template again.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
The sidebar and the tab's icon showed a purple square of their own.
They now show Unlayer's logo mark, the nine squares, with the paths of
packages/demo/src/components/UnlayerLogo.tsx (Unlayer's mark as the
Unlayer console ships it). The sidebar's is in the text color; the
tab's is Unlayer's navy on a light browser and white on a dark one.
Both are inline (a component and a data: URL), so the app's build,
the package's files and a static build under any path need nothing
more.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
…k render

A one-off command that ends while a template that timed out still
renders waited for "drain" before exiting whenever stdout or stderr
had output buffered. A stream emits "drain" only after a write that
returned false, so with a report buffered short of the high water
mark (a slow pipe) the command never exited. It now waits for a write
callback after the output, which comes once what's before it is
written out, as it is with a drain too.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ivoIturrieta and others added 10 commits October 22, 2026 16:18
As the Elements and migrate publish jobs now do: a version can't be
changed once it's on npm. The preview's tarball has the repository's
license (pnpm adds it: no file in packages/preview matches LICENSE).

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Like migrate, elements-preview has no LICENSE of its own (its files are
dist and THIRD_PARTY_NOTICES.md): pnpm adds the repository's to its
tarball, which only publish.yml checked. test.yml's pack steps now pack
it and check it too.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
ivoIturrieta and others added 29 commits October 23, 2026 14:15
Elements now writes a Button's javascript: URL as the one react-dom 19
writes, which throws, as it does every link's. The check still finds
the template's link to fix (it's a javascript: link that goes nowhere),
now with that URL: the tests of check and render with check expect it.

Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants