gigafibre-fsm/services/targo-hub/scripts/build-native-template.js
louispaulb 0fb9089f4e fix(campaigns/templates): center logos via nested-table pattern
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>
2026-06-01 18:45:30 -04:00

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)`)
}