114 lines
3.3 KiB
JavaScript
114 lines
3.3 KiB
JavaScript
|
|
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() }));
|
||
|
|
}
|