94 lines
4.8 KiB
JavaScript
94 lines
4.8 KiB
JavaScript
import mjml2html from 'mjml'
|
|
|
|
function getPath(data, path) {
|
|
return String(path || '').split('.').filter(Boolean).reduce((value, key) => value?.[key], data)
|
|
}
|
|
|
|
export function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
}
|
|
|
|
export function interpolate(input, data) {
|
|
return String(input ?? '').replace(/{{\s*([\w.[\]-]+)\s*}}/g, (_, path) => {
|
|
const normalized = path.replace(/\[(\d+)\]/g, '.$1')
|
|
const value = getPath(data, normalized)
|
|
return value == null ? '' : String(value)
|
|
})
|
|
}
|
|
|
|
function safeUrl(input, data) {
|
|
const url = interpolate(input, data).trim()
|
|
return /^(https?:|mailto:)/i.test(url) ? url : '#'
|
|
}
|
|
|
|
function renderTable(block, data) {
|
|
const rows = getPath(data, block.props?.rowsPath) || []
|
|
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
|
|
if (!Array.isArray(rows)) return ''
|
|
return `<div class="table-wrap"><table><thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escapeHtml(getPath(row, c.key))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
|
|
}
|
|
|
|
function blockHtml(block, data) {
|
|
const p = block.props || {}
|
|
switch (block.type) {
|
|
case 'heading':
|
|
return `<h2>${escapeHtml(interpolate(p.text, data))}</h2>`
|
|
case 'text':
|
|
return `<p>${escapeHtml(interpolate(p.text, data))}</p>`
|
|
case 'kpi':
|
|
return `<div class="kpi"><span>${escapeHtml(interpolate(p.label, data))}</span><strong>${escapeHtml(interpolate(p.value, data))}</strong></div>`
|
|
case 'button':
|
|
return `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
|
case 'image':
|
|
return `<img class="image" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
|
case 'divider':
|
|
return '<hr />'
|
|
case 'table':
|
|
return renderTable(block, data)
|
|
default:
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export function renderReportHtml(template, data = {}) {
|
|
const blocks = template?.blocks || []
|
|
return `<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<style>
|
|
@page{size:A4;margin:18mm}*{box-sizing:border-box}body{font:15px/1.5 system-ui,-apple-system,sans-serif;color:#172033;margin:0}h2{font-size:24px;margin:0 0 16px}p{margin:0 0 14px}.kpi{border:1px solid #dbe2ea;border-radius:12px;padding:14px;margin:0 0 12px;display:flex;justify-content:space-between;gap:16px}.kpi span{color:#64748b}.kpi strong{font-size:18px}.button{display:inline-block;background:#111827;color:white;text-decoration:none;padding:10px 16px;border-radius:10px}.image{max-width:100%;height:auto;border-radius:10px}hr{border:0;border-top:1px solid #dbe2ea;margin:18px 0}.table-wrap{overflow:hidden;border:1px solid #dbe2ea;border-radius:10px;margin:0 0 16px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid #eef2f7}th{background:#f8fafc}
|
|
</style></head><body>${blocks.map(block => blockHtml(block, data)).join('\n')}</body></html>`
|
|
}
|
|
|
|
function emailBlock(block, data) {
|
|
const p = block.props || {}
|
|
switch (block.type) {
|
|
case 'heading':
|
|
return `<mj-text font-size="24px" font-weight="700">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
|
case 'text':
|
|
return `<mj-text>${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
|
case 'kpi':
|
|
return `<mj-text color="#64748b">${escapeHtml(interpolate(p.label, data))}</mj-text><mj-text font-size="22px" font-weight="700">${escapeHtml(interpolate(p.value, data))}</mj-text>`
|
|
case 'button':
|
|
return `<mj-button background-color="#111827" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</mj-button>`
|
|
case 'image':
|
|
return `<mj-image src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
|
case 'divider':
|
|
return '<mj-divider border-color="#dbe2ea" />'
|
|
default:
|
|
return '<mj-text color="#64748b">This block is available in report output only.</mj-text>'
|
|
}
|
|
}
|
|
|
|
export function renderEmailHtml(template, data = {}) {
|
|
const body = (template?.blocks || []).map(block => emailBlock(block, data)).join('\n')
|
|
const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="Arial, sans-serif" /><mj-text font-size="15px" line-height="1.5" color="#172033" /></mj-attributes></mj-head><mj-body background-color="#f1f5f9"><mj-section background-color="#ffffff" padding="24px"><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
|
const result = mjml2html(mjml, { validationLevel: 'soft' })
|
|
if (result.errors?.length) console.warn('MJML warnings', result.errors)
|
|
return result.html
|
|
}
|