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:
louispaulb 2026-05-22 06:22:47 -04:00
parent 4acb18c7df
commit 448e62177e
5 changed files with 539 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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 MJMLUnlayer 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`)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long