/**
 * routes/blog.js — Blog index and article pages.
 *
 * Owns: GET /blog (curated external articles page), GET /blog/:slug (article), GET /api/blog/posts (JSON API).
 * Does NOT own: user auth, health checks, payments, DB connection.
 *
 * GET /blog serves public/blog.html — a static curated articles page linking to linuxappsdba.blogspot.com.
 * GET /blog/:slug serves individual DB-backed article pages (legacy path, kept for SEO continuity).
 * Mounted at / in server.js (so /blog and /blog/:slug work directly).
 *
 * Analytics: every page here tracks via the client-side /tv-tracker.js, same as
 * the rest of the site. Do NOT reintroduce a server-side trackEvent page_view —
 * it cannot see a session id (the tv_sid cookie is set by tv-tracker.js alone)
 * and carries no UTM, which is what broke the blog visitor counter and traffic
 * attribution until 2026-08-09.
 */

'use strict';

const express = require('express');
const path = require('path');
const fs = require('fs');
const { listPosts, getPostBySlug } = require('../db/blog');

const router = express.Router();

const SITE_URL = process.env.APP_URL || 'https://tunevault.app';

// ─── Static blog posts ──────────────────────────────────────────────────────
// Long-form runbooks authored as standalone dark-themed HTML files in
// public/blog/ (rather than markdown in the blog_posts table). Each entry is
// served directly by slug and injected into the /api/blog/posts index so it
// shows up as a card on /blog alongside the DB-backed posts.
const STATIC_POSTS = [
  {
    slug: 'ebs-12-2-rac-clone-rman-duplicate',
    file: 'ebs-12-2-rac-clone-rman-duplicate.html',
    title: 'Oracle EBS 12.2 + 19c RAC Clone Using RMAN Duplicate — Complete Runbook',
    excerpt: 'A production-tested, end-to-end runbook to clone an Oracle E-Business Suite 12.2 environment backed by a 2-node Oracle 19c RAC database using RMAN Active Database Duplicate — ASM, CDB/PDB, single-instance-to-RAC conversion, EBS Rapid Clone, post-clone tasks, and adding a new RAC node.',
    author: 'Kiran Kumar Ale',
    published_at: '2026-07-01T00:00:00.000Z',
    read_time_minutes: 18,
    coming_soon: false,
    tags: ['oracle-rac', 'ebs-clone', 'rman', 'oracle-19c'],
  },
];

// ─── Helpers ──────────────────────────────────────────────────────────────

function formatDate(date) {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric', month: 'long', day: 'numeric'
  });
}

function escapeHtml(str) {
  if (!str) return '';
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

/** Convert markdown-ish content to HTML (headings, code blocks, paragraphs, bold, lists) */
function markdownToHtml(md) {
  if (!md) return '';
  let html = md;

  // Fenced code blocks (```lang ... ```) — stash to placeholders BEFORE any other
  // transform so later steps (esp. the blank-line paragraph split) can never break
  // a code block that contains an internal blank line.
  const codeStash = [];
  html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
    codeStash.push(`<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.trim())}</code></pre>`);
    return '\n\n@@TVCODE' + (codeStash.length - 1) + 'ENDCODE@@\n\n';
  });

  // Headings
  html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
  html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
  html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');

  // Bold
  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');

  // Italic
  html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');

  // Inline code
  html = html.replace(/`([^`]+)`/g, '<code>$1</code>');

  // Links [text](url)
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');

  // Horizontal rule
  html = html.replace(/^---+$/gm, '<hr>');

  // Unordered lists (lines starting with - or *)
  html = html.replace(/((?:^[*-] .+\n?)+)/gm, (block) => {
    const items = block.trim().split('\n').map(l => `<li>${l.replace(/^[*-] /, '')}</li>`).join('');
    return `<ul>${items}</ul>`;
  });

  // Ordered lists
  html = html.replace(/((?:^\d+\. .+\n?)+)/gm, (block) => {
    const items = block.trim().split('\n').map(l => `<li>${l.replace(/^\d+\. /, '')}</li>`).join('');
    return `<ol>${items}</ol>`;
  });

  // Paragraphs — blank-line-separated blocks not already wrapped in a tag
  const blocks = html.split(/\n\n+/);
  html = blocks.map(block => {
    block = block.trim();
    if (!block) return '';
    if (/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(block)) return block;
    // A standalone code placeholder is a block on its own — don't wrap it in <p>.
    if (/^@@TVCODE\d+ENDCODE@@$/.test(block)) return block;
    // Replace single newlines within paragraph with <br>
    return `<p>${block.replace(/\n/g, '<br>')}</p>`;
  }).join('\n');

  // Restore stashed fenced code blocks.
  html = html.replace(/@@TVCODE(\d+)ENDCODE@@/g, (_, i) => codeStash[+i] || '');

  return html;
}

// ─── Blog index — serves static curated articles page ─────────────────────

// ─── Blog index, SERVER-RENDERED ──────────────────────────────────────────
// The listing used to ship as a static shell that fetched /api/blog/posts and
// built the cards in the browser. Every article page is server-rendered, so the
// ARTICLES indexed fine — but the index itself was an empty "Loading articles…"
// placeholder to anything that does not run JavaScript: search crawlers,
// LinkedIn/Slack/WhatsApp link previews, and the AI crawlers named in robots.txt.
// Same class of problem as the 2026-08-09 blog analytics bug, where a page that
// looked fine in a browser was invisible to everything else.
//
// The cards are now rendered here and injected into the placeholder, and the same
// post list is embedded as JSON so the client keeps filtering and searching with
// no second round-trip. Client rendering is unchanged and still works if the
// injection ever fails — it just falls back to fetching.

function escHtml(str) {
  return String(str == null ? '' : str)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// Month + year, computed in UTC so the server never disagrees with itself across
// timezones. The client re-renders in its own locale on load.
function fmtDateUTC(iso) {
  const d = new Date(iso);
  if (isNaN(d)) return '';
  return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
}

// Mirrors buildCard() in public/blog.html. If one changes, change both — they are
// deliberately identical so the server HTML and the client re-render match.
function renderCard(p) {
  const cs = !!p.coming_soon;
  const top = (cs ? '<span class="badge-coming-soon">Coming Soon</span>' : '')
    + '<span class="card-date">' + escHtml(fmtDateUTC(p.published_at)) + '</span>';
  let body = '<div class="card-top">' + top + '</div>'
    + '<div class="card-title">' + escHtml(p.title) + '</div>'
    + '<div class="card-summary">' + escHtml(p.excerpt) + '</div>';
  if (!cs) {
    body += '<div style="display:flex;align-items:center;gap:10px;padding-top:4px;">'
      + '<span class="read-time">' + (p.read_time_minutes || 10) + ' min read</span>'
      + '<a class="card-link" href="/blog/' + encodeURIComponent(p.slug) + '">Read article'
      + '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 9.5L9.5 2.5M9.5 2.5H5M9.5 2.5V7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
      + '</a></div>';
  }
  return '<article class="card' + (cs ? ' card-stub' : '') + '"'
    + ' data-cs="' + (cs ? 'true' : 'false') + '"'
    + ' data-title="' + escHtml((p.title + ' ' + (p.excerpt || '')).toLowerCase()) + '">'
    + body + '</article>';
}

router.get('/blog', async (req, res) => {
  const file = path.join(__dirname, '../public/blog.html');
  try {
    const [html, dbPosts] = await Promise.all([
      fs.promises.readFile(file, 'utf8'),
      listPosts(),
    ]);
    const posts = [...STATIC_POSTS, ...dbPosts]
      .sort((a, b) => new Date(b.published_at) - new Date(a.published_at));

    const full = posts.filter(p => !p.coming_soon).length;
    const cards = posts.map(renderCard).join('\n');

    const out = html
      .replace('<div class="empty-state">Loading articles…</div>', cards)
      .replace('<div class="results-line" id="results-line">Loading…</div>',
        '<div class="results-line" id="results-line">Showing ' + posts.length
        + ' article' + (posts.length === 1 ? '' : 's') + '</div>')
      // Embedded so the client filters without re-fetching. JSON.stringify output
      // is escaped for </script> — a title containing that would end the block early.
      .replace('</head>', '<script>window.__BLOG_POSTS__ = '
        + JSON.stringify(posts.map(p => ({
            slug: p.slug, title: p.title, excerpt: p.excerpt,
            published_at: p.published_at, read_time_minutes: p.read_time_minutes,
            coming_soon: !!p.coming_soon,
          }))).replace(/</g, '\\u003c')
        + ';</script>\n</head>');

    res.set('Cache-Control', 'public, max-age=300');
    res.send(out);
    void full;
  } catch (err) {
    // Never let a database hiccup take the blog index down — fall back to the
    // static shell, which still renders client-side.
    console.error('[blog] index SSR failed, serving static shell:', err.message);
    res.sendFile(file);
  }
});

// ─── Static blog articles (standalone HTML in public/blog/) ────────────────
// Registered before /blog/:slug so the dedicated file wins over the DB lookup.
STATIC_POSTS.forEach((post) => {
  router.get(`/blog/${post.slug}`, (req, res) => {
    res.sendFile(path.join(__dirname, '../public/blog', post.file));
  });
});

// ─── Blog article ─────────────────────────────────────────────────────────

router.get('/blog/:slug', async (req, res) => {
  try {
    const post = await getPostBySlug(req.params.slug);
    if (!post) {
      return res.status(404).send('Post not found');
    }

    // NO server-side page_view here — the page loads /tv-tracker.js instead (see
    // the end of the template). Server-side tracking read the session from
    // `req.cookies.tv_sid`, a cookie only tv-tracker.js ever sets, so every
    // arrival from search — human or crawler — recorded session_id = NULL.
    // COUNT(DISTINCT session_id) skips NULLs, so /admin/funnel showed 168 blog
    // views against 1 visitor (15 of 16 posts at exactly 0). It also attached no
    // utm_* properties, so no blog visit was ever attributable and every source
    // fell into "(direct)". The client tracker fixes both: it mints a session id,
    // sets the cookie, and carries UTM capture. Crawlers that don't run JS drop
    // out of the view count — correct for a visitor metric, and the reason blog
    // views fell sharply on 2026-08-09.

    const bodyHtml = markdownToHtml(post.content);
    const publishedIso = new Date(post.published_at).toISOString();
    const canonicalUrl = `${SITE_URL}/blog/${encodeURIComponent(post.slug)}`;

    res.send(`<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${escapeHtml(post.title)} — TuneVault</title>
  <meta name="description" content="${escapeHtml(post.excerpt)}">
  <meta name="keywords" content="Oracle DBA, Oracle EBS, ORA errors, Oracle health check, database performance">
  <link rel="canonical" href="${canonicalUrl}">
  <meta property="og:title" content="${escapeHtml(post.title)}">
  <meta property="og:description" content="${escapeHtml(post.excerpt)}">
  <meta property="og:type" content="article">
  <meta property="og:url" content="${canonicalUrl}">
  <meta property="og:image" content="${SITE_URL}/og/blog.png">
  <meta property="og:site_name" content="TuneVault">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="${escapeHtml(post.title)}">
  <meta name="twitter:description" content="${escapeHtml(post.excerpt)}">
  <meta name="twitter:image" content="${SITE_URL}/og/blog.png">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="/favicon.svg">
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
      --bg: #0a0a0a;
      --surface: #111;
      --border: #1e1e1e;
      --text: #e8e8e8;
      --text-dim: #666;
      --accent: #f0a830;
      --code-bg: #161616;
    }
    body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.7; min-height: 100vh; }
    nav { display: flex; align-items: center; justify-content: space-between; padding: 0 40px; height: 60px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
    .logo { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; color: var(--text); text-decoration: none; }
    .logo-icon { width: 28px; height: 28px; background: var(--accent); border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; color: #000; }
    .nav-right { display: flex; align-items: center; gap: 20px; }
    .nav-cta { background: var(--accent); color: #000; font-weight: 600; font-size: 13px; padding: 7px 16px; border-radius: 6px; text-decoration: none; transition: opacity 0.2s; }
    .nav-cta:hover { opacity: 0.85; }
    main { max-width: 720px; margin: 0 auto; padding: 60px 24px 80px; }
    .post-header { margin-bottom: 48px; }
    .post-meta { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 16px; }
    .post-header h1 { font-size: 2.2rem; font-weight: 700; line-height: 1.3; margin-bottom: 20px; }
    .post-excerpt-text { font-size: 1.1rem; color: var(--text-dim); line-height: 1.7; padding-bottom: 32px; border-bottom: 1px solid var(--border); }
    /* Article body */
    .article-body h2 { font-size: 1.5rem; font-weight: 700; margin: 48px 0 16px; color: var(--text); }
    .article-body h3 { font-size: 1.15rem; font-weight: 700; margin: 32px 0 12px; color: var(--accent); }
    .article-body p { margin-bottom: 16px; color: var(--text); }
    .article-body strong { color: var(--text); }
    .article-body a { color: var(--accent); }
    .article-body ul, .article-body ol { margin: 16px 0 16px 24px; }
    .article-body li { margin-bottom: 6px; }
    .article-body pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; overflow-x: auto; margin: 24px 0; }
    .article-body code { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 13px; color: #b8d4ff; }
    .article-body pre code { color: #b8d4ff; }
    .article-body p code { background: var(--code-bg); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; color: var(--accent); }
    .article-body hr { border: none; border-top: 1px solid var(--border); margin: 40px 0; }
    .cta-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; margin-top: 60px; text-align: center; }
    .cta-box h3 { font-size: 1.2rem; margin-bottom: 10px; }
    .cta-box p { color: var(--text-dim); font-size: 0.95rem; margin-bottom: 24px; }
    .cta-btn { display: inline-block; background: var(--accent); color: #000; font-weight: 700; font-size: 15px; padding: 12px 28px; border-radius: 8px; text-decoration: none; transition: opacity 0.2s; }
    .cta-btn:hover { opacity: 0.85; }
    .cta-btn-secondary { display: inline-block; background: transparent; color: var(--accent); border: 1px solid var(--accent); font-weight: 700; font-size: 15px; padding: 12px 28px; border-radius: 8px; text-decoration: none; transition: opacity 0.2s; }
    .cta-btn-secondary:hover { opacity: 0.85; }
    .blog-cta-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; margin-top: 60px; }
    .blog-cta-box h3 { font-size: 1.2rem; margin-bottom: 10px; }
    .blog-cta-box p { color: var(--text-dim); font-size: 0.95rem; line-height: 1.6; margin-bottom: 0; }
    .back-link { font-size: 13px; color: var(--text-dim); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 40px; }
    .back-link:hover { color: var(--accent); }
    .author-aside { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; margin-top: 48px; display: flex; align-items: flex-start; gap: 20px; }
    .author-aside-avatar { flex-shrink: 0; width: 52px; height: 52px; border-radius: 50%; background: var(--accent); color: #000; font-weight: 800; font-size: 15px; display: flex; align-items: center; justify-content: center; letter-spacing: 0.02em; }
    .author-aside-body { flex: 1; min-width: 0; }
    .author-aside-name { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
    .author-aside-bio { font-size: 13px; color: var(--text-dim); line-height: 1.6; margin-bottom: 12px; }
    .author-aside-links { display: flex; gap: 16px; flex-wrap: wrap; }
    .author-aside-link { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); text-decoration: none; transition: color 0.2s; }
    .author-aside-link:hover { color: var(--accent); }
    footer { border-top: 1px solid var(--border); padding: 24px 40px; display: flex; justify-content: space-between; align-items: center; color: var(--text-dim); font-size: 13px; }
    footer a { color: inherit; text-decoration: none; }
    footer a:hover { color: var(--accent); }
    @media (max-width: 600px) { nav { padding: 0 16px; } main { padding: 40px 16px 60px; } .post-header h1 { font-size: 1.6rem; } footer { padding: 20px 16px; flex-direction: column; gap: 8px; text-align: center; } .author-aside { flex-direction: column; gap: 14px; } }
  </style>
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": "${escapeHtml(post.title).replace(/"/g, '\\"')}",
    "description": "${escapeHtml(post.excerpt).replace(/"/g, '\\"')}",
    "url": "${canonicalUrl}",
    "datePublished": "${publishedIso}",
    "dateModified": "${publishedIso}",
    "author": { "@type": "Person", "name": "Kiran Kumar Ale", "url": "https://www.linkedin.com/in/kirankumarale/", "sameAs": ["https://www.linkedin.com/in/kirankumarale/", "https://linuxappsdba.blogspot.com"] },
    "publisher": { "@type": "Organization", "name": "TuneVault", "url": "${SITE_URL}" },
    "mainEntityOfPage": { "@type": "WebPage", "@id": "${canonicalUrl}" }
  }
  </script>
</head>
<body>
<nav>
  <a href="/" class="logo"><div class="logo-icon">TV</div>TuneVault</a>
  <div class="nav-right">
    <a href="/blog" style="font-size:13px;color:var(--text-dim);text-decoration:none;">Blog</a>
    <a href="/pricing" style="font-size:13px;color:var(--text-dim);text-decoration:none;">Pricing</a>
    <a href="/signin" style="font-size:13px;color:var(--text-dim);text-decoration:none;">Sign in</a>
    <a href="/signup?path=health-check&redirect=/dashboard" class="nav-cta">Get Started</a>
  </div>
</nav>
<main>
  <a href="/blog" class="back-link">&larr; All articles</a>
  <header class="post-header">
    <div class="post-meta">${formatDate(post.published_at)} &middot; ${post.read_time_minutes} min read &middot; ${escapeHtml(post.author)}</div>
    <h1>${escapeHtml(post.title)}</h1>
    <p class="post-excerpt-text">${escapeHtml(post.excerpt)}</p>
  </header>
  <div class="article-body">
    ${bodyHtml}
  </div>
  <div class="blog-cta-box">
    <h3>Running Oracle EBS 12.2? TuneVault catches these issues automatically.</h3>
    <p>Self-hosted — runs inside your own network, no data leaves your datacenter. 200+ Oracle DB + EBS health checks in under 2 minutes. Installs on Oracle Linux 8 in 15 minutes.</p>
    <div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:16px;">
      <a href="/download" class="cta-btn">Download Self-Hosted Free →</a>
      <a href="/sample-report" class="cta-btn-secondary">View Live Demo →</a>
    </div>
  </div>
  <aside class="author-aside" aria-label="About the author">
    <div class="author-aside-avatar">KKA</div>
    <div class="author-aside-body">
      <div class="author-aside-name">Kiran Kumar Ale</div>
      <p class="author-aside-bio">20+ years Oracle EBS/DBA/Cloud. 23 Oracle &amp; PM certifications across DBA, EBS, Cloud Infrastructure, and Security tracks. Built TuneVault to encode two decades of production Oracle expertise into an automated health check tool.</p>
      <div class="author-aside-links">
        <a href="https://www.linkedin.com/in/kirankumarale/" target="_blank" rel="author external noopener" class="author-aside-link">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
          linkedin.com/in/kirankumarale
        </a>
        <a href="mailto:hello@tunevault.app" class="author-aside-link">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
          hello@tunevault.app
        </a>
        <a href="https://linuxappsdba.blogspot.com" target="_blank" rel="author external noopener" class="author-aside-link">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
          linuxappsdba.blogspot.com
        </a>
      </div>
    </div>
  </aside>
</main>
<footer>
  <span>TuneVault &copy; 2026</span>
  <span style="display:flex;gap:20px;">
    <a href="/blog">Blog</a>
    <a href="/docs/oracle-setup">Setup Guide</a>
    <a href="/pricing">Pricing</a>
  </span>
</footer>
<script src="/tv-tracker.js"></script>
</body>
</html>`);
  } catch (err) {
    console.error('[blog] article error:', err.message);
    res.status(500).send('Internal server error');
  }
});

// ─── JSON API (for sitemap generation) ───────────────────────────────────

router.get('/api/blog/posts', async (req, res) => {
  try {
    const dbPosts = await listPosts();
    // Merge in static file-backed posts and sort newest-first by published_at.
    const posts = [...STATIC_POSTS, ...dbPosts].sort(
      (a, b) => new Date(b.published_at) - new Date(a.published_at)
    );
    res.json({ posts });
  } catch (err) {
    console.error('[blog] api error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// markdownToHtml is exported so routes/changelog.js renders release notes with the
// same renderer the blog uses. A second private copy is how two pages drift into
// rendering the same markdown differently — the lesson from three copies of
// htmlToMarkdown (now lib/html-to-markdown.js).
module.exports = router;
module.exports.markdownToHtml = markdownToHtml;
