Feat/templatical editor - integrate WYSIWYG block editor - #288
Open
MrSnoozles wants to merge 13 commits into
Open
Feat/templatical editor - integrate WYSIWYG block editor#288MrSnoozles wants to merge 13 commits into
MrSnoozles wants to merge 13 commits into
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
Author
|
I have read the CLA Document and I hereby sign the CLA |
MrSnoozles
force-pushed
the
feat/templatical-editor
branch
from
July 26, 2026 18:36
8122747 to
95494d7
Compare
Adds a second render path alongside React Email so the renderer can turn a
Templatical document into send-ready HTML, in preparation for the visual
email editor.
The Go backend treats `compiled_js` as an opaque string — it stores the
bundle and hashes it for cache invalidation, never parsing it — so the
whole branch fits inside the renderer with no change to the wire format.
Bundles now carry an optional `kind`; those without one predate this and
take the React Email path exactly as before, so stored templates keep
working and nothing needs migrating.
Templatical documents are rendered at compile time rather than per render.
Their output depends only on the document: merge tags survive as literal
{{ ... }} and are resolved later by the platform's Liquid pass, so props
cannot change the result. Rendering once per save instead of once per
recipient matters for large campaigns.
Plain text is derived with html-to-text, with heading uppercasing turned
off. Left on, a merge tag inside a heading becomes {{ USER.FIRST_NAME }},
which Liquid cannot resolve — that would have shipped the raw tag in the
text part of every email.
mjml needs permissions the service did not previously grant. Importing it
pulls in env-paths, which reads the home directory and then the TMPDIR,
TMP, TEMP chain, and mjml stats its `filePath` option before parsing. Deno
resolves the temp dir through Deno.env.get, so an unlisted variable raises
NotCapable instead of returning undefined and the chain dies on the first
name it touches — all three must be granted even though none are set and
the lookup ends at its /tmp default. Without these the service dies on
import, so the Dockerfile and the deno tasks grant them.
The test task runs on the same allowlist as production rather than a blanket
--allow-env, so a missing permission fails the suite instead of the deploy.
Social icon URLs default to a jsDelivr CDN, which would make every sent
email depend on a third party. TEMPLATICAL_SOCIAL_ICONS_BASE_URL overrides
the base URL; self-hosting the assets is left for a follow-up.
Email templates gain a `type` discriminator so the backend knows which
document to compile. An absent type means React Email, so every template
stored before this change keeps compiling its JSX exactly as before and
nothing needs migrating.
A Templatical template has no JSX at all; its source is the visual
document in `data.blocks`. Both compile call sites — the save path in the
management controller and the lazy compile on send — previously keyed off
`code.source` being non-empty, which would have skipped such a template
entirely. They now ask for the source by type.
`blocks` is cleared after rendering, alongside `code`. The composed
payload is handed to the downstream Liquid renderer, which walks the whole
JSON; a document left in place would have its merge tags and any literal
{{ }} in user-authored text evaluated as templates.
Fills the block-editor slot that already existed in the mail editor but had no open-source implementation, so email can be composed visually instead of by writing React Email JSX. Templatical mounts imperatively rather than rendering as a component, so BlockEditor wraps init() against a ref. The mount is async and StrictMode invokes effects twice, so a disposal flag covers the window where cleanup runs before the promise settles — without it the first editor is orphaned, still mounted and still emitting changes. Merge tags use the liquid preset, matching the platform's own template syntax so tags authored here resolve in the send pipeline unchanged. The editor is chosen when the campaign is created rather than inside the editor: switching afterwards starts the other editor from its own state, so it is a decision worth making before any content exists. The toolbar toggle remains for changing your mind, behind the existing confirmation. data.type now follows the mode. Left pinned to react-email, a template switched to the visual editor would save its document but the backend would keep compiling stale JSX. A freshly seeded document is also reported upward on mount, because Templatical only emits onChange once the user edits something — a template saved straight after switching would otherwise store no document at all, and the backend, told by data.type to compile the document, would have nothing to compile. Switching modes no longer discards the other representation. The JSX always survived in code.source; the document is now kept the same way, so both directions are reversible and only data.type decides what is compiled. @templatical/editor dynamically imports pusher-js from its cloud chunk, which is not among its declared dependencies. Only initCloud() reaches it and the console uses the self-hosted init(), so it is aliased to a stub rather than bundling a realtime transport that is never called. Retires data.editor, a "code" | "visual" field written at creation that nothing ever read, in favour of the editorMode the editor actually uses.
The campaign preview compiled data.code.source client-side as React Email regardless of the template's type. For a visually authored template that field holds the JSX the template carried before it was switched — kept only so the switch stays reversible — so the preview showed a different email than the one that would be sent. These templates are rendered by the backend on save and the resulting HTML is already in the bundle, so the preview reads it from there instead of compiling anything. Two components had their own copy of the compile logic (the campaign detail preview and the shared Preview component), so the bundle-reading helper is shared rather than duplicated a third time. The editor's UI theme now follows the console's `dark` class on <html>, matching how components/ui/code-editor and components/ui/map already detect it, and is applied at init as well as on change so the first paint does not flash the library default. Nothing sets that class yet — PreferencesContext pins mode to "light" with its theme effect commented out — so this reads as light today and starts following automatically once dark mode is wired up.
The editor rendered blank after in-app navigation. StrictMode invokes the mount effect twice and init() is async, so two editors were initialising into the same container; whichever settled second had its DOM torn down by the other's unmount, leaving an empty shadow root. Each mount now gets its own child node, so the two can no longer interfere. The Preview Text tab did nothing. The enterprise block editor renders that tab itself, which is why the host passed activeTab straight through; Templatical does not, so the host now swaps in the existing plain-text editor. The block editor stays mounted underneath rather than being remounted per tab, which would discard its undo history. Plain text for these templates comes from the backend render and lands in the bundle at save time, so it reflects the last save rather than unsaved edits.
The campaign's variables are now offered in the editor's tag picker and
typing autocomplete. Because the editor uses the liquid syntax preset, a
tag's value is the same {{ path }} expression the send pipeline resolves,
so nothing translates between the two. Object and array variables are
dropped: they have no scalar rendering and would emit [object Object].
Adds a Preview panel alongside Editor and Preview Text, showing the
rendered email with the recipient chosen in the toolbar substituted in.
The HTML comes from the backend render and carries merge tags literally,
so resolving them is the same Handlebars pass the campaign and journey
previews already use. With no recipient selected the tags stay visible.
The panel is open-source only; the enterprise block editor renders its own
preview and only knows the other two tabs.
It previews how the email renders in a dark-mode mail client, which this integration cannot honour yet: the backend renders a single HTML variant, so what the toggle shows would not match what is sent. The editor exposes no config for it and its UI sits behind a shadow boundary, so the rule is injected into the shadow root.
The editor's "Browse Media" control now opens Lunogram's own media manager, so images come from the project's existing uploads and storage rather than a second asset store. The editor requests media imperatively and awaits a promise, while the media manager is a React modal, so the resolver is parked until the user picks an image or dismisses the dialog. A pending request is also resolved on unmount, so the editor is never left awaiting a promise that cannot settle.
A Templatical template is `{ blocks, settings }` — portable by design — so
moving one between templates, campaigns or instances needs no server support.
Export serialises the copy already held in `blocksData`, either to the
clipboard or to a download named after the campaign and locale. Import parses
a pasted or uploaded document and pushes it into the mounted editor.
Import replaces the whole document rather than merging: the format has no
meaningful merge, and a partial import would leave the template in a state
neither the user nor the renderer could reason about.
Validation is structural rather than exhaustive, mirroring the renderer's
`isTemplaticalDocument` check so the console rejects the same documents the
backend would fail to compile. Individual blocks are deliberately not
validated — the editor tolerates shapes this console does not know about, and
rejecting them would break documents written by a newer version.
Reaching the editor imperatively needs a handle. It arrives through an
`onReady` callback rather than a forwarded ref because the editor sits behind
`React.lazy` and mounts asynchronously, so there is no render at which a ref
would reliably be populated. The wrapper adapts Templatical's `TemplateContent`
to the opaque `EmailDocument` the host speaks, and re-emits `onChange` after
`setContent` — the editor repaints on `setContent` but does not necessarily
emit, and without that the imported document would never reach `blocksData`,
so the next save would persist the pre-import template.
Templatical can show or hide a block per recipient, but it does not model a condition as field/operator/value: a condition is an opaque `before`/`after` markup pair the renderer wraps the block in, and the platform owns whatever is inside it. So the picker's contents are ours to define, and a boolean user attribute maps onto it directly — one condition to show a block when the flag is true, one for when it is not. The emitted Liquid is the dialect the send pipeline already runs, so nothing translates on the way out: the renderer wraps the block in `<mj-raw>` guards that survive MJML intact and stay balanced whether the block sits at the top level or inside a section column, and the per-recipient pass evaluates them alongside the merge tags it already resolves. The negative arm is `unless` rather than `if … == false` so it also covers recipients the attribute was never set on. Those are indistinguishable from an explicit false to whoever reads the email, and admitting them is what makes the pair exhaustive — every recipient matches exactly one of the two, which is asserted rather than assumed. The labels say "is true" / "is not true" for the same reason: "is set" would invite a has-a-value reading that the guard does not implement. Only paths whose sole schema type is boolean are offered. One the backend has seen hold more than one type is not reliably a flag, and while Liquid truthiness would still evaluate it, "is not true" on a sometimes-string field means something the label does not say. Mounting the editor now waits on the schema fetch. It reads merge tags and display conditions once, at init, and exposes no way to update them afterwards, so mounting early pinned it to the hardcoded base variables for the rest of the session. That was invisible for merge tags, which kept working minus the project's own attributes, but total for display conditions: none of the base variables is a boolean, so the editor was handed an empty list and hid the control outright. Readiness is the fetch having settled rather than succeeded — a project whose schema endpoints are down still needs a usable editor. The six schema endpoints are independent, so they now go out together. Requested one after another they cost six round trips, which is what made the editor win the race against them often enough to matter.
A visually authored template is rendered once by the backend, at save time,
and that render deliberately leaves merge tags as literal `{{ … }}` for the
Liquid pass that runs per recipient. Previewing one therefore means running
that same pass in the console — without it the body is byte-identical whoever
is selected.
That was visible on the campaign page, where picking a recipient appeared to
do nothing: `EmailContentPreview` re-ran on the change but took an early
return for Templatical templates that handed back the bundle HTML verbatim.
Only the subject and sender name reacted, because those go through `Render`
separately. The code-template path never had the problem, since `compileEmail`
bakes user values in at React render time.
The substitution is Liquid rather than Handlebars. The merge-tag picker offers
filtered tags such as `{{ now | date: '%Y' }}`, which Handlebars cannot parse,
and because the pass covers the whole document a single parse error takes
every other tag down with it. Liquid is also what the send pipeline runs, so
the preview and the delivered email agree by construction.
The context is the full preview props rather than the user alone: the picker
also offers campaign variables, `unsubscribe_url`, `preferences_url` and
`now`, none of which resolve under a user-only scope. On the campaign page it
goes through `mergeUserIntoProps` so derived fields like `user.external_id`
resolve to the same values the editor's preview shows, rather than the raw
identifier array.
Rendering falls back to the unsubstituted HTML on failure, so a preview never
goes blank, and the plain-text tab resolves against the same context as the
HTML so the two cannot disagree. Both are display-only — the persisted
`plaintext.generated` still comes from `autoPlainText`, so preview values
cannot leak into a send.
Both preview panels render what the backend produced at save time, so opening one without saving first showed the previous edit — the panel looked stale for no reason the user could see. Saving on the way in is what "Send test" already does before dispatching, and for the same reason; saving is idempotent, so an unchanged template just costs a round trip. A spinner marks the wait, and a failed save says so rather than silently leaving the old render up. The preview frame also had no width or height, so it fell back to the CSS default of 300x150. That squeezed the email's 600px body and wrapped headings mid-word, which made every preview look broken regardless of the template. Sizing it means measuring the document, and measuring in place only ever grows the frame: a document can never report a scrollHeight smaller than the frame rendering it. So a short email inherited the height of whatever was shown before it, and the very first measurement was pinned to that 300x150 default. Collapsing the frame before measuring is what makes the number the document's own, with the previous height kept as a fallback for a document that is not measurable yet.
MrSnoozles
force-pushed
the
feat/templatical-editor
branch
from
July 26, 2026 20:29
95494d7 to
478b553
Compare
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.
This adds www.templatical.com as a second editing mode for email templates to Lunogram.
Integrated features:
Closes #282