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>
148 lines
5.1 KiB
JavaScript
Executable File
148 lines
5.1 KiB
JavaScript
Executable File
#!/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`)
|