#!/usr/bin/env node 'use strict' /** * ai-convert-to-native.js — interpret an existing compiled .html template * via Gemini Flash and produce a native-block templates-spec/.js file * that can be built by build-native-template.js. * * The AI handles SEMANTIC interpretation only (which paragraph is a * greeting? which div is the CTA? which span is a chip?) — the conversion * to specific Unlayer JSON / HTML markup is then deterministic in * build-native-template.js. This split prevents AI hallucinations in the * final markup that gets sent to recipients. * * Usage: * AI_API_KEY=... node ai-convert-to-native.js gift-email-fr * * Output: * scripts/templates-spec/gift-email-fr-native.js (generated) * templates/gift-email-fr-native.html + .json (built from the spec) */ const fs = require('fs') const path = require('path') const { aiCall } = require('../lib/ai') // The AI returns an array of "block descriptors". Our deterministic builder // then maps each one to the matching factory in build-native-template.js. // Keeping the schema TIGHT means less surface for the model to invent things. const SYSTEM_PROMPT = `You are an email-template interpreter. Given the inner-body HTML of a transactional email, identify the SEMANTIC BLOCKS and return ONLY a JSON object: { "preheader": "...", "ariaLabel": "...", "blocks": [ ... ] }. Each block is an object with: - "type" — one of: "text", "image", "button", "html", "divider" - "purpose" — short label like "greeting", "intro", "cta", "expiry-badge", "prorata", "signature", "contact-info", "header-logo", "footer-logo", "option-1-chip", "option-2-chip", "brand-logos-card", "view-in-browser-link", "footer-text", "footer-band" - type-specific fields (see below) For type "text": - "html" : the rich HTML content (preserve , ,
, , Mustache {{vars}} like {{firstname}}, etc.) - "fontSize" : e.g. "16px", "14px", "13px" - "fontWeight": 400 / 500 / 600 / 700 - "color" : hex like "#374151" or "#1B2E24" - "textAlign" : "left" / "center" / "right" / "justify" - "padding" : CSS shorthand for container padding, e.g. "10px 36px 14px" - "background": (optional) row background color hex; default "#ffffff" if part of the white card - "lineHeight": (optional) e.g. "150%" or "140%" For type "image": - "src" : full URL - "alt" : alt text - "width" : pixel width as number (e.g. 140) - "padding" : container padding - "textAlign": "left" / "center" / "right" - "background": (optional) row background - "href" : (optional) link URL For type "button": - "text" : button label HTML (often with Mustache like "🎁 {{amount}}") - "href" : link URL (often "{{gift_url}}") - "bgColor" : background hex - "color" : text color hex - "padding" : container padding - "buttonPadding": inner button padding (e.g. "30px 24px") - "borderRadius": e.g. "12px" - "fontSize": e.g. "32px" - "background": (optional) row background For type "html": - "html" : the raw HTML markup (keep this for chips, multi-image rows, anything too custom for native blocks) - "padding" : container padding - "background": (optional) row background For type "divider": - "padding" : container padding - "background": (optional) row background ROW STYLING — if multiple consecutive blocks share the same card (white background, side borders, etc.), they should each carry the SAME "background" value so the deterministic builder groups them into one row with that background. CRITICAL CONSTRAINTS: - Preserve ALL Mustache placeholders verbatim: {{firstname}}, {{amount}}, {{gift_url}}, {{expires_at_date}}, {{commitment_months}}, {{description}}, {{year}}, {{view_url}}. - Preserve all Mustache section blocks: {{#view_url}}...{{/view_url}} — these go inside an "html" block as-is. - Preserve external image URLs verbatim (xqy3m.mjt.lu/...). - The "preheader" is the hidden div at the top of the body — extract its inner text. - The "ariaLabel" is the aria-label of the role="article" outer div. - Output VALID JSON, no markdown fences, no commentary.` function buildUserPrompt (innerHtml) { return `Inner body HTML to interpret: \`\`\`html ${innerHtml.slice(0, 60000)} \`\`\` Return the JSON now.` } function extractInnerBody (fullHtml) { const m = fullHtml.match(/]*>([\s\S]*?)<\/body>/i) return m ? m[1] : fullHtml } // Map AI block descriptors → templates-spec JS source. The spec is a JS // module so the operator can hand-tweak before re-running the builder. function generateSpec (aiResult) { const { preheader, ariaLabel, blocks } = aiResult if (!Array.isArray(blocks)) throw new Error('AI did not return blocks[]') // Group consecutive blocks sharing the same background into one row. const rows = [] let current = null for (const b of blocks) { const bg = b.background || '' if (!current || current.bg !== bg) { if (current) rows.push(current) current = { bg, blocks: [] } } current.blocks.push(b) } if (current) rows.push(current) const factoryFor = (b) => { switch (b.type) { case 'text': return `textBlock({ html: ${JSON.stringify(b.html || '')}, padding: ${JSON.stringify(b.padding || '10px')}, fontSize: ${JSON.stringify(b.fontSize || '16px')}, fontWeight: ${JSON.stringify(b.fontWeight || 400)}, color: ${JSON.stringify(b.color || '#374151')}, textAlign: ${JSON.stringify(b.textAlign || 'left')}${b.lineHeight ? `, lineHeight: ${JSON.stringify(b.lineHeight)}` : ''} })` case 'image': return `imageBlock({ src: ${JSON.stringify(b.src || '')}, alt: ${JSON.stringify(b.alt || '')}, width: ${b.width || 140}, padding: ${JSON.stringify(b.padding || '10px')}, textAlign: ${JSON.stringify(b.textAlign || 'center')}${b.href ? `, href: ${JSON.stringify(b.href)}` : ''} })` case 'button': return `buttonBlock({ text: ${JSON.stringify(b.text || '')}, href: ${JSON.stringify(b.href || '')}, padding: ${JSON.stringify(b.padding || '10px 25px')}, buttonPadding: ${JSON.stringify(b.buttonPadding || '30px 24px')}, bgColor: ${JSON.stringify(b.bgColor || '#00C853')}, color: ${JSON.stringify(b.color || '#FFFFFF')}, borderRadius: ${JSON.stringify(b.borderRadius || '12px')}, fontSize: ${JSON.stringify(b.fontSize || '32px')} })` case 'divider': return `dividerBlock({ padding: ${JSON.stringify(b.padding || '10px')} })` case 'html': default: return `htmlBlock({ html: ${JSON.stringify(b.html || '')}, padding: ${JSON.stringify(b.padding || '0')} })` } } const rowSrcs = rows.map(r => { const blockSrcs = r.blocks.map(b => ` ${factoryFor(b)}, // ${b.purpose || b.type}`).join('\n') const opts = r.bg ? `, { backgroundColor: ${JSON.stringify(r.bg)}, border: '1px solid #e5e7eb' }` : '' return ` row([\n${blockSrcs}\n ]${opts}),` }) return `'use strict' /** * AUTO-GENERATED by ai-convert-to-native.js — review before deploying. * * The AI identified ${blocks.length} semantic block(s) in ${rows.length} row(s). * Each text/image/button block is individually editable in Unlayer. * html blocks (chips, multi-image strips) require raw HTML edits. */ module.exports = { preheader: ${JSON.stringify(preheader || '')}, ariaLabel: ${JSON.stringify(ariaLabel || '')}, rows: ({ textBlock, imageBlock, buttonBlock, htmlBlock, dividerBlock, row }) => [ ${rowSrcs.join('\n\n')} ], } ` } // ── CLI ─────────────────────────────────────────────────────────────────── async function main () { const srcName = process.argv[2] if (!srcName) { console.error('Usage: ai-convert-to-native.js '); process.exit(1) } const tplDir = path.resolve(__dirname, '..', 'templates') const srcPath = path.join(tplDir, srcName + '.html') if (!fs.existsSync(srcPath)) { console.error(`✗ Source not found: ${srcPath}`); process.exit(1) } const innerHtml = extractInnerBody(fs.readFileSync(srcPath, 'utf8')) console.log(`→ Source: ${srcName}.html (${innerHtml.length}b inner body)`) console.log('→ Asking Gemini Flash to identify blocks...') const aiResult = await aiCall(SYSTEM_PROMPT, buildUserPrompt(innerHtml), { jsonMode: true, maxTokens: 32768, temperature: 0.2, }) if (aiResult.error || aiResult.raw) { console.error('✗ AI returned unparseable output:', aiResult); process.exit(1) } console.log(`→ AI identified ${aiResult.blocks?.length || 0} blocks`) const outName = srcName + '-native' const specPath = path.resolve(__dirname, 'templates-spec', outName + '.js') const specSrc = generateSpec(aiResult) fs.writeFileSync(specPath, specSrc, 'utf8') console.log(`✓ Spec written: scripts/templates-spec/${outName}.js (${specSrc.length}b)`) // Sanity check: every {{var}} present in the source must survive into the // generated spec. The AI occasionally drops minor placeholders ({{year}} // in a copyright line is the canonical miss) — warn loudly so the // operator can patch the spec before building. const srcVars = new Set([...innerHtml.matchAll(/\{\{\s*[#/]?\s*([a-z0-9_]+)\s*\}\}/gi)].map(m => m[1].toLowerCase())) const specVars = new Set([...specSrc.matchAll(/\{\{\s*[#/]?\s*([a-z0-9_]+)\s*\}\}/gi)].map(m => m[1].toLowerCase())) const missing = [...srcVars].filter(v => !specVars.has(v)) if (missing.length) { console.warn(`⚠ ${missing.length} Mustache variable(s) NOT preserved in spec — review before building:`) for (const v of missing) console.warn(` - {{${v}}}`) console.warn(' The AI sometimes drops minor placeholders. Edit the spec file to restore them.') } else { console.log(`✓ All ${srcVars.size} Mustache variable(s) preserved in spec`) } console.log() console.log('Next step: review the spec, then build the files:') console.log(` node scripts/build-native-template.js ${outName}`) console.log() console.log('If the layout is good, deploy by copying the generated') console.log(`templates/${outName}.html and .json to prod.`) } main().catch(e => { console.error('Fatal:', e); process.exit(1) })