/* ============================================================================ NEWS AND UPDATES - one article ---------------------------------------------------------------------------- /{locale}/news-and-updates/{slug} Registered in app/routes.ts as "newsDetail", a SIBLING of the listing route under the ":lang" layout, and typed against its generated `./+types/` module. A "From the Network" update credits the submitting organisation on the page and in the Article JSON-LD, because the draft is explicit that network updates are attributed to the CSO that wrote them - the project must not appear to be the author of somebody else's work. ========================================================================= */ import { Link, useLoaderData } from "react-router"; import { Button } from "~/components/ui/Button"; import { formatDate, sectionPath, toIsoDate, useTranslation, } from "~/i18n"; import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { requireEntity } from "~/lib/strapi"; import type { Route } from "./+types/news-and-updates.$slug"; import { collectLocalizedSlugs, detailAlternateDescriptors, LOCALIZATION_POPULATE, SELF_ALTERNATES_HANDLE, } from "./_shared/alternates"; import { CMS, fetchBySlug, toImage, type NewsEntry } from "./_shared/cms"; import { readRootSeo, requireLocale, withParam } from "./_shared/listing"; import { BackLink, MetaLine, MetaList, PageHeader, PageShell, TopicChips, type MetaItem, } from "./_shared/listing-ui"; import { RichText } from "./_shared/RichText"; import { DETAIL_COPY, NEWS_COPY, NEWS_DETAIL_COPY } from "./_shared/page-copy"; /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ /** * The Arabic translation of this article has its OWN slug, so the hreflang * alternate cannot be built by swapping the locale prefix - see * ./_shared/alternates.ts. `localizations` is populated for exactly that. */ export const handle = SELF_ALTERNATES_HANDLE; export async function loader({ params }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const slug = params.slug ?? ""; const entry = requireEntity( await fetchBySlug(CMS.newsArticles, slug, { locale, populate: { coverImage: { fields: ["url", "alternativeText", "width", "height", "formats"], }, authorOrganisation: { fields: ["name", "slug"] }, topics: { fields: ["name", "slug"] }, localizations: LOCALIZATION_POPULATE, }, }), ); const date = entry.publishDate ?? entry.publishedAt ?? undefined; return { slug: entry.slug, alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations), title: entry.title, excerpt: entry.excerpt ?? undefined, body: entry.body ?? undefined, category: entry.category ?? "sharper-news", organisation: entry.authorOrganisation?.name ?? undefined, date, dateIso: toIsoDate(date), dateLabel: date ? formatDate(date, locale) : undefined, modifiedIso: toIsoDate(entry.updatedAt), topics: (entry.topics ?? []) .filter((topic) => Boolean(topic?.slug && topic?.name)) .map((topic) => ({ label: topic.name, slug: topic.slug })), cover: toImage(entry.coverImage, "large"), }; } /* --------------------------------------------------------------------------- META The shell emits a prefix-swapped canonical + hreflang pair for every route; a DETAIL page overrides it, because a Strapi 5 localisation has its own slug. `handle = SELF_ALTERNATES_HANDLE` is what tells the shell to stand down for this route. ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const entry = loaderData as Awaited> | undefined; const section = NEWS_COPY[root.locale]; if (!entry) { return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: section.heading, description: section.metaDescription, }); } const sectionLabel = entry.category === "from-the-network" ? section.networkBlurb : section.projectBlurb; const descriptors = buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: entry.title, description: entry.excerpt ?? sectionLabel, 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.organisation ? { authors: [entry.organisation] } : {}), tags: entry.topics.map((topic) => topic.label), }, } : {}), jsonLd: [ buildBreadcrumbJsonLd(root.siteUrl, [ { name: "SHARP-ER", pathname: sectionPath("home", root.locale) }, { name: section.heading, pathname: sectionPath("news", 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.excerpt ? { description: entry.excerpt } : {}), ...(entry.cover ? { image: entry.cover.src } : {}), // A network update is authored by the CSO that submitted it. ...(entry.organisation ? { authorName: entry.organisation } : {}), keywords: entry.topics.map((topic) => topic.label), }), ] : []), ], }); /* The corrected canonical + hreflang pair, built from the counterpart's real slug rather than from this URL with the prefix swapped. */ return [ ...descriptors, ...detailAlternateDescriptors({ siteUrl: root.siteUrl, section: "news", locale: root.locale, slugs: entry.alternates, }), ]; } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function NewsArticleDetail() { const entry = useLoaderData(); const { t, locale } = useTranslation(); const listingPath = sectionPath("news", locale); const detail = NEWS_DETAIL_COPY[locale]; const shared = DETAIL_COPY[locale]; const streamLabel = entry.category === "from-the-network" ? t("news.tabNetwork") : t("news.tabProject"); const streamHref = `${listingPath}${withParam(new URLSearchParams(), "tab", entry.category)}`; const metaItems: MetaItem[] = [ ...(entry.dateLabel ? [ { label: shared.published, value: , }, ] : []), ...(entry.organisation ? [{ label: shared.author, value: entry.organisation }] : []), ]; return (
{entry.cover ? ( {entry.cover.alt} ) : null}
); }