Skip to content

Feat/templatical editor - integrate WYSIWYG block editor - #288

Open
MrSnoozles wants to merge 13 commits into
lunogram:mainfrom
MrSnoozles:feat/templatical-editor
Open

Feat/templatical editor - integrate WYSIWYG block editor#288
MrSnoozles wants to merge 13 commits into
lunogram:mainfrom
MrSnoozles:feat/templatical-editor

Conversation

@MrSnoozles

@MrSnoozles MrSnoozles commented Jul 26, 2026

Copy link
Copy Markdown

This adds www.templatical.com as a second editing mode for email templates to Lunogram.

Integrated features:

  • switch between code and block editor
  • Lunograms preview features working
  • merge tags (placeholder variables)
  • display conditions based on boolean types in the schema
image

Closes #282

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@MrSnoozles

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

signbot9000 Bot added a commit to lunogram/cla that referenced this pull request Jul 26, 2026
@MrSnoozles
MrSnoozles force-pushed the feat/templatical-editor branch from 8122747 to 95494d7 Compare July 26, 2026 18:36
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
MrSnoozles force-pushed the feat/templatical-editor branch from 95494d7 to 478b553 Compare July 26, 2026 20:29
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.

Drag and Drop email editor

1 participant