/* ============================================================================ CMS ACCESS - server only ---------------------------------------------------------------------------- `.server.ts`, so the bundler keeps this (and the Strapi token behind it) out of the browser build entirely. Call it from a `loader` / `action`. Everything here is deliberately forgiving. The CMS is empty today - and in development it is often not running at all - so a page must render its approved draft copy rather than a 500 when Strapi answers 404, times out or refuses the connection. `loadSingle` / `loadMany` therefore turn any transport- or Strapi-level failure into `null` / `[]` and log it once, server-side. A genuinely missing *entity* on a $slug route is a different thing: use `requireEntity()` from ~/lib/strapi for that, so the root ErrorBoundary can render the localised 404. ========================================================================= */ import { getMany, getSingle, isStrapiError, mediaAlt, mediaFormatUrl, type StrapiMedia, type StrapiQuery, } from "~/lib/strapi"; import type { CmsPartner } from "./cms-types"; /** A picture, resolved to absolute URLs at load time. */ export interface ResolvedImage { readonly src: string; readonly alt: string; readonly width?: number; readonly height?: number; } /** A partner, flattened to exactly what the partner grid renders. */ export interface ResolvedPartner { readonly id: string; readonly name: string; readonly website?: string; readonly logo?: ResolvedImage; } function warn(resource: string, error: unknown): void { const reason = isStrapiError(error) ? `${error.status || "network"} ${error.message}` : String(error); console.warn(`[cms] "${resource}" is unavailable - rendering fallback copy (${reason})`); } /** * One entry of a single type, or `null` when the CMS has none / is unreachable. */ export async function loadSingle( resource: string, query?: StrapiQuery, ): Promise { try { return await getSingle(resource, query); } catch (error) { warn(resource, error); return null; } } /** A collection page, or `[]` when the CMS has none / is unreachable. */ export async function loadMany( resource: string, query?: StrapiQuery, ): Promise { try { const { data } = await getMany(resource, query); return data; } catch (error) { warn(resource, error); return []; } } /** * A Strapi media entity as a plain, absolute-URL image. * * `format` picks the responsive derivative Strapi generated; it silently falls * back to the original when that derivative does not exist (small uploads). * `alt` comes from the CMS and stays "" when the editor left it empty, which is * the correct markup for a decorative image. */ export function toImage( media: StrapiMedia | null | undefined, format: "thumbnail" | "small" | "medium" | "large" = "large", ): ResolvedImage | undefined { if (!media) return undefined; const src = mediaFormatUrl(media, format); if (!src) return undefined; return { src, alt: mediaAlt(media), ...(media.width ? { width: media.width } : {}), ...(media.height ? { height: media.height } : {}), }; } /** Flatten a populated partner relation for the grid. */ export function toPartner(partner: CmsPartner): ResolvedPartner { return { id: partner.documentId ?? String(partner.id), name: partner.name, ...(partner.website ? { website: partner.website } : {}), ...(toImage(partner.logo ?? partner.logoDark, "small") ? { logo: toImage(partner.logo ?? partner.logoDark, "small") } : {}), }; } /** * A Strapi `json` field authored as a list of strings ("Dialogue", * "Inclusion", ...). Anything else in the field is ignored rather than thrown * over, because an editor can put arbitrary JSON there. */ export function toStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string" && item.trim() !== ""); } /** Drop `null`/`undefined` and empty arrays in one expression. */ export function list(value: readonly T[] | null | undefined): T[] { return value ? [...value] : []; }