#!/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/.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: , html: } ── 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: `
${html}
`, } } function imageBlock ({ src, alt = '', width = 140, padding = '10px', textAlign = 'center', href = '' }) { const id = nextId('u_content_image') const imgTag = `${alt}` const wrapped = href ? `${imgTag}` : 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="", 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: `
${wrapped}
`, } } 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: `${text}`, }, }, html: `
${text}
`, } } 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: `
${html}
`, } } 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: `

`, } } // 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: `
${blocks.map(b => b.html).join('\n')}
`, } } // Compose the full shell — Unlayer-standard markup + our brand defaults. function compileHtml (preheader, ariaLabel, rows) { return ` ` } 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 '); 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)`) }