The native-block imageBlock factory was emitting img tags wrapped only
by a td with text-align:center. That doesn't actually center the image
because text-align only affects inline content, and the img has
display:block. The result: top header logo and dark-footer logo were
left-aligned despite the textAlign:"center" prop on the block.
Fix: wrap each img in an inner <table align="<textAlign>"> exactly the
way MJML/Litmus/Mailchimp do it. This is the canonical email-client
pattern that works in Outlook 2007-2019 (which ignores margin:0 auto
on inline tables but respects table align attributes).
Also: the AI converter dumped the entire dark footer band into a
SINGLE htmlBlock with malformed table markup (a stray </td> outside
its row). Split into proper image + text native blocks so:
1. The logo inherits the new centered nested-table pattern
2. The URL+copyright text is now individually editable in Unlayer
3. The {{year}} placeholder is in a text block where it belongs
And one AI hallucination correction: the converter assigned
textAlign:"left" to the top header logo (probably because the
surrounding column had align="left" in the MJML output). Original
design intent was centered — fixed in the spec.
Verified live: both logos (140px top, 120px footer) now render with
align="center" on their nested wrapper table.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
287 lines
15 KiB
JavaScript
287 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict'
|
|
/**
|
|
* build-native-template.js — generate a template using NATIVE Unlayer blocks
|
|
* (text / image / button / divider / html) instead of one giant Custom HTML
|
|
* block. Produces matched .json (for the Unlayer editor canvas) and .html
|
|
* (what the worker sends to recipients) in lockstep so what the operator
|
|
* edits is exactly what the recipient receives.
|
|
*
|
|
* Usage:
|
|
* node build-native-template.js gift-email-native-reminder-fr
|
|
*
|
|
* Each template is defined as a JS module under templates-spec/<name>.js
|
|
* exporting { body: { rows: [...] }, preheader, ariaLabel }.
|
|
*
|
|
* Native block coverage (granular editability in Unlayer):
|
|
* - text : rich-text paragraphs, supports {{vars}}
|
|
* - image : standalone logos, with alt + optional href
|
|
* - button : single CTA, supports {{vars}} in href and label
|
|
* - divider : horizontal rule
|
|
* - html : escape-hatch for custom markup (chips, multi-logo strips)
|
|
*/
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
// ── ID counter so each block gets a unique htmlID Unlayer can reference ──
|
|
const counter = { u_row: 0, u_column: 0, u_content_text: 0, u_content_image: 0,
|
|
u_content_button: 0, u_content_divider: 0, u_content_html: 0 }
|
|
function nextId (kind) { counter[kind]++; return `${kind}_${counter[kind]}` }
|
|
|
|
// ── Default values shared across all blocks (Unlayer fills these on save) ──
|
|
const COMMON = {
|
|
selectable: true, draggable: true, duplicatable: true, deletable: true,
|
|
hideable: true, hideDesktop: false, displayCondition: null,
|
|
}
|
|
|
|
// ── Block factories — each returns { json: <Unlayer block>, html: <compiled> } ──
|
|
function textBlock ({ html, padding = '10px 36px', fontSize = '16px', fontWeight = 400,
|
|
color = '#374151', textAlign = 'left', lineHeight = '150%' }) {
|
|
const id = nextId('u_content_text')
|
|
return {
|
|
json: {
|
|
id, type: 'text',
|
|
values: {
|
|
...COMMON, containerPadding: padding, anchor: '',
|
|
fontWeight, fontSize, color, textAlign, lineHeight,
|
|
linkStyle: { inherit: true },
|
|
_meta: { htmlID: id, htmlClassNames: 'u_content_text' },
|
|
text: html,
|
|
},
|
|
},
|
|
html: ` <table style="font-family:'Plus Jakarta Sans', sans-serif;" role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
|
<tbody><tr><td style="overflow-wrap:break-word;word-break:break-word;padding:${padding};font-family:'Plus Jakarta Sans', sans-serif;" align="${textAlign}">
|
|
<div class="u_content_text" style="font-size:${fontSize};font-weight:${fontWeight};color:${color};text-align:${textAlign};line-height:${lineHeight};">${html}</div>
|
|
</td></tr></tbody>
|
|
</table>`,
|
|
}
|
|
}
|
|
|
|
function imageBlock ({ src, alt = '', width = 140, padding = '10px',
|
|
textAlign = 'center', href = '' }) {
|
|
const id = nextId('u_content_image')
|
|
const imgTag = `<img alt="${alt}" src="${src}" width="${width}" style="display:block;border:0;height:auto;outline:none;text-decoration:none;width:${width}px;max-width:100%;" />`
|
|
const wrapped = href ? `<a href="${href}" target="_blank" style="text-decoration:none;">${imgTag}</a>` : imgTag
|
|
// Centering an img with display:block via text-align doesn't work — the
|
|
// text-align prop only affects inline content. The robust email-client
|
|
// pattern (used by MJML, Litmus, Mailchimp) is to wrap the img in a
|
|
// nested table with align="<textAlign>", which Outlook 2007-2019 respects
|
|
// alongside modern clients.
|
|
return {
|
|
json: {
|
|
id, type: 'image',
|
|
values: {
|
|
...COMMON, containerPadding: padding, anchor: '',
|
|
src: { url: src, width, height: 'auto', autoWidth: false, maxWidth: `${width}px` },
|
|
textAlign, altText: alt,
|
|
action: href ? { name: 'web', values: { href, target: '_blank' } } : { name: 'web', values: { href: '', target: '_blank' } },
|
|
_meta: { htmlID: id, htmlClassNames: 'u_content_image' },
|
|
},
|
|
},
|
|
html: ` <table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
|
<tbody><tr><td style="padding:${padding};text-align:${textAlign};" align="${textAlign}">
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${textAlign}" style="border-collapse:collapse;border-spacing:0;${textAlign === 'center' ? 'margin:0 auto;' : ''}">
|
|
<tbody><tr><td style="width:${width}px;">
|
|
${wrapped}
|
|
</td></tr></tbody>
|
|
</table>
|
|
</td></tr></tbody>
|
|
</table>`,
|
|
}
|
|
}
|
|
|
|
function buttonBlock ({ text, href, padding = '10px 25px',
|
|
buttonPadding = '30px 24px', bgColor = '#00C853',
|
|
color = '#FFFFFF', borderRadius = '12px',
|
|
fontSize = '32px', fontWeight = 700 }) {
|
|
const id = nextId('u_content_button')
|
|
return {
|
|
json: {
|
|
id, type: 'button',
|
|
values: {
|
|
...COMMON, containerPadding: padding, anchor: '',
|
|
href: { name: 'web', values: { href, target: '_blank' } },
|
|
buttonColors: { color, backgroundColor: bgColor, hoverColor: color, hoverBackgroundColor: '#005026' },
|
|
size: { autoWidth: false, width: '100%' },
|
|
fontSize, fontWeight, textAlign: 'center', lineHeight: '120%',
|
|
padding: buttonPadding, border: {}, borderRadius,
|
|
_meta: { htmlID: id, htmlClassNames: 'u_content_button' },
|
|
text: `<span style="font-size: ${fontSize}; line-height: 120%; font-weight: ${fontWeight};">${text}</span>`,
|
|
},
|
|
},
|
|
html: ` <table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0" style="border-collapse:separate;line-height:100%;">
|
|
<tbody><tr><td style="padding:${padding};text-align:center;" align="center">
|
|
<a href="${href}" target="_blank" class="u_content_button" style="display:inline-block;background:${bgColor};color:${color};font-family:'Space Grotesk', Helvetica, Arial, sans-serif;font-size:${fontSize};font-weight:${fontWeight};line-height:120%;text-decoration:none;padding:${buttonPadding};border-radius:${borderRadius};">${text}</a>
|
|
</td></tr></tbody>
|
|
</table>`,
|
|
}
|
|
}
|
|
|
|
function htmlBlock ({ html, padding = '0' }) {
|
|
const id = nextId('u_content_html')
|
|
return {
|
|
json: {
|
|
id, type: 'html',
|
|
values: {
|
|
...COMMON, containerPadding: padding, anchor: '',
|
|
_meta: { htmlID: id, htmlClassNames: 'u_content_html' },
|
|
html,
|
|
},
|
|
},
|
|
html: ` <table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
|
<tbody><tr><td style="padding:${padding};">
|
|
${html}
|
|
</td></tr></tbody>
|
|
</table>`,
|
|
}
|
|
}
|
|
|
|
function dividerBlock ({ padding = '10px', borderColor = '#e5e7eb' }) {
|
|
const id = nextId('u_content_divider')
|
|
return {
|
|
json: {
|
|
id, type: 'divider',
|
|
values: {
|
|
...COMMON, containerPadding: padding, anchor: '',
|
|
width: '100%', border: { borderTopWidth: '1px', borderTopStyle: 'solid', borderTopColor: borderColor },
|
|
textAlign: 'center', _meta: { htmlID: id, htmlClassNames: 'u_content_divider' },
|
|
},
|
|
},
|
|
html: ` <table role="presentation" cellpadding="0" cellspacing="0" width="100%" border="0">
|
|
<tbody><tr><td style="padding:${padding};">
|
|
<hr style="border:none;border-top:1px solid ${borderColor};margin:0;width:100%;" />
|
|
</td></tr></tbody>
|
|
</table>`,
|
|
}
|
|
}
|
|
|
|
// Bundle a list of blocks into a single Unlayer row+column with shared row styles.
|
|
function row (blocks, opts = {}) {
|
|
const rowId = nextId('u_row'); const colId = nextId('u_column')
|
|
const { backgroundColor = '', columnsBackgroundColor = '', padding = '0px',
|
|
border = '', borderRadius = '', columnPadding = '0px' } = opts
|
|
const rowBg = backgroundColor || 'transparent'
|
|
const innerStyle = [
|
|
border ? `border:${border}` : '',
|
|
borderRadius ? `border-radius:${borderRadius}` : '',
|
|
padding !== '0px' ? `padding:${padding}` : '',
|
|
backgroundColor ? `background:${backgroundColor};background-color:${backgroundColor}` : '',
|
|
'margin:0 auto', 'max-width:600px',
|
|
].filter(Boolean).join(';')
|
|
return {
|
|
json: {
|
|
id: rowId, cells: [1],
|
|
columns: [{
|
|
id: colId,
|
|
contents: blocks.map(b => b.json),
|
|
values: { ...COMMON, _meta: { htmlID: colId, htmlClassNames: 'u_column' },
|
|
padding: columnPadding, border: {}, borderRadius: '0px', backgroundColor: columnsBackgroundColor },
|
|
}],
|
|
values: {
|
|
...COMMON, displayCondition: null, columns: false,
|
|
backgroundColor, columnsBackgroundColor,
|
|
backgroundImage: { url: '', fullWidth: true, repeat: 'no-repeat', size: 'custom', position: 'center' },
|
|
padding, anchor: '', borderRadius,
|
|
_meta: { htmlID: rowId, htmlClassNames: 'u_row' },
|
|
},
|
|
},
|
|
html: `<div class="u-row-container" style="padding:${padding};background-color:${rowBg};">
|
|
<div class="u-row" style="${innerStyle}">
|
|
<div style="border-collapse:collapse;display:table;width:100%;height:100%;">
|
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:${padding};background-color:${rowBg};"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center" style="border-collapse:collapse;background:${rowBg};"><tr><td style="${innerStyle.replace(/background[^;]+;?/, '')}"><![endif]-->
|
|
<div class="u-col u-col-100" style="max-width:320px;min-width:600px;display:table-cell;vertical-align:top;">
|
|
<div style="height:100%;width:100% !important;">
|
|
<!--[if (!mso)&(!IE)]><!--><div style="box-sizing:border-box;height:100%;padding:${columnPadding};"><!--<![endif]-->
|
|
${blocks.map(b => b.html).join('\n')}
|
|
<!--[if (!mso)&(!IE)]><!--></div><!--<![endif]-->
|
|
</div>
|
|
</div>
|
|
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
|
|
</div>
|
|
</div>
|
|
</div>`,
|
|
}
|
|
}
|
|
|
|
// Compose the full <html> shell — Unlayer-standard markup + our brand defaults.
|
|
function compileHtml (preheader, ariaLabel, rows) {
|
|
return `<!DOCTYPE HTML PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
<head>
|
|
<!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="x-apple-disable-message-reformatting">
|
|
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]-->
|
|
<style type="text/css">
|
|
@media only screen and (min-width: 620px) { .u-row { width: 600px !important; } .u-row .u-col { vertical-align: top; } .u-row .u-col-100 { width: 600px !important; } }
|
|
@media only screen and (max-width: 620px) {
|
|
.u-row-container { max-width: 100% !important; padding-left: 0 !important; padding-right: 0 !important; }
|
|
.u-row { width: 100% !important; }
|
|
.u-row .u-col { display: block !important; width: 100% !important; min-width: 320px !important; max-width: 100% !important; }
|
|
.u-row .u-col > div { margin: 0 auto; }
|
|
}
|
|
body{margin:0;padding:0}table,td,tr{border-collapse:collapse;vertical-align:top}*{line-height:inherit}a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:none!important}
|
|
table, td { color: #1B2E24; } #u_body a { color: #00C853; text-decoration: underline; }
|
|
</style>
|
|
<!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700" rel="stylesheet" type="text/css"><!--<![endif]-->
|
|
</head>
|
|
<body class="clean-body u_body" style="margin:0;padding:0;-webkit-text-size-adjust:100%;background-color:#F5FAF7;color:#1B2E24">
|
|
<!--[if IE]><div class="ie-container"><![endif]--><!--[if mso]><div class="mso-container"><![endif]-->
|
|
<table role="presentation" id="u_body" style="border-collapse:collapse;table-layout:fixed;border-spacing:0;mso-table-lspace:0pt;mso-table-rspace:0pt;vertical-align:top;min-width:320px;Margin:0 auto;background-color:#F5FAF7;width:100%" cellpadding="0" cellspacing="0">
|
|
<tbody>
|
|
<tr><td style="display:none !important;visibility:hidden;mso-hide:all;font-size:1px;color:#fff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${preheader}</td></tr>
|
|
<tr style="vertical-align:top"><td style="word-break:break-word;border-collapse:collapse !important;vertical-align:top">
|
|
<!--[if (mso)|(IE)]><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color:#F5FAF7;" bgcolor="#F5FAF7"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" align="center"><tr><td><![endif]-->
|
|
<div aria-label="${ariaLabel}" aria-roledescription="email" role="article" lang="und" dir="auto" style="word-spacing:normal;background-color:#F5FAF7;">
|
|
|
|
${rows.map(r => r.html).join('\n\n')}
|
|
|
|
</div>
|
|
<!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]-->
|
|
</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<!--[if mso]></div><![endif]--><!--[if IE]></div><![endif]-->
|
|
</body></html>`
|
|
}
|
|
|
|
function compileJson (preheader, ariaLabel, rows) {
|
|
return {
|
|
counters: { ...counter },
|
|
body: {
|
|
id: 'u_body',
|
|
rows: rows.map(r => r.json),
|
|
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: 16,
|
|
}
|
|
}
|
|
|
|
// ── Export the factories so template-spec files can use them ──────────────
|
|
module.exports = { textBlock, imageBlock, buttonBlock, htmlBlock, dividerBlock, row, compileHtml, compileJson }
|
|
|
|
// ── CLI ───────────────────────────────────────────────────────────────────
|
|
if (require.main === module) {
|
|
const name = process.argv[2]
|
|
if (!name) { console.error('Usage: build-native-template.js <template-name>'); process.exit(1) }
|
|
const spec = require(path.resolve(__dirname, 'templates-spec', name))
|
|
const tplDir = path.resolve(__dirname, '..', 'templates')
|
|
const rows = spec.rows(module.exports)
|
|
fs.writeFileSync(path.join(tplDir, name + '.html'), compileHtml(spec.preheader, spec.ariaLabel, rows), 'utf8')
|
|
fs.writeFileSync(path.join(tplDir, name + '.json'), JSON.stringify(compileJson(spec.preheader, spec.ariaLabel, rows), null, 2), 'utf8')
|
|
console.log(`✓ Built ${name}.html (${fs.statSync(path.join(tplDir, name + '.html')).size}b) + ${name}.json (${fs.statSync(path.join(tplDir, name + '.json')).size}b)`)
|
|
}
|