132 lines
4.2 KiB
TypeScript
132 lines
4.2 KiB
TypeScript
/* ============================================================================
|
|
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<T>(
|
|
resource: string,
|
|
query?: StrapiQuery,
|
|
): Promise<T | null> {
|
|
try {
|
|
return await getSingle<T>(resource, query);
|
|
} catch (error) {
|
|
warn(resource, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** A collection page, or `[]` when the CMS has none / is unreachable. */
|
|
export async function loadMany<T>(
|
|
resource: string,
|
|
query?: StrapiQuery,
|
|
): Promise<T[]> {
|
|
try {
|
|
const { data } = await getMany<T>(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<T>(value: readonly T[] | null | undefined): T[] {
|
|
return value ? [...value] : [];
|
|
}
|