import mysql from "mysql2/promise"; import fallback from "../config/projects.json"; /** * Projects, read from cja_projects at BUILD time. * * The site is static, so this runs once during `npm run build` and every * project page ships as real HTML — crawlable, instant, no client-side fetch * and no loading state. The cost is that content changes need a rebuild, which * is exactly what the agent pipeline already does. * * If the database is unreachable the build does NOT fail: it falls back to * config/projects.json and logs loudly. A dead database should never be able * to take the whole site down with it. */ const env = import.meta.env; let cache = null; const parseJson = (value, whenEmpty) => { if (!value) return whenEmpty; if (typeof value === "object") return value; // mysql2 may pre-parse JSON columns try { return JSON.parse(value); } catch { return whenEmpty; } }; /** Shape a DB row into what the templates expect. */ const toProject = (row) => ({ slug: row.slug, title: row.title, kind: row.kind, period: row.period_label, status: row.status, summary: row.summary, lede: row.lede, body: row.body, role: row.role, categories: parseJson(row.categories, []), stack: parseJson(row.stack, []), skills: parseJson(row.skills, []), metrics: parseJson(row.metrics, []), gallery: parseJson(row.gallery, []), links: parseJson(row.links, {}), cover: row.cover_image || null, coverAlt: row.cover_alt || "", seoTitle: row.seo_title || null, seoDescription: row.seo_description || null, featured: Boolean(row.featured), }); export async function getProjects() { if (cache) return cache; const host = env.DB_HOST; const database = env.DB_NAME; const user = env.DB_USER; const password = env.DB_PASS; if (!host || !database || !user) { console.warn( "[projects] No database credentials in env — falling back to config/projects.json", ); cache = fallback; return cache; } let conn; try { conn = await mysql.createConnection({ host, user, password, database }); const [rows] = await conn.execute( `SELECT * FROM cja_projects WHERE published = 1 ORDER BY sort_order ASC, started_on DESC`, ); if (!rows.length) { console.warn("[projects] cja_projects returned no published rows — using fallback"); cache = fallback; } else { cache = rows.map(toProject); console.log(`[projects] loaded ${cache.length} from cja_projects`); } } catch (error) { // Loud, but not fatal. A build that silently ships stale content is worse // than one that tells you the database was unreachable. console.error(`[projects] DB read failed (${error.message}) — using fallback`); cache = fallback; } finally { await conn?.end(); } return cache; } export async function getProject(slug) { return (await getProjects()).find((p) => p.slug === slug) ?? null; } /** Category names with counts, derived from the projects themselves. */ export async function getCategories() { const projects = await getProjects(); const counts = {}; for (const p of projects) { for (const c of p.categories ?? []) counts[c] = (counts[c] ?? 0) + 1; } return Object.entries(counts) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .map(([name, count]) => ({ name, count, slug: name.toLowerCase() })); }