481 lines
15 KiB
TypeScript
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;
|
|
}
|
|
}
|