TS246 linhas12.778 bytes
apps/worker/src/services/newsletter-html.ts
SHA-256
3a8264b5db907f2073cf3bf7c4bc1299906485e72255a98718993d399eefb9cf
Somente leiturafonte-acf635076da2
/**
* Renderiza o markdown gerado pela IA + tabela top 5 num HTML email-safe.
* Sem CSS externo, sem JS, layout em tabela aninhada para máxima compatibilidade
* (Gmail, Outlook, Apple Mail, clients web).
*/
import type { NewsletterData } from './newsletter-data'
const SITE = 'https://observasenado.org'
const COR_INK = '#0d1421'
const COR_MUTED = '#5a6273'
const COR_BORDER = '#e5e7eb'
const COR_PANEL = '#f7f9fb'
const COR_PRIMARY = '#1e3a8a'
const COR_ACCENT = '#92400e'
function escapeHtml(s: string): string {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
}
/** Conversão markdown → HTML mínima, suficiente pro output da IA (## ### **bold** *italic* lista). */
function mdToHtml(md: string): string {
const lines = md.split('\n')
const out: string[] = []
let inList = false
for (const raw of lines) {
const line = raw.trimEnd()
if (line.startsWith('## ')) {
if (inList) { out.push('</ul>'); inList = false }
out.push(`<h2 style="font-family:Georgia,serif;font-size:20px;color:${COR_INK};margin:32px 0 12px;border-bottom:1px solid ${COR_BORDER};padding-bottom:6px;">${escapeHtml(line.slice(3))}</h2>`)
} else if (line.startsWith('### ')) {
if (inList) { out.push('</ul>'); inList = false }
out.push(`<h3 style="font-family:Georgia,serif;font-size:16px;color:${COR_INK};margin:20px 0 8px;">${escapeHtml(line.slice(4))}</h3>`)
} else if (line.startsWith('- ') || line.startsWith('* ')) {
if (!inList) { out.push(`<ul style="margin:8px 0 14px;padding-left:20px;color:${COR_MUTED};">`); inList = true }
out.push(`<li style="margin:4px 0;line-height:1.55;">${inlineMd(line.slice(2))}</li>`)
} else if (line.match(/^\d+\.\s/)) {
if (!inList) { out.push(`<ol style="margin:8px 0 14px;padding-left:20px;color:${COR_MUTED};">`); inList = true }
out.push(`<li style="margin:4px 0;line-height:1.55;">${inlineMd(line.replace(/^\d+\.\s/, ''))}</li>`)
} else if (line.trim() === '') {
if (inList) { out.push('</ul>'); inList = false }
} else {
if (inList) { out.push('</ul>'); inList = false }
out.push(`<p style="margin:0 0 14px;font-size:15px;line-height:1.6;color:${COR_MUTED};">${inlineMd(line)}</p>`)
}
}
if (inList) out.push('</ul>')
return out.join('\n')
}
function inlineMd(s: string): string {
return escapeHtml(s)
.replace(/\*\*(.+?)\*\*/g, `<strong style="color:${COR_INK};">$1</strong>`)
.replace(/\*(.+?)\*/g, '<em>$1</em>')
}
function deltaBadge(delta: number | undefined): string {
if (delta == null || delta === 0) return `<span style="color:${COR_MUTED};font-family:monospace;">—</span>`
const isUp = delta > 0
const cor = isUp ? '#15803d' : '#b91c1c'
const seta = isUp ? '▲' : '▼'
return `<span style="color:${cor};font-family:monospace;">${seta}${Math.abs(delta)}</span>`
}
function formatBR(d: string): string {
if (!d) return ''
const [y, m, dd] = d.split('-')
return `${dd}/${m}/${y}`
}
export interface NewsletterHtmlOutput {
subject: string
preheader: string
html: string
text: string // versão plain-text fallback
}
export function renderNewsletter(
data: NewsletterData,
markdown: string,
): NewsletterHtmlOutput {
const subject = `Observatório do Senado · Edição ${data.edicao} (${formatBR(data.semana.inicio)} a ${formatBR(data.semana.fim)})`
const preheader = `Análise da ${data.edicao}: ${data.totalSenadores} senadores, IDS médio ${data.panorama.idsMedio.toFixed(1)}, ${data.atividade.totalProcessosNovos} matérias novas e ${data.atividade.totalVotacoes} votações.`
const corpoMd = mdToHtml(markdown)
const linhasTop = data.topAtual
.slice(0, 5)
.map((s) => `
<tr>
<td style="padding:8px 10px;border-bottom:1px solid ${COR_BORDER};font-family:monospace;color:${COR_MUTED};font-size:13px;">${s.posicao}º</td>
<td style="padding:8px 10px;border-bottom:1px solid ${COR_BORDER};color:${COR_INK};font-size:14px;">
<a href="${SITE}/senador?codigo=${s.senadorCod}" style="color:${COR_INK};text-decoration:none;font-weight:600;">${escapeHtml(s.nome)}</a>
<span style="color:${COR_MUTED};font-size:12px;">${escapeHtml(s.partido)}/${escapeHtml(s.uf)}</span>
</td>
<td style="padding:8px 10px;border-bottom:1px solid ${COR_BORDER};text-align:right;font-family:monospace;color:${COR_INK};font-size:14px;font-weight:600;">${s.idsTotal.toFixed(1)}</td>
<td style="padding:8px 10px;border-bottom:1px solid ${COR_BORDER};text-align:right;font-size:13px;">${deltaBadge(s.delta)}</td>
</tr>`)
.join('')
const linhasMovers = data.topMovers.slice(0, 4).map((s) => `
<tr>
<td style="padding:6px 10px;border-bottom:1px solid ${COR_BORDER};color:${COR_INK};font-size:13px;">
<a href="${SITE}/senador?codigo=${s.senadorCod}" style="color:${COR_INK};text-decoration:none;">${escapeHtml(s.nome)}</a>
<span style="color:${COR_MUTED};font-size:11px;">${escapeHtml(s.partido)}/${escapeHtml(s.uf)}</span>
</td>
<td style="padding:6px 10px;border-bottom:1px solid ${COR_BORDER};text-align:right;font-size:12px;color:${COR_MUTED};">${s.posicaoAnterior ?? '—'}º → ${s.posicao}º</td>
<td style="padding:6px 10px;border-bottom:1px solid ${COR_BORDER};text-align:right;font-size:13px;">${deltaBadge(s.delta)}</td>
</tr>`).join('')
const html = `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${escapeHtml(subject)}</title>
</head>
<body style="margin:0;padding:0;background:${COR_PANEL};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:${COR_INK};">
<!-- preheader oculto -->
<div style="display:none;font-size:1px;color:${COR_PANEL};line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
${escapeHtml(preheader)}
</div>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:${COR_PANEL};">
<tr>
<td align="center" style="padding:24px 12px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width:640px;background:#ffffff;border:1px solid ${COR_BORDER};border-radius:4px;">
<!-- HEADER -->
<tr>
<td style="padding:24px 28px 12px;border-bottom:1px solid ${COR_BORDER};">
<p style="margin:0;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:${COR_MUTED};font-weight:600;">Observatório do Senado</p>
<h1 style="margin:8px 0 4px;font-family:Georgia,serif;font-size:24px;color:${COR_INK};font-weight:600;">Edição ${escapeHtml(data.edicao)}</h1>
<p style="margin:0;font-size:13px;color:${COR_MUTED};">Semana de ${formatBR(data.semana.inicio)} a ${formatBR(data.semana.fim)}</p>
</td>
</tr>
<!-- PANORAMA STATS -->
<tr>
<td style="padding:20px 28px 8px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="33%" style="text-align:center;padding:8px;border-right:1px solid ${COR_BORDER};">
<p style="margin:0;font-size:10px;letter-spacing:1px;text-transform:uppercase;color:${COR_MUTED};">IDS médio</p>
<p style="margin:4px 0 0;font-family:Georgia,serif;font-size:22px;color:${COR_INK};font-weight:600;">${data.panorama.idsMedio.toFixed(1)}</p>
</td>
<td width="33%" style="text-align:center;padding:8px;border-right:1px solid ${COR_BORDER};">
<p style="margin:0;font-size:10px;letter-spacing:1px;text-transform:uppercase;color:${COR_MUTED};">Matérias novas</p>
<p style="margin:4px 0 0;font-family:Georgia,serif;font-size:22px;color:${COR_INK};font-weight:600;">${data.atividade.totalProcessosNovos}</p>
</td>
<td width="33%" style="text-align:center;padding:8px;">
<p style="margin:0;font-size:10px;letter-spacing:1px;text-transform:uppercase;color:${COR_MUTED};">Votações</p>
<p style="margin:4px 0 0;font-family:Georgia,serif;font-size:22px;color:${COR_INK};font-weight:600;">${data.atividade.totalVotacoes}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- ANÁLISE (markdown da IA) -->
<tr>
<td style="padding:8px 28px 16px;">
${corpoMd}
</td>
</tr>
<!-- TOP 5 -->
<tr>
<td style="padding:0 28px 24px;">
<h2 style="font-family:Georgia,serif;font-size:20px;color:${COR_INK};margin:24px 0 12px;border-bottom:1px solid ${COR_BORDER};padding-bottom:6px;">Top 5 esta semana</h2>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="border:1px solid ${COR_BORDER};border-radius:3px;">
<thead>
<tr style="background:${COR_PANEL};">
<th style="padding:8px 10px;text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:${COR_MUTED};font-weight:600;">Pos</th>
<th style="padding:8px 10px;text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:${COR_MUTED};font-weight:600;">Senador</th>
<th style="padding:8px 10px;text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:${COR_MUTED};font-weight:600;">IDS</th>
<th style="padding:8px 10px;text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:${COR_MUTED};font-weight:600;">Δ</th>
</tr>
</thead>
<tbody>${linhasTop}</tbody>
</table>
<p style="margin:8px 0 0;font-size:12px;color:${COR_MUTED};">
<a href="${SITE}" style="color:${COR_PRIMARY};text-decoration:none;">Ver ranking completo →</a>
</p>
</td>
</tr>
${data.topMovers.length > 0 ? `
<!-- MOVERS -->
<tr>
<td style="padding:0 28px 24px;">
<h3 style="font-family:Georgia,serif;font-size:16px;color:${COR_INK};margin:0 0 8px;">Maiores movimentos</h3>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="border:1px solid ${COR_BORDER};border-radius:3px;">
${linhasMovers}
</table>
</td>
</tr>` : ''}
<!-- CTA -->
<tr>
<td style="padding:8px 28px 28px;text-align:center;">
<a href="${SITE}" style="display:inline-block;background:${COR_PRIMARY};color:#fff;text-decoration:none;padding:12px 28px;border-radius:3px;font-size:13px;letter-spacing:0.5px;text-transform:uppercase;font-weight:600;">
Acessar o Observatório do Senado
</a>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td style="padding:20px 28px;background:${COR_PANEL};border-top:1px solid ${COR_BORDER};">
<p style="margin:0;font-size:11px;color:${COR_MUTED};line-height:1.6;">
Você recebeu este e-mail porque é assinante da newsletter do Observatório do Senado, iniciativa cidadã independente sem vínculo com o Senado Federal, partidos ou mandatos parlamentares. Dados oficiais coletados em <a href="https://legis.senado.leg.br/dadosabertos" style="color:${COR_PRIMARY};">legis.senado.leg.br/dadosabertos</a>.
</p>
<p style="margin:10px 0 0;font-size:11px;color:${COR_MUTED};">
<a href="${SITE}/metodologia" style="color:${COR_PRIMARY};text-decoration:none;">Metodologia</a> ·
<a href="${SITE}/transparencia" style="color:${COR_PRIMARY};text-decoration:none;">Transparência</a> ·
<a href="${SITE}/apoie" style="color:${COR_PRIMARY};text-decoration:none;">Apoie</a> ·
<a href="{{UNSUBSCRIBE_URL}}" style="color:${COR_MUTED};text-decoration:underline;">Cancelar inscrição</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
// Plain-text fallback
const text = [
`OBSERVATÓRIO DO SENADO · Edição ${data.edicao}`,
`Semana ${formatBR(data.semana.inicio)} a ${formatBR(data.semana.fim)}`,
'',
markdown,
'',
'---',
'Top 5 esta semana:',
...data.topAtual.slice(0, 5).map((s) => `${s.posicao}º. ${s.nome} (${s.partido}/${s.uf}) — IDS ${s.idsTotal.toFixed(1)}`),
'',
`Acessar: ${SITE}`,
'Cancelar: {{UNSUBSCRIBE_URL}}',
].join('\n')
return { subject, preheader, html, text }
}