import { defineConfig, loadEnv, type Plugin } from "vite"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import path from "node:path"; import { buildGhostBlog } from "./src/ghost/build"; import { renderEmptyState } from "./src/ghost/render"; const root = path.dirname(fileURLToPath(import.meta.url)); /** Marker in blog/index.html where Ghost cards are injected. */ const CARDS_MARKER = ""; /** * Every blog//index.html — the hand-authored posts plus the pages just * generated from Ghost. Globbed rather than listed so new posts need no config * change. */ function blogPageInputs(): Record { const blogDir = path.resolve(root, "blog"); const inputs: Record = {}; for (const entry of fs.readdirSync(blogDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const file = path.join(blogDir, entry.name, "index.html"); if (fs.existsSync(file)) inputs[`blog-${entry.name}`] = file; } return inputs; } /** Injects the rendered Ghost cards into the blog listing grid. */ function ghostListingPlugin(cardsHtml: string): Plugin { return { name: "ghost-listing", transformIndexHtml: { order: "pre", handler(html, ctx) { if (!ctx.filename.replace(/\\/g, "/").endsWith("/blog/index.html")) return html; if (!html.includes(CARDS_MARKER)) { console.warn(`[ghost] ${CARDS_MARKER} not found in blog/index.html; cards not injected.`); return html; } // Empty when Ghost has no tagged posts, or (in dev) when it was // unreachable. The grid has no static cards behind it, so show the // empty state rather than a blank gap. return html.replace(CARDS_MARKER, cardsHtml || renderEmptyState()); }, }, }; } export default defineConfig(async ({ mode, command }) => { // Third arg "" loads unprefixed vars too: these are read here in Node at build // time and never inlined into the client bundle, so the Ghost key stays server-side. const env = { ...process.env, ...loadEnv(mode, root, "") }; let cardsHtml = ""; try { // Always fetch fresh, so a newly published post shows up on the next dev // restart or deploy. In dev only, fall back to the last cached response if // Ghost is unreachable. ({ cardsHtml } = await buildGhostBlog(env, { allowStaleFallback: command === "serve" })); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (command === "build") { // Fail the build rather than publish a blog with posts silently missing. // On Vercel a failed build leaves the previous deployment serving. throw new Error(`[ghost] blog generation failed: ${message}`); } // In dev, keep the site usable — the hand-authored posts still render. console.warn(`[ghost] ${message}\n[ghost] Continuing without Ghost posts (dev only).`); } return { base: "./", server: { port: 4190 }, plugins: [ghostListingPlugin(cardsHtml)], build: { rollupOptions: { input: { main: path.resolve(root, "index.html"), faq: path.resolve(root, "faq.html"), blog: path.resolve(root, "blog/index.html"), terms: path.resolve(root, "terms/index.html"), ...blogPageInputs(), }, output: { manualChunks: { three: ["three"], gsap: ["gsap"] }, }, }, }, }; });