Repository navigation
@unlayer/elements-preview: a local preview for Elements templates - #72
Open
ivoIturrieta wants to merge 57 commits into
Open
ivoIturrieta wants to merge 57 commits into
ivoIturrieta wants to merge 57 commits into
Conversation
…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
marked this pull request as ready for review
October 16, 2026 15:24
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
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]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
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]>
khashaba
approved these changes
Oct 20, 2026
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
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]>
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]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
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]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
Co-Authored-By: Claude Code (Claude Opus 5.5 xhigh) <[email protected]>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on the 0.3.0 library PR (#69): it shows only the preview. Merge it after #69, with its base changed to
mainfirst. The repo's tests only run on pull requests intomainordevelop, so CI starts here once the base ismain; until then, the validation below was run locally, with #69's head06a1825merged in.What
@unlayer/elements-preview0.1.0 is a local preview for Elements templates, like React Email'semail dev. It's a dev dependency of a project:npm install -D @unlayer/elements-preview && npx elements-preview dev.dev:--render-timeout <seconds>ondev,check,render,export,screenshotandbuild). One that waits forever fails as a template that throws (the preview and a build show it,checkcounts it as to fix, andcheck,render,exportandscreenshotexit 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.check --jsonandrender --jsonprint 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.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
@unlayer/react-elements >= 0.2.0and React 18 or 19.preview-v0.1.0, with its trusted publisher added. After that, the newpublish-previewjob inpublish.ymlpublishes a change underpackages/previewafter a reviewer approves, without waiting for an Elements release.Validation
With #69's head
b35cc79merged in (the merges had no conflicts; since11ec4e2they 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"needsskipLibCheck, an image's source is written as given, a prop leaves out aurl()in double quotes); the round before: markup's inline styles hold what an escapedstylecan (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 asnullrenders 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 asnullis 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'sjavascript:URL loads nothing; highlight.js's time is bounded; Markdown's quotes and task lists anddesignToElements()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@applyTailwind 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 (javascript:,javascript:), in every shape, so nojavascript: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 ajavascript:URL as one that throws; anullMenu item or Social icon is skipped; and before that: ajavascript: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; acid:or base64 imageurl()is kept; a link ofnullis 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):javascript:link's URL (the needs-fixes template's Button) as the one react-dom 19 writes, which throws:checkstill finds it to fix, with that URL, and its tests andrender --check's expect it.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.pnpm test; the packed preview has its LICENSE).tailwindcssandprettierof any version, so a project's Tailwind CSS 3 or prettier 2 doesn't failnpm 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'scheck --jsongives the same results andexportbyte-identical HTML.check --json(findings, the failing component'sRenderErrorwith its source line, exit codes),render --json(designs carrydisplayMode, paragraphs are the editor's text block),export --designandbuild.screenshot --all(PNGs looked at),start, anddevin 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).