/* ============================================================================ RESOURCES AND PUBLICATIONS - one publication ---------------------------------------------------------------------------- /{locale}/resources-and-publications/{slug} Registered in app/routes.ts as "resourcesDetail", a SIBLING of the listing route under the ":lang" layout, and typed against its generated `./+types/` module. 404s A slug that does not exist is a 404, and so is a collection Strapi has not been given yet - `fetchBySlug` maps Strapi's own 404 to null and rethrows anything else, so an outage stays a 500 instead of being advertised to a crawler as "this publication does not exist". ========================================================================= */ import { useLoaderData } from "react-router"; import { Button } from "~/components/ui/Button"; import { formatDate, formatFileSize, sectionPath, toIsoDate, translate, useTranslation, type TranslationKey, } from "~/i18n"; import { requireEntity } from "~/lib/strapi"; import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import type { Route } from "./+types/resources-and-publications.$slug"; import { collectLocalizedSlugs, detailAlternateDescriptors, LOCALIZATION_POPULATE, SELF_ALTERNATES_HANDLE, } from "./_shared/alternates"; import { CMS, fetchBySlug, taxonomyNames, toFile, toImage, type ResourceEntry, type ResourceType, } from "./_shared/cms"; import { readRootSeo, requireLocale, withParam } from "./_shared/listing"; import { BackLink, MetaList, MetaLine, PageHeader, PageShell, TopicChips, type MetaItem, } from "./_shared/listing-ui"; import { DETAIL_COPY, RESOURCES_COPY } from "./_shared/page-copy"; const TYPE_LABEL: Readonly> = { "research-perception": "resources.categoryResearch", "policy-article": "resources.categoryPolicy", "project-report": "resources.categoryReports", "visibility-factsheet": "resources.categoryVisibility", }; /** * A Strapi 5 localisation carries its own slug, so this page emits its own * canonical + hreflang instead of letting the shell swap the locale prefix. */ export const handle = SELF_ALTERNATES_HANDLE; /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ export async function loader({ params }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const slug = params.slug ?? ""; const entry = requireEntity( await fetchBySlug(CMS.resources, slug, { locale, populate: { thumbnail: { fields: ["url", "alternativeText", "width", "height", "formats"], }, file: { fields: ["url", "name", "ext", "mime", "size"] }, partnerOrganisations: { fields: ["name", "slug"] }, topics: { fields: ["name", "slug"] }, localizations: LOCALIZATION_POPULATE, }, }), ); const file = toFile(entry.file); const date = entry.publicationDate ?? entry.publishedAt ?? undefined; const cover = toImage(entry.thumbnail, "large"); return { locale, slug: entry.slug, alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations), title: entry.title, summary: entry.description ?? undefined, typeLabel: entry.resourceType ? translate(locale, TYPE_LABEL[entry.resourceType]) : undefined, date, dateIso: toIsoDate(date), dateLabel: date ? formatDate(date, locale) : undefined, modifiedIso: toIsoDate(entry.updatedAt), /** Free text in the CMS, rendered as the editor wrote it. */ authors: entry.authors?.trim() || undefined, partners: taxonomyNames(entry.partnerOrganisations), topics: (entry.topics ?? []) .filter((topic) => Boolean(topic?.slug && topic?.name)) .map((topic) => ({ label: topic.name, slug: topic.slug })), cover, file: file ? { ...file, label: `${file.kind} ยท ${formatFileSize(file.sizeBytes, locale)}` } : undefined, externalUrl: entry.externalUrl ?? undefined, }; } /* --------------------------------------------------------------------------- META The shell's prefix-swapped canonical + hreflang is suppressed for this route by `handle = SELF_ALTERNATES_HANDLE`; the correct pair is appended below from the counterpart's real slug. ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const entry = loaderData as Awaited> | undefined; const fallback = RESOURCES_COPY[root.locale]; if (!entry) { return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: fallback.heading, description: fallback.metaDescription, }); } const description = entry.summary ?? fallback.metaDescription; const descriptors = buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: entry.title, description, type: "article", ...(entry.cover ? { image: { url: entry.cover.src, alt: entry.cover.alt } } : {}), ...(entry.dateIso ? { article: { publishedTime: entry.dateIso, ...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}), ...(entry.authors ? { authors: [entry.authors] } : {}), ...(entry.typeLabel ? { section: entry.typeLabel } : {}), tags: entry.topics.map((topic) => topic.label), }, } : {}), jsonLd: [ buildBreadcrumbJsonLd(root.siteUrl, [ { name: "SHARP-ER", pathname: sectionPath("home", root.locale) }, { name: fallback.heading, pathname: sectionPath("resources", root.locale) }, { name: entry.title, pathname: root.pathname }, ]), ...(entry.dateIso ? [ buildArticleJsonLd({ siteUrl: root.siteUrl, locale: root.locale, headline: entry.title, pathname: root.pathname, datePublished: entry.dateIso, ...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}), ...(entry.summary ? { description: entry.summary } : {}), ...(entry.cover ? { image: entry.cover.src } : {}), ...(entry.authors ? { authorName: entry.authors } : {}), ...(entry.typeLabel ? { section: entry.typeLabel } : {}), keywords: entry.topics.map((topic) => topic.label), }), ] : []), ], }); return [ ...descriptors, ...detailAlternateDescriptors({ siteUrl: root.siteUrl, section: "resources", locale: root.locale, slugs: entry.alternates, }), ]; } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function ResourceDetail() { const entry = useLoaderData(); const { t, locale } = useTranslation(); const listingPath = sectionPath("resources", locale); const detail = DETAIL_COPY[locale]; const downloadHref = entry.file?.url ?? entry.externalUrl; const metaItems: MetaItem[] = [ ...(entry.dateLabel ? [ { label: detail.published, value: , }, ] : []), ...(entry.authors ? [{ label: detail.author, value: entry.authors }] : []), ...(entry.partners.length > 0 ? [{ label: detail.partner, value: entry.partners.join(", ") }] : []), ]; return (
{entry.cover ? ( {entry.cover.alt} ) : null}
{/* A publication has no rich-text body in the CMS - it IS the document. `description` is the standfirst above. */} {downloadHref ? (
{entry.file ? ( {entry.file.label} ) : null}
) : null}
); }