Files
sharper.ly/apps/web/app/routes/_page-kit/cms.server.ts
T

132 lines
4.2 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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] : [];
}