initial commit
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
/* ============================================================================
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user