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 safeHex(value, fallback) { const color = String(value || '').trim() return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : fallback } function safeNumber(value, fallback, min, max) { const number = Number(value) if (!Number.isFinite(number)) return fallback return Math.min(max, Math.max(min, number)) } function safeAlign(value) { return ['left', 'center', 'right'].includes(value) ? value : 'left' } function safeFont(value) { const font = String(value || '') const allowed = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui'] return allowed.includes(font) ? font : 'Arial' } function themeOf(template) { const theme = template?.theme || {} return { backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'), surfaceColor: safeHex(theme.surfaceColor, '#ffffff'), textColor: safeHex(theme.textColor, '#172033'), mutedColor: safeHex(theme.mutedColor, '#64748b'), accentColor: safeHex(theme.accentColor, '#111827'), buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), borderColor: safeHex(theme.borderColor, '#dbe2ea'), borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), pagePadding: safeNumber(theme.pagePadding, 18, 0, 40), fontFamily: safeFont(theme.fontFamily), pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}), borderImage: safeUrl(theme.borderImage, {}) } } function reportBlockStyle(block, theme) { const style = block?.style || {} const rules = [ `background:${safeHex(style.backgroundColor, 'transparent')}`, `color:${safeHex(style.textColor, theme.textColor)}`, `border-color:${safeHex(style.borderColor, theme.borderColor)}`, `border-width:${safeNumber(style.borderWidth, 0, 0, 8)}px`, 'border-style:solid', `border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`, `padding:${safeNumber(style.padding, 0, 0, 48)}px`, `text-align:${safeAlign(style.align)}` ] return rules.join(';') } function wrapReportBlock(block, theme, html) { return `
${html}
` } 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 `
${columns.map(c => ``).join('')}${rows.map(row => `${columns.map(c => ``).join('')}`).join('')}
${escapeHtml(c.label)}
${escapeHtml(getPath(row, c.key))}
` } function blockHtml(block, data, theme) { const p = block.props || {} let html = '' switch (block.type) { case 'heading': html = `

${escapeHtml(interpolate(p.text, data))}

` break case 'text': html = `

${escapeHtml(interpolate(p.text, data))}

` break case 'kpi': html = `
${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}
` break case 'button': html = `

${escapeHtml(interpolate(p.text, data))}

` break case 'image': html = `${escapeHtml(interpolate(p.alt, data))}` break case 'divider': html = '
' break case 'table': html = renderTable(block, data) break default: return '' } return wrapReportBlock(block, theme, html) } export function renderReportHtml(template, data = {}) { const blocks = template?.blocks || [] const theme = themeOf(template) const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")` const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch` return `
${blocks.map(block => blockHtml(block, data, theme)).join('\n')}
` } function emailBlock(block, data, theme) { const p = block.props || {} const style = block.style || {} const padding = `${safeNumber(style.padding, 0, 0, 48)}px` const align = safeAlign(style.align) const color = safeHex(style.textColor, theme.textColor) const backgroundColor = safeHex(style.backgroundColor, theme.surfaceColor) const borderColor = safeHex(style.borderColor, theme.borderColor) const borderWidth = safeNumber(style.borderWidth, 0, 0, 8) const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32) const common = `padding="${padding}" align="${align}" color="${color}"` switch (block.type) { case 'heading': return `${escapeHtml(interpolate(p.text, data))}` case 'text': return `${escapeHtml(interpolate(p.text, data))}` case 'kpi': return `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}` case 'button': return `${escapeHtml(interpolate(p.text, data))}` case 'image': return `` case 'divider': return `` default: return 'This block is available in report output only.' } } export function renderEmailHtml(template, data = {}) { const theme = themeOf(template) const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n') const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"` const mjml = `${body}` const result = mjml2html(mjml, { validationLevel: 'soft' }) if (result.errors?.length) console.warn('MJML warnings', result.errors) return result.html }