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)}`)
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(name)}`)
|
||||||
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
if (!res.ok) throw new Error(`Hub returned ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// The hub's getTemplate returns { name, format, mjml?, html }
|
// Three sources of truth, in priority order:
|
||||||
// For MJML templates we'd ideally parse the MJML into easy-email's JSON
|
// 1. .json file → easy-email JSON tree (fast, full restore)
|
||||||
// tree — but easy-email doesn't ship an MJML importer. Workaround: we
|
// 2. .mjml file → MJML source (no auto-importer, start blank)
|
||||||
// start the editor with an EMPTY page and let the user rebuild visually
|
// 3. nothing → empty page
|
||||||
// OR stash the existing MJML as the editor's content. For first iteration,
|
if (data.json) {
|
||||||
// empty + the user reconstructs is honest about the limitation.
|
try {
|
||||||
// The compiled HTML preview remains available so they can see what
|
const parsed = typeof data.json === 'string' ? JSON.parse(data.json) : data.json
|
||||||
// they're rebuilding TOWARD.
|
setInitialValues(parsed as IEmailTemplate)
|
||||||
// TODO Phase 3: integrate mjml-react-email or similar MJML→JSON parser
|
} catch (e: any) {
|
||||||
setInitialValues(emptyTemplate())
|
setError(`Stored JSON is invalid (${e.message}) — starting blank`)
|
||||||
setError(`Note: existing template MJML (${data.format}, ${(data.mjml || '').length}b) ` +
|
setInitialValues(emptyTemplate())
|
||||||
`not auto-imported — easy-email starts from an empty page. ` +
|
}
|
||||||
`Use the hub's compiled HTML as a visual reference and rebuild here.`)
|
} 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) {
|
} catch (e: any) {
|
||||||
setError(`Could not load template "${name}": ${e.message}`)
|
setError(`Could not load template "${name}": ${e.message}`)
|
||||||
setInitialValues(emptyTemplate())
|
setInitialValues(emptyTemplate())
|
||||||
|
|
@ -108,10 +114,13 @@ export function EmailEditorApp() {
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
context: values.content as any,
|
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)}`, {
|
const res = await fetch(`${HUB_URL}/campaigns/templates/${encodeURIComponent(templateName)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ mjml: mjmlSource }),
|
body: JSON.stringify({ mjml: mjmlSource, json: values }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errBody = await res.json().catch(() => ({}))
|
const errBody = await res.json().catch(() => ({}))
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,16 @@ function templateMjmlPath (name) {
|
||||||
return path.join(TEMPLATES_DIR, name + '.mjml')
|
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
|
// Return 'mjml' if the template has a .mjml companion file on disk (source
|
||||||
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
|
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
|
||||||
function templateFormat (name) {
|
function templateFormat (name) {
|
||||||
|
|
@ -1020,11 +1030,17 @@ async function handle (req, res, method, path) {
|
||||||
try {
|
try {
|
||||||
const format = templateFormat(name)
|
const format = templateFormat(name)
|
||||||
if (format === 'mjml') {
|
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')
|
const mjml = fs.readFileSync(templateMjmlPath(name), 'utf8')
|
||||||
let html = ''
|
let html = ''
|
||||||
try { html = fs.readFileSync(templatePath(name), 'utf8') } catch {}
|
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')
|
const html = fs.readFileSync(templatePath(name), 'utf8')
|
||||||
return json(res, 200, { name, format: 'html', html })
|
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) {
|
if (typeof body.mjml === 'string' && body.mjml) {
|
||||||
const mjmlPath = templateMjmlPath(name)
|
const mjmlPath = templateMjmlPath(name)
|
||||||
const htmlPath = templatePath(name)
|
const htmlPath = templatePath(name)
|
||||||
|
const jsonPath = templateJsonPath(name)
|
||||||
// Compile MJML → HTML (async in mjml v5)
|
// Compile MJML → HTML (async in mjml v5)
|
||||||
const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
|
const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
|
||||||
if (r.errors?.length) {
|
if (r.errors?.length) {
|
||||||
|
|
@ -1059,18 +1076,27 @@ async function handle (req, res, method, path) {
|
||||||
details: r.errors.map(e => e.formattedMessage || e.message),
|
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)
|
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`))
|
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(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) }
|
} catch (e) { log('template backup failed:', e.message) }
|
||||||
fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
|
fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
|
||||||
fs.writeFileSync(htmlPath, r.html, '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, {
|
return json(res, 200, {
|
||||||
name, format: 'mjml', saved: true,
|
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) ──
|
// ── HTML save path (legacy) ──
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user