111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
/**
|
|
* 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<string, string | undefined>): 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<GhostPost[]> {
|
|
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;
|
|
}
|
|
}
|