Files
sharper.ly/apps/web/app/routes/_shared/cms.ts
T
2026-09-11 02:46:56 +02:00

481 lines
15 KiB
TypeScript

/* ============================================================================
CMS CONTENT MODEL - Knowledge and Impact + News
----------------------------------------------------------------------------
SERVER ONLY. Everything here reaches ~/lib/strapi, which imports
~/lib/env.server, so these helpers may only be called from a `loader`.
The three collections below are what the Strapi agent has to create; the
API ids and field names are the contract. Nothing in this file invents
content: when a collection does not exist yet, or Strapi is unreachable, a
listing degrades to `available: false` and the route renders its localised
empty state instead of a 500.
resources Reports, research and official documents (§6 of the draft)
knowledge-items Training modules, guides, case studies, FAQs (§7)
news-articles SHARP-ER news and network updates (§5)
plus the two taxonomies that supply filter options:
topics partners
FIELD NAMES ARE THE ONES IN apps/cms/src/api/<type>/content-types/<type>/
schema.json - not a guess. `resource.authors` in particular is a plain
STRING field, not a relation, so there is no /api/authors to read.
========================================================================= */
import type { CmsLocalizable } from "./alternates";
import {
findFirst,
getBySlug,
getMany,
isStrapiError,
mediaAlt,
mediaFormatUrl,
mediaUrl,
type StrapiEntity,
type StrapiMedia,
type StrapiPagination,
type StrapiQuery,
} from "~/lib/strapi";
/* ---------------------------------------------------------------------------
RESOURCE NAMES
------------------------------------------------------------------------ */
export const CMS = {
resources: "resources",
knowledgeItems: "knowledge-items",
newsArticles: "news-articles",
topics: "topics",
partners: "partners",
} as const;
/* ---------------------------------------------------------------------------
SHARED SHAPES
------------------------------------------------------------------------ */
/** A taxonomy entry. Only `name` and `slug` are ever read. */
export interface CmsTaxonomy extends StrapiEntity {
readonly name: string;
readonly slug: string;
}
/** One option of a filter `<select>`. */
export interface FilterOption {
readonly value: string;
readonly label: string;
}
/**
* A Strapi blocks-rich-text value, or a plain markdown string when the field
* was modelled as `text`. Both are rendered safely - see _shared/RichText.tsx.
*/
export type CmsRichText = string | readonly CmsBlockNode[] | null;
export type CmsBlockNode =
| { readonly type: "paragraph"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "heading";
readonly level?: number;
readonly children?: readonly CmsInlineNode[];
}
| {
readonly type: "list";
readonly format?: "ordered" | "unordered";
readonly children?: readonly CmsBlockNode[];
}
| { readonly type: "list-item"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "quote"; readonly children?: readonly CmsInlineNode[] }
| { readonly type: "code"; readonly children?: readonly CmsInlineNode[] }
| {
readonly type: "image";
readonly image?: {
readonly url?: string;
readonly alternativeText?: string | null;
readonly width?: number | null;
readonly height?: number | null;
} | null;
};
/**
* One inline node: a run of text, or a link wrapping more inline nodes.
* Modelled as a single open shape rather than a discriminated union because
* this is untrusted CMS JSON - `type` may be absent on a text run, which is
* exactly the case a union cannot narrow, and every field has to be treated
* as possibly missing anyway.
*/
export interface CmsInlineNode {
readonly type?: "text" | "link";
readonly text?: string;
/** Present on a link node. */
readonly url?: string;
/** Present on a link node. */
readonly children?: readonly CmsInlineNode[];
readonly bold?: boolean;
readonly italic?: boolean;
readonly underline?: boolean;
readonly strikethrough?: boolean;
readonly code?: boolean;
}
/* ---------------------------------------------------------------------------
COLLECTIONS
------------------------------------------------------------------------ */
/** §6 Resources and Publications - `resource.resourceType`. */
export type ResourceType =
| "research-perception"
| "policy-article"
| "project-report"
| "visibility-factsheet";
export const RESOURCE_TYPES = [
"research-perception",
"policy-article",
"project-report",
"visibility-factsheet",
] as const satisfies readonly ResourceType[];
/** `resource.resourceLanguage`. */
export type ResourceLanguage = "en" | "ar" | "bilingual";
export interface ResourceEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
/** The card excerpt. `description` in the CMS, not `summary`. */
readonly description?: string | null;
readonly resourceType?: ResourceType | null;
/** Publication date of the document itself, not of the CMS entry. */
readonly publicationDate?: string | null;
/**
* A free-text credit line ("Ahmed Ali, Fatima Saleh"), NOT a relation:
* there is no author content type, so the author filter is built from the
* distinct values this field actually holds - see `fetchDistinctValues`.
*/
readonly authors?: string | null;
readonly partnerOrganisations?: readonly CmsTaxonomy[] | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
readonly file?: StrapiMedia | null;
/** Used when the document is hosted elsewhere instead of uploaded. */
readonly externalUrl?: string | null;
readonly fileSizeLabel?: string | null;
readonly pageCount?: number | null;
readonly resourceLanguage?: ResourceLanguage | null;
}
/** §7 Knowledge Hub - `knowledge-item.itemType`. */
export type KnowledgeItemType =
| "training-module"
| "toolkit"
| "law-session-guide"
| "case-study"
| "faq";
export const KNOWLEDGE_TYPES = [
"training-module",
"toolkit",
"law-session-guide",
"case-study",
"faq",
] as const satisfies readonly KnowledgeItemType[];
export interface KnowledgeEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly summary?: string | null;
readonly body?: CmsRichText;
readonly itemType?: KnowledgeItemType | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly thumbnail?: StrapiMedia | null;
/**
* Multiple media. There is NO `externalUrl` on this type: a knowledge item
* is either downloadable or it is read on the page.
*/
readonly attachments?: readonly StrapiMedia[] | null;
readonly publicationDate?: string | null;
readonly keywords?: unknown;
}
/** §5 News and Updates. `category` drives the two tabs of the listing. */
export type NewsCategory = "sharper-news" | "from-the-network";
export const NEWS_CATEGORIES = [
"sharper-news",
"from-the-network",
] as const satisfies readonly NewsCategory[];
export interface NewsEntry extends StrapiEntity, CmsLocalizable {
readonly title: string;
readonly slug: string;
readonly excerpt?: string | null;
readonly body?: CmsRichText;
readonly category?: NewsCategory | null;
/**
* Credit line for a "From the Network" update. A RELATION to a partner, so
* it has to be populated - an unpopulated relation comes back as undefined
* and the card silently loses its byline.
*/
readonly authorOrganisation?: CmsTaxonomy | null;
readonly topics?: readonly CmsTaxonomy[] | null;
readonly regions?: readonly CmsTaxonomy[] | null;
readonly coverImage?: StrapiMedia | null;
readonly publishDate?: string | null;
readonly featured?: boolean | null;
}
/* ---------------------------------------------------------------------------
MEDIA -> PLAIN LOADER DATA
`mediaUrl` reads the environment, so media is flattened in the loader and
the component never sees a StrapiMedia.
------------------------------------------------------------------------ */
export interface CmsImage {
readonly src: string;
readonly alt: string;
readonly width?: number;
readonly height?: number;
}
export interface CmsFile {
readonly url: string;
readonly name: string;
/** "PDF", "DOCX", ... - upper case, without the dot. */
readonly kind: string;
readonly mime: string;
/** Bytes. Strapi stores `size` in kilobytes, so it is converted here. */
readonly sizeBytes: number;
}
export function toImage(
media: StrapiMedia | null | undefined,
format: "thumbnail" | "small" | "medium" | "large" = "medium",
): CmsImage | undefined {
const src = mediaFormatUrl(media, format);
if (!src || !media) return undefined;
const chosen = media.formats?.[format];
const width = chosen?.width ?? media.width ?? undefined;
const height = chosen?.height ?? media.height ?? undefined;
return {
src,
alt: mediaAlt(media),
...(width ? { width } : {}),
...(height ? { height } : {}),
};
}
export function toFile(media: StrapiMedia | null | undefined): CmsFile | undefined {
const url = mediaUrl(media?.url);
if (!url || !media) return undefined;
const extension = (media.ext ?? "").replace(/^\./, "").toUpperCase();
const fallback = media.mime.split("/").pop()?.toUpperCase() ?? "FILE";
return {
url,
name: media.name,
kind: extension || fallback,
mime: media.mime,
// Strapi's upload plugin records `size` in KB (a float), not in bytes.
sizeBytes: Math.round(Math.max(0, media.size) * 1024),
};
}
/** Names of a taxonomy list, in CMS order. */
export function taxonomyNames(
entries: readonly CmsTaxonomy[] | null | undefined,
): string[] {
return (entries ?? [])
.map((entry) => entry?.name)
.filter((name): name is string => typeof name === "string" && name.length > 0);
}
/* ---------------------------------------------------------------------------
FAULT-TOLERANT FETCHING
The CMS is empty today and its collections may not exist at all, so a
listing must degrade rather than throw. A DETAIL page does the opposite:
a missing entry is a genuine 404 and a transport failure is a real error.
------------------------------------------------------------------------ */
export interface CollectionResult<T> {
readonly entries: readonly T[];
readonly pagination: StrapiPagination;
/** False when the collection could not be read at all. */
readonly available: boolean;
}
function emptyPagination(page: number, pageSize: number): StrapiPagination {
return { page, pageSize, pageCount: 0, total: 0 };
}
function warn(resource: string, error: unknown): void {
const reason = isStrapiError(error)
? `${error.status || "no response"} ${error.message}`
: String(error);
console.warn(`[cms] "${resource}" is unavailable - rendering the empty state (${reason})`);
}
/**
* A paginated collection, or an empty one when Strapi cannot answer.
* Only `StrapiError` is swallowed: a bug in this module still throws.
*/
export async function fetchCollection<T>(
resource: string,
query: StrapiQuery,
): Promise<CollectionResult<T>> {
const page = query.pagination?.page ?? 1;
const pageSize = query.pagination?.pageSize ?? 25;
try {
const result = await getMany<T>(resource, query);
return {
entries: result.data,
pagination: result.pagination ?? {
page,
pageSize,
pageCount: result.data.length > 0 ? 1 : 0,
total: result.data.length,
},
available: true,
};
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return { entries: [], pagination: emptyPagination(page, pageSize), available: false };
}
}
/**
* Filter options from a taxonomy collection. Returns [] - never throws - so a
* taxonomy that has not been created yet simply renders as "All".
*/
export async function fetchFilterOptions(
resource: string,
locale: string,
): Promise<FilterOption[]> {
try {
const result = await getMany<CmsTaxonomy>(resource, {
locale,
fields: ["name", "slug"],
sort: "name:asc",
pagination: { pageSize: 100 },
});
return result.data
.filter((entry) => Boolean(entry?.slug && entry?.name))
.map((entry) => ({ value: entry.slug, label: entry.name }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The distinct values a plain STRING field actually holds, as filter options.
*
* `resource.authors` is free text, not a taxonomy, so there is no /api/authors
* to read - asking for one is a 404. The options are derived from the entries
* themselves instead, which also means the filter never offers a name that
* has nothing behind it, and offers nothing at all while the CMS is empty.
*/
export async function fetchDistinctValues(
resource: string,
locale: string,
field: string,
limit = 200,
): Promise<FilterOption[]> {
try {
const result = await getMany<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
pagination: { page: 1, pageSize: limit },
});
const seen = new Map<string, string>();
for (const entry of result.data) {
const raw = entry?.[field];
if (typeof raw !== "string") continue;
const value = raw.trim();
if (value.length === 0 || value.length > 200) continue;
if (!seen.has(value)) seen.set(value, value);
}
return [...seen.values()]
.sort((a, b) => a.localeCompare(b, locale))
.map((value) => ({ value, label: value }));
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
/**
* The calendar years actually covered by a collection, newest first. Derived
* from the oldest and newest entry rather than hard-coded, so the "Date"
* filter never offers a year that has nothing behind it - and offers nothing
* at all while the CMS is empty.
*/
export async function fetchYearOptions(
resource: string,
locale: string,
field: string,
): Promise<number[]> {
try {
const [newest, oldest] = await Promise.all([
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:desc`,
}),
findFirst<Record<string, unknown>>(resource, {
locale,
fields: [field],
sort: `${field}:asc`,
}),
]);
const to = yearOf(newest?.[field]);
const from = yearOf(oldest?.[field]);
if (to === undefined || from === undefined) return [];
const years: number[] = [];
for (let year = Math.max(from, to); year >= Math.min(from, to); year -= 1) {
years.push(year);
}
return years;
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return [];
}
}
function yearOf(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.getUTCFullYear();
}
/**
* One entry by slug, or null. A 404 from Strapi (including "this collection
* does not exist yet") resolves to null so the route can throw its own 404;
* every other failure propagates, because a CMS outage is not a missing page.
*/
export async function fetchBySlug<T>(
resource: string,
slug: string,
query: StrapiQuery,
): Promise<T | null> {
try {
return await getBySlug<T>(resource, slug, query);
} catch (error) {
if (isStrapiError(error) && error.status === 404) return null;
throw error;
}
}