feat(campaigns): convert existing HTML templates to Unlayer JSON designs
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.
Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:
• Canvas shows the template visually (Unlayer renders the HTML)
• Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
• User can edit the HTML directly via the block's side panel
• User can incrementally REPLACE the HTML block with native Unlayer
blocks (Text, Image, Button) for any section they want decomposed —
on their own schedule, not blocking the campaign send
New file: services/targo-hub/scripts/convert-html-to-unlayer.js
• CLI: node scripts/convert-html-to-unlayer.js <template-name>
• Reads templates/<name>.html, extracts <body> inner content, detects
preheader from a hidden <div style="display:none">, builds Unlayer
design JSON with brand-appropriate body.values (Targo Green link
color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
• Backs up existing .json before overwriting.
Generated outputs (committed):
templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
templates/gift-email-en.json — 33 KB
Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4acb18c7df
commit
448e62177e
98
scripts/campaigns/templates/gift-email-en.json
Normal file
98
scripts/campaigns/templates/gift-email-en.json
Normal file
File diff suppressed because one or more lines are too long
98
scripts/campaigns/templates/gift-email-fr.json
Normal file
98
scripts/campaigns/templates/gift-email-fr.json
Normal file
File diff suppressed because one or more lines are too long
147
services/targo-hub/scripts/convert-html-to-unlayer.js
Executable file
147
services/targo-hub/scripts/convert-html-to-unlayer.js
Executable file
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict'
|
||||
/**
|
||||
* convert-html-to-unlayer.js — one-time converter from our existing compiled
|
||||
* .html templates into Unlayer design JSON. Run after MJML→Unlayer migration
|
||||
* so the visual editor loads existing templates instead of starting blank.
|
||||
*
|
||||
* Strategy: wrap the entire HTML body content in a single "Custom HTML" block
|
||||
* inside a minimal Unlayer design. This is the MIN VIABLE conversion — the
|
||||
* template renders correctly in the canvas, the user can edit the HTML
|
||||
* directly, and they can incrementally replace the HTML block with native
|
||||
* Unlayer blocks (Text, Image, Button) on their own schedule.
|
||||
*
|
||||
* Usage:
|
||||
* node convert-html-to-unlayer.js gift-email-fr
|
||||
* node convert-html-to-unlayer.js gift-email-en
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
function htmlToUnlayer (innerBodyHtml, opts = {}) {
|
||||
const preheader = opts.preheader || ''
|
||||
return {
|
||||
counters: { u_row: 1, u_column: 1, u_content_html: 1 },
|
||||
body: {
|
||||
id: 'BODY-1',
|
||||
rows: [
|
||||
{
|
||||
id: 'ROW-1',
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
id: 'COL-1',
|
||||
contents: [
|
||||
{
|
||||
id: 'HTML-1',
|
||||
type: 'html',
|
||||
values: {
|
||||
html: innerBodyHtml,
|
||||
hideDesktop: false,
|
||||
displayCondition: null,
|
||||
containerPadding: '0px',
|
||||
_meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' },
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
duplicatable: true,
|
||||
deletable: true,
|
||||
hideable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
values: {
|
||||
_meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' },
|
||||
},
|
||||
},
|
||||
],
|
||||
values: {
|
||||
displayCondition: null,
|
||||
columns: false,
|
||||
backgroundColor: '',
|
||||
columnsBackgroundColor: '',
|
||||
padding: '0px',
|
||||
anchor: '',
|
||||
hideDesktop: false,
|
||||
_meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
duplicatable: true,
|
||||
deletable: true,
|
||||
hideable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
values: {
|
||||
popupPosition: 'center',
|
||||
popupWidth: '600px',
|
||||
popupHeight: 'auto',
|
||||
borderRadius: '10px',
|
||||
contentAlign: 'center',
|
||||
contentVerticalAlign: 'center',
|
||||
contentWidth: '600px',
|
||||
fontFamily: {
|
||||
label: 'Plus Jakarta Sans',
|
||||
value: "'Plus Jakarta Sans', sans-serif",
|
||||
url: 'https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700',
|
||||
},
|
||||
textColor: '#1B2E24',
|
||||
popupBackgroundColor: '#FFFFFF',
|
||||
backgroundColor: '#F5FAF7',
|
||||
preheaderText: preheader,
|
||||
linkStyle: {
|
||||
body: true,
|
||||
linkColor: '#00C853',
|
||||
linkHoverColor: '#005026',
|
||||
linkUnderline: true,
|
||||
linkHoverUnderline: true,
|
||||
},
|
||||
_meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
|
||||
},
|
||||
},
|
||||
schemaVersion: 12,
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────
|
||||
const name = process.argv[2]
|
||||
if (!name) {
|
||||
console.error('Usage: convert-html-to-unlayer.js <template-name>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
|
||||
const htmlPath = path.join(TEMPLATES_DIR, name + '.html')
|
||||
const jsonPath = path.join(TEMPLATES_DIR, name + '.json')
|
||||
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
console.error(`✗ No HTML at ${htmlPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fullHtml = fs.readFileSync(htmlPath, 'utf8')
|
||||
|
||||
// Extract just the body inner content — Unlayer wraps everything in its own
|
||||
// <html><head><body> at preview/export time, so we don't want duplicated
|
||||
// doctype/head/body tags.
|
||||
const bodyMatch = fullHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
|
||||
const innerHtml = bodyMatch ? bodyMatch[1].trim() : fullHtml
|
||||
|
||||
// Pull preheader text if a hidden <div style="display:none"> is present
|
||||
// (standard email preheader pattern, also what MJML's <mj-preview> compiles to)
|
||||
const preheaderMatch = innerHtml.match(/<div[^>]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
|
||||
const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
|
||||
|
||||
const design = htmlToUnlayer(innerHtml, { preheader })
|
||||
|
||||
// Optional: backup existing .json
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
|
||||
}
|
||||
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(design, null, 2), 'utf8')
|
||||
|
||||
console.log(`✓ Converted ${name}.html (${fullHtml.length}b) → ${name}.json (${JSON.stringify(design).length}b)`)
|
||||
console.log(` preheader: "${preheader.slice(0, 80)}${preheader.length > 80 ? '…' : ''}"`)
|
||||
console.log(` inner HTML: ${innerHtml.length}b in one Custom HTML block`)
|
||||
98
services/targo-hub/templates/gift-email-en.json
Normal file
98
services/targo-hub/templates/gift-email-en.json
Normal file
File diff suppressed because one or more lines are too long
98
services/targo-hub/templates/gift-email-fr.json
Normal file
98
services/targo-hub/templates/gift-email-fr.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user