289 lines
17 KiB
JavaScript
289 lines
17 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 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 safeBackgroundMode(value) {
|
|
return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
|
|
}
|
|
|
|
function safeDensityMode(value) {
|
|
return ['auto', 'comfortable', 'compact'].includes(value) ? value : 'auto'
|
|
}
|
|
|
|
function resolveDensity(template, mode) {
|
|
if (mode !== 'auto') return mode
|
|
const blocks = Array.isArray(template?.blocks) ? template.blocks : []
|
|
const tableCount = blocks.filter(block => block?.type === 'table').length
|
|
return blocks.length >= 20 || tableCount >= 4 ? 'compact' : 'comfortable'
|
|
}
|
|
|
|
function themeOf(template) {
|
|
const theme = template?.theme || {}
|
|
const reportDensityMode = safeDensityMode(theme.reportDensity)
|
|
const reportDensity = resolveDensity(template, reportDensityMode)
|
|
const baseFontSize = safeNumber(theme.baseFontSize, 15, 10, 22)
|
|
const headingSize = safeNumber(theme.headingSize, 23, 14, 42)
|
|
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),
|
|
baseFontSize,
|
|
bodyFontSize: Math.max(10, baseFontSize - (reportDensity === 'compact' ? 2 : 0)),
|
|
headingSize,
|
|
renderedHeadingSize: Math.max(14, headingSize - (reportDensity === 'compact' ? 4 : 0)),
|
|
lineHeight: safeNumber(theme.lineHeight, 1.45, 1.1, 1.9),
|
|
blockGap: safeNumber(theme.blockGap, 10, 0, 32),
|
|
cardPadding: safeNumber(theme.cardPadding, 12, 4, 32),
|
|
tableCellPadding: safeNumber(theme.tableCellPadding, 8, 2, 20),
|
|
pagePadding: safeNumber(theme.pagePadding, 18, 0, 80),
|
|
pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40),
|
|
pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40),
|
|
pageMarginBottom: safeNumber(theme.pageMarginBottom, 12, 0, 40),
|
|
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
|
|
fontFamily: safeFont(theme.fontFamily),
|
|
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
|
|
pageBackgroundMode: safeBackgroundMode(theme.pageBackgroundMode),
|
|
pageBackgroundOpacity: safeNumber(theme.pageBackgroundOpacity, 0.08, 0, 0.35),
|
|
decorativeBorder: theme.decorativeBorder === true,
|
|
borderImage: safeUrl(theme.borderImage, {}),
|
|
reportDensityMode,
|
|
reportDensity
|
|
}
|
|
}
|
|
|
|
function safeFit(value) {
|
|
return ['contain', 'cover', 'fill'].includes(value) ? value : 'contain'
|
|
}
|
|
|
|
function safeVariant(block) {
|
|
const explicit = String(block?.props?.variant || '').toLowerCase()
|
|
if (['logo', 'hero', 'image'].includes(explicit)) return explicit
|
|
const marker = `${block?.id || ''} ${block?.props?.alt || ''}`.toLowerCase()
|
|
if (marker.includes('logo') || marker.includes('mark')) return 'logo'
|
|
if (marker.includes('hero') || marker.includes('banner')) return 'hero'
|
|
return 'image'
|
|
}
|
|
|
|
function reportBlockStyle(block, theme) {
|
|
const style = block?.style || {}
|
|
const compact = theme.reportDensity === 'compact'
|
|
const forceBreakBefore = style.forcePageBreak === true
|
|
const breakBefore = (style.pageBreakBefore === true && (!compact || forceBreakBefore)) ? 'page' : 'auto'
|
|
const keepWithNext = style.keepWithNext === true || (compact && block?.type === 'heading')
|
|
const breakAfter = style.pageBreakAfter === true ? 'page' : (keepWithNext ? 'avoid-page' : 'auto')
|
|
const breakInside = style.allowPageBreak === true ? 'auto' : 'avoid-page'
|
|
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)}`,
|
|
`break-before:${breakBefore}`,
|
|
`break-after:${breakAfter}`,
|
|
`break-inside:${breakInside}`,
|
|
`page-break-inside:${breakInside === 'avoid-page' ? 'avoid' : 'auto'}`
|
|
]
|
|
return rules.join(';')
|
|
}
|
|
|
|
function imageStyle(block, theme) {
|
|
const style = block?.style || {}
|
|
const variant = safeVariant(block)
|
|
const defaults = variant === 'logo'
|
|
? { maxWidth: 110, maxHeight: 72, fit: 'contain' }
|
|
: variant === 'hero'
|
|
? { maxWidth: 999, maxHeight: 260, fit: 'cover' }
|
|
: { maxWidth: 999, maxHeight: 340, fit: 'contain' }
|
|
|
|
const maxWidth = safeNumber(style.maxWidth ?? block?.props?.maxWidth, defaults.maxWidth, 24, 999)
|
|
const maxHeight = safeNumber(style.maxHeight ?? block?.props?.maxHeight, defaults.maxHeight, 24, 900)
|
|
const width = style.width ?? block?.props?.width
|
|
const widthRule = width == null || width === '' ? 'width:auto' : `width:${safeNumber(width, maxWidth, 24, 999)}px`
|
|
const fit = safeFit(style.fit ?? block?.props?.fit ?? defaults.fit)
|
|
const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32)
|
|
|
|
return [
|
|
widthRule,
|
|
`max-width:min(100%,${maxWidth}px)`,
|
|
`max-height:${maxHeight}px`,
|
|
`object-fit:${fit}`,
|
|
`border-radius:${radius}px`,
|
|
'display:inline-block'
|
|
].join(';')
|
|
}
|
|
|
|
function wrapReportBlock(block, theme, html) {
|
|
const variant = block?.type === 'image' ? ` image-${safeVariant(block)}` : ''
|
|
return `<div class="block${variant}" style="${reportBlockStyle(block, theme)}">${html}</div>`
|
|
}
|
|
|
|
function renderTable(block, data, theme) {
|
|
const rows = getPath(data, block.props?.rowsPath) || []
|
|
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
|
|
if (!Array.isArray(rows)) return ''
|
|
const allowBreak = block?.style?.allowPageBreak === true
|
|
const compact = theme.reportDensity === 'compact' || block?.style?.compact === true
|
|
const classes = ['table-wrap', allowBreak ? 'allow-break' : '', compact ? 'compact-table' : ''].filter(Boolean).join(' ')
|
|
return `<div class="${classes}"><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, theme) {
|
|
const p = block.props || {}
|
|
let html = ''
|
|
switch (block.type) {
|
|
case 'heading':
|
|
html = `<h2>${escapeHtml(interpolate(p.text, data))}</h2>`
|
|
break
|
|
case 'text':
|
|
html = `<p>${escapeHtml(interpolate(p.text, data))}</p>`
|
|
break
|
|
case 'kpi':
|
|
html = `<div class="kpi"><span>${escapeHtml(interpolate(p.label, data))}</span><strong>${escapeHtml(interpolate(p.value, data))}</strong></div>`
|
|
break
|
|
case 'button':
|
|
html = `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
|
break
|
|
case 'image':
|
|
html = `<img class="image" style="${imageStyle(block, theme)}" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
|
break
|
|
case 'divider':
|
|
html = '<hr />'
|
|
break
|
|
case 'table':
|
|
html = renderTable(block, data, theme)
|
|
break
|
|
default:
|
|
return ''
|
|
}
|
|
return wrapReportBlock(block, theme, html)
|
|
}
|
|
|
|
function renderReportBlocks(blocks, data, theme) {
|
|
const output = []
|
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
const block = blocks[index]
|
|
if (theme.reportDensity === 'compact' && block?.type === 'kpi') {
|
|
const group = [block]
|
|
while (index + 1 < blocks.length && blocks[index + 1]?.type === 'kpi') {
|
|
group.push(blocks[index + 1])
|
|
index += 1
|
|
}
|
|
if (group.length > 1) {
|
|
output.push(`<div class="kpi-grid">${group.map(item => blockHtml(item, data, theme)).join('')}</div>`)
|
|
continue
|
|
}
|
|
}
|
|
output.push(blockHtml(block, data, theme))
|
|
}
|
|
return output.join('\n')
|
|
}
|
|
|
|
export function renderReportHtml(template, data = {}) {
|
|
const blocks = template?.blocks || []
|
|
const theme = themeOf(template)
|
|
const hasBackground = theme.pageBackgroundImage !== '#' && theme.pageBackgroundMode !== 'none'
|
|
const backgroundImage = hasBackground ? `url("${escapeHtml(theme.pageBackgroundImage)}")` : 'none'
|
|
const borderEnabled = theme.decorativeBorder && theme.borderImage !== '#'
|
|
const borderImage = borderEnabled ? `url("${escapeHtml(theme.borderImage)}") 30 stretch` : 'none'
|
|
const backgroundSize = theme.pageBackgroundMode === 'full' ? 'cover' : '100% auto'
|
|
|
|
return `<!doctype html>
|
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<style>
|
|
@page{size:A4;margin:${theme.pageMarginTop}mm ${theme.pageMarginRight}mm ${theme.pageMarginBottom}mm ${theme.pageMarginLeft}mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${theme.bodyFontSize}px/${theme.lineHeight} ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{position:relative;isolation:isolate;overflow:hidden;min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${borderEnabled ? 10 : 0}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.document::before{content:"";position:absolute;z-index:-1;inset:0;background-image:${backgroundImage};background-size:${backgroundSize};background-position:center top;background-repeat:no-repeat;opacity:${hasBackground ? theme.pageBackgroundOpacity : 0};pointer-events:none}.block{margin:0 0 ${theme.reportDensity === 'compact' ? Math.max(2, theme.blockGap - 4) : theme.blockGap}px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:${theme.renderedHeadingSize}px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0 0 6px;break-inside:avoid-page;page-break-inside:avoid}.kpi-grid>.block{margin:0;min-width:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:${theme.reportDensity === 'compact' ? `${Math.max(4, theme.cardPadding - 4)}px ${Math.max(6, theme.cardPadding - 2)}px` : `${theme.cardPadding}px ${theme.cardPadding + 2}px`};display:flex;justify-content:space-between;gap:10px;background:${theme.surfaceColor}}.kpi-grid .kpi{height:100%;display:grid;gap:3px;align-content:center}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:${theme.reportDensity === 'compact' ? 16 : 18}px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:${theme.reportDensity === 'compact' ? 6 : 10}px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}.table-wrap.allow-break{overflow:visible;break-inside:auto;page-break-inside:auto}.table-wrap.allow-break table{border-collapse:collapse}.compact-table{border-radius:${Math.min(theme.borderRadius, 6)}px}table{border-collapse:collapse;width:100%;font-size:${Math.max(9.5, theme.bodyFontSize - 2)}px}thead{display:table-header-group}tfoot{display:table-footer-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:${theme.reportDensity === 'compact' ? `${Math.max(2, theme.tableCellPadding - 3)}px ${Math.max(3, theme.tableCellPadding - 2)}px` : `${theme.tableCellPadding}px ${theme.tableCellPadding + 2}px`};text-align:left;border-bottom:1px solid ${theme.borderColor};vertical-align:top}th{background:${theme.backgroundColor};font-weight:700}.compact-table th,.compact-table td{padding:${Math.max(2, theme.tableCellPadding - 3)}px ${Math.max(3, theme.tableCellPadding - 2)}px;font-size:${Math.max(9.5, theme.bodyFontSize - 2)}px}
|
|
</style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>`
|
|
}
|
|
|
|
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 `<mj-text ${common} font-size="${theme.headingSize}px" line-height="${theme.lineHeight}" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
|
case 'text':
|
|
return `<mj-text ${common} container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
|
case 'kpi':
|
|
return `<mj-text padding="${padding}" color="${theme.mutedColor}" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.label, data))}</mj-text><mj-text padding="0 ${padding} ${padding}" color="${color}" font-size="22px" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.value, data))}</mj-text>`
|
|
case 'button':
|
|
return `<mj-button background-color="${safeHex(style.backgroundColor, theme.accentColor)}" color="${safeHex(style.textColor, theme.buttonTextColor)}" font-size="${theme.baseFontSize}px" border-radius="${radius}px" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</mj-button>`
|
|
case 'image':
|
|
return `<mj-image padding="${padding}" border="${borderWidth}px solid ${borderColor}" border-radius="${radius}px" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
|
case 'divider':
|
|
return `<mj-divider border-color="${borderColor}" border-width="${Math.max(1, borderWidth)}px" />`
|
|
default:
|
|
return '<mj-text color="#64748b">This block is available in report output only.</mj-text>'
|
|
}
|
|
}
|
|
|
|
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 = `<mjml><mj-head><mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="${theme.baseFontSize}px" line-height="${theme.lineHeight}" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><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
|
|
}
|