feat(email-editor): persist easy-email JSON state for instant restore on reload
Phase 2.5 — close the load/save loop so the editor isn't broken by a page
refresh.
Problem: easy-email doesn't ship an MJML→JSON parser, so loading an
existing MJML template into the editor canvas isn't possible. First-time
load = empty canvas. Without this fix, every page reload would also reset
to empty (even after saving), making the editor useless past one session.
Solution: persist easy-email's raw JSON tree (editor state) as a third
companion file alongside .mjml + .html. Editor reads .json on load when
present, falls back to empty otherwise.
Three files per template now:
gift-email-fr.mjml — MJML source (rendered by send-worker → already done)
gift-email-fr.html — compiled HTML (cached output, sent to recipients)
gift-email-fr.json — easy-email editor state (UI restoration only)
Backend (lib/campaigns.js):
- New templateJsonPath() helper + EDITABLE_TEMPLATES checks
- GET /campaigns/templates/:name returns { format, mjml, html, json }
when format=mjml (json null until first easy-email save)
- PUT /campaigns/templates/:name now accepts body.json alongside body.mjml
(writes both .mjml + .html [compiled] + .json [editor state])
- Backup hook extended to also backup .json before overwrite
Editor (EmailEditorApp.tsx):
- On load: prefer data.json → parse and seed initialValues. If json
missing but mjml present, show explanatory error banner + empty canvas
(user reconstructs once; that save fixes future loads).
- On save: send BOTH mjml (compiled via JsonToMjml) AND raw values
object as json. Hub persists all three artifacts.
First UX flow on next user visit:
1. Open editor → empty canvas + banner "MJML exists but no JSON
editor-state yet; reconstruct once to save a JSON snapshot"
2. User drag-drops blocks to rebuild the template visually
3. Click save → MJML + HTML + JSON all persist
4. Subsequent reloads load from JSON instantly with full editor state
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f9971e9113
commit
bb88a27b90
|
|
@ -75,19 +75,25 @@ export function EmailEditorApp() {
|
|||
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
|
||||
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
||||
const data = await res.json()
|
||||
// The hub's getTemplate returns { name, format, mjml?, html }
|
||||
// For MJML templates we'd ideally parse the MJML into easy-email's JSON
|
||||
// tree — but easy-email doesn't ship an MJML importer. Workaround: we
|
||||
// start the editor with an EMPTY page and let the user rebuild visually
|
||||
// OR stash the existing MJML as the editor's content. For first iteration,
|
||||
// empty + the user reconstructs is honest about the limitation.
|
||||
// The compiled HTML preview remains available so they can see what
|
||||
// they're rebuilding TOWARD.
|
||||
// TODO Phase 3: integrate mjml-react-email or similar MJML→JSON parser
|
||||
setInitialValues(emptyTemplate())
|
||||
setError(`Note: existing template MJML (${data.format}, ${(data.mjml || '').length}b) ` +
|
||||
`not auto-imported — easy-email starts from an empty page. ` +
|
||||
`Use the hub's compiled HTML as a visual reference and rebuild here.`)
|
||||
// Three sources of truth, in priority order:
|
||||
// 1. .json file → easy-email JSON tree (fast, full restore)
|
||||
// 2. .mjml file → MJML source (no auto-importer, start blank)
|
||||
// 3. nothing → empty page
|
||||
if (data.json) {
|
||||
try {
|
||||
const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
|
||||
setInitialValues(parsed as IEmailTemplate)
|
||||
} catch (e: any) {
|
||||
setError(`Stored JSON is invalid (${e.message}) — starting blank`)
|
||||
setInitialValues(emptyTemplate())
|
||||
}
|
||||
} else if (data.mjml) {
|
||||
setError(`Existing MJML (${(data.mjml || '').length}b) cannot be auto-imported into easy-email. ` +
|
||||
`Reconstructing this template once with the drag-drop blocks here will save an editable JSON snapshot for next time.`)
|
||||
setInitialValues(emptyTemplate())
|
||||
} else {
|
||||
setInitialValues(emptyTemplate())
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(`Could not load template "${name}": ${e.message}`)
|
||||
setInitialValues(emptyTemplate())
|
||||
|
|
@ -108,10 +114,13 @@ export function EmailEditorApp() {
|
|||
mode: 'production',
|
||||
context: values.content as any,
|
||||
})
|
||||
// Send BOTH the compiled MJML (for send-worker) AND the raw easy-email
|
||||
// JSON tree (for next-load restore). Hub persists .mjml + .html + .json
|
||||
// — the JSON file is the canonical editing source going forward.
|
||||
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mjml: mjmlSource }),
|
||||
body: JSON.stringify({ mjml: mjmlSource, json: values }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errBody = await res.json().catch(() => ({}))
|
||||
|
|
|
|||
|
|
@ -685,6 +685,16 @@ function templateMjmlPath (name) {
|
|||
return path.join(TEMPLATES_DIR, name + '.mjml')
|
||||
}
|
||||
|
||||
// Companion .json — easy-email's raw editor state (JSON tree). When present,
|
||||
// the editor uses it as the source of truth on load (instant restore of all
|
||||
// blocks/styling). Generated client-side by easy-email; we just persist it.
|
||||
function templateJsonPath (name) {
|
||||
if (!EDITABLE_TEMPLATES.includes(name)) {
|
||||
throw new Error(`template not editable: ${name}`)
|
||||
}
|
||||
return path.join(TEMPLATES_DIR, name + '.json')
|
||||
}
|
||||
|
||||
// Return 'mjml' if the template has a .mjml companion file on disk (source
|
||||
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
|
||||
function templateFormat (name) {
|
||||
|
|
@ -1020,11 +1030,17 @@ async function handle (req, res, method, path) {
|
|||
try {
|
||||
const format = templateFormat(name)
|
||||
if (format === 'mjml') {
|
||||
// Source of truth = .mjml. Also return compiled .html as a preview.
|
||||
// Source of truth = .mjml. Also return compiled .html (preview) and
|
||||
// .json (easy-email editor state) when they exist.
|
||||
const mjml = fs.readFileSync(templateMjmlPath(name), 'utf8')
|
||||
let html = ''
|
||||
try { html = fs.readFileSync(templatePath(name), 'utf8') } catch {}
|
||||
return json(res, 200, { name, format, mjml, html })
|
||||
let editorJson = null
|
||||
try {
|
||||
const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
|
||||
editorJson = JSON.parse(jsonStr)
|
||||
} catch {}
|
||||
return json(res, 200, { name, format, mjml, html, json: editorJson })
|
||||
}
|
||||
const html = fs.readFileSync(templatePath(name), 'utf8')
|
||||
return json(res, 200, { name, format: 'html', html })
|
||||
|
|
@ -1051,6 +1067,7 @@ async function handle (req, res, method, path) {
|
|||
if (typeof body.mjml === 'string' && body.mjml) {
|
||||
const mjmlPath = templateMjmlPath(name)
|
||||
const htmlPath = templatePath(name)
|
||||
const jsonPath = templateJsonPath(name)
|
||||
// Compile MJML → HTML (async in mjml v5)
|
||||
const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
|
||||
if (r.errors?.length) {
|
||||
|
|
@ -1059,18 +1076,27 @@ async function handle (req, res, method, path) {
|
|||
details: r.errors.map(e => e.formattedMessage || e.message),
|
||||
})
|
||||
}
|
||||
// Backup both files before overwriting
|
||||
// Backup all 3 companion files before overwriting
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
try {
|
||||
if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`))
|
||||
if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`))
|
||||
if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||||
} catch (e) { log('template backup failed:', e.message) }
|
||||
fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
|
||||
fs.writeFileSync(htmlPath, r.html, 'utf8')
|
||||
log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b)`)
|
||||
// Optional editor-state snapshot — only written when client sends it
|
||||
// (the React editor does, but the legacy HTML editor doesn't).
|
||||
let json_size = 0
|
||||
if (body.json) {
|
||||
const jsonStr = typeof body.json === 'string' ? body.json : JSON.stringify(body.json)
|
||||
fs.writeFileSync(jsonPath, jsonStr, 'utf8')
|
||||
json_size = jsonStr.length
|
||||
}
|
||||
log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b${json_size ? ', json: ' + json_size + 'b' : ''})`)
|
||||
return json(res, 200, {
|
||||
name, format: 'mjml', saved: true,
|
||||
mjml_size: body.mjml.length, html_size: r.html.length,
|
||||
mjml_size: body.mjml.length, html_size: r.html.length, json_size,
|
||||
})
|
||||
}
|
||||
// ── HTML save path (legacy) ──
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user