/** * Ghost Content API client. Runs at build time under Node only — the API key is * read from the environment here and never reaches the browser bundle. * * Direct fetch is used rather than @tryghost/content-api: this site needs one * read-only endpoint, and the SDK would add a dependency for a single GET. */ import fs from "node:fs"; import path from "node:path"; import type { GhostConfig, GhostPost, GhostPostsResponse } from "./types"; /** Only posts carrying this tag are published to this site. */ export const SITE_TAG = "lynkeduppro-new"; const CACHE_DIR = path.resolve("node_modules/.cache/ghost"); const CACHE_FILE = path.join(CACHE_DIR, `posts-${SITE_TAG}.json`); /** * Reads Ghost credentials from the environment. * @throws if either required variable is missing. */ export function readGhostConfig(env: Record): GhostConfig { const apiUrl = env.GHOST_API_URL?.trim(); const contentApiKey = env.GHOST_CONTENT_API_KEY?.trim(); const missing = [ !apiUrl && "GHOST_API_URL", !contentApiKey && "GHOST_CONTENT_API_KEY", ].filter(Boolean); if (missing.length) { throw new Error( `Missing Ghost environment variable(s): ${missing.join(", ")}. ` + `Copy .env.example to .env and fill them in (see README of .env.example).` ); } return { apiUrl: apiUrl!.replace(/\/+$/, ""), contentApiKey: contentApiKey!, siteUrl: (env.SITE_URL?.trim() || "https://lynkeduppro.com").replace(/\/+$/, ""), }; } function readCache(): GhostPost[] | null { try { return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")) as GhostPost[]; } catch { return null; } } function writeCache(posts: GhostPost[]): void { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_FILE, JSON.stringify(posts), "utf8"); } catch { /* Cache is an optimisation; a failure to persist must not fail the build. */ } } /** * Fetches every published post tagged {@link SITE_TAG}, newest first. * * Always hits Ghost — one request per dev-server start or build is necessary, not * wasteful, and serving a TTL-cached copy here would silently hide freshly * published posts. The on-disk copy is only a fallback for when Ghost is * unreachable, so a dropped connection doesn't stop local work. * * @param allowStaleFallback Fall back to the last good response if the fetch * fails. Dev only: a production build must fail loudly rather than publish * stale content. */ export async function fetchSitePosts( config: GhostConfig, { allowStaleFallback = false }: { allowStaleFallback?: boolean } = {} ): Promise { const url = new URL(`${config.apiUrl}/ghost/api/content/posts/`); url.searchParams.set("key", config.contentApiKey); url.searchParams.set("filter", `tag:${SITE_TAG}`); url.searchParams.set("include", "tags,authors"); url.searchParams.set("limit", "all"); url.searchParams.set("order", "published_at DESC"); try { const res = await fetch(url, { headers: { "Accept-Version": "v5.0" } }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error( `Ghost API responded ${res.status} ${res.statusText} for ${config.apiUrl}. ${body.slice(0, 300)}` ); } const data = (await res.json()) as GhostPostsResponse; const posts = data.posts ?? []; writeCache(posts); return posts; } catch (error) { if (!allowStaleFallback) throw error; const cached = readCache(); if (!cached) throw error; console.warn( `[ghost] fetch failed (${error instanceof Error ? error.message : String(error)}).\n` + `[ghost] Falling back to the last cached response (${cached.length} post(s)) — may be out of date.` ); return cached; } }