/* ============================================================================ KNOWLEDGE HUB - one item ---------------------------------------------------------------------------- /{locale}/knowledge-hub/{slug} Registered in app/routes.ts as "knowledgeHubDetail", a SIBLING of the listing route under the ":lang" layout, and typed against its generated `./+types/` module. A missing slug is a 404. A CMS outage is not: `fetchBySlug` only converts Strapi's own 404 into null and rethrows everything else. ========================================================================= */ import { useLoaderData } from "react-router"; import { Button } from "~/components/ui/Button"; import { formatDate, formatFileSize, sectionPath, toIsoDate, useTranslation, type Locale, } from "~/i18n"; import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import { requireEntity } from "~/lib/strapi"; import type { Route } from "./+types/knowledge-hub.$slug"; import { collectLocalizedSlugs, detailAlternateDescriptors, LOCALIZATION_POPULATE, SELF_ALTERNATES_HANDLE, } from "./_shared/alternates"; import { CMS, fetchBySlug, toFile, toImage, type CmsFile, type KnowledgeEntry, } 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, KNOWLEDGE_COPY, KNOWLEDGE_DETAIL_COPY, KNOWLEDGE_TYPE_BLURBS, KNOWLEDGE_TYPE_LABELS, } from "./_shared/page-copy"; function typeLabel(value: string, locale: Locale): string { return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value; } /** * Strapi 5 gives the Arabic translation its own slug, so this route emits its * own canonical + hreflang - see ./_shared/alternates.ts. */ 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.knowledgeItems, slug, { locale, populate: { thumbnail: { fields: ["url", "alternativeText", "width", "height", "formats"], }, // Multiple media, and the only download this type has: there is no // `externalUrl` field on a knowledge item. attachments: { fields: ["url", "name", "ext", "mime", "size"] }, topics: { fields: ["name", "slug"] }, localizations: LOCALIZATION_POPULATE, }, }), ); const attachments = (entry.attachments ?? []) .map((media) => toFile(media)) .filter((file): file is CmsFile => file !== undefined) .map((file) => ({ ...file, label: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`, })); const date = entry.publicationDate ?? entry.publishedAt ?? undefined; return { slug: entry.slug, alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations), title: entry.title, summary: entry.summary ?? undefined, body: entry.body ?? undefined, typeLabel: entry.itemType ? typeLabel(entry.itemType, locale) : undefined, typeBlurb: entry.itemType ? KNOWLEDGE_TYPE_BLURBS[locale][entry.itemType] : 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.thumbnail, "large"), attachments, }; } /* --------------------------------------------------------------------------- META ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const entry = loaderData as Awaited> | undefined; const section = KNOWLEDGE_COPY[root.locale]; if (!entry) { return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: section.heading, description: section.metaDescription, }); } const descriptors = buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: entry.title, description: entry.summary ?? entry.typeBlurb ?? section.metaDescription, 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.typeLabel ? { section: entry.typeLabel } : {}), tags: entry.topics.map((topic) => topic.label), }, } : {}), jsonLd: [ buildBreadcrumbJsonLd(root.siteUrl, [ { name: "SHARP-ER", pathname: sectionPath("home", root.locale) }, { name: section.heading, pathname: sectionPath("knowledgeHub", 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.typeLabel ? { section: entry.typeLabel } : {}), keywords: entry.topics.map((topic) => topic.label), }), ] : []), ], }); return [ ...descriptors, ...detailAlternateDescriptors({ siteUrl: root.siteUrl, section: "knowledgeHub", locale: root.locale, slugs: entry.alternates, }), ]; } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function KnowledgeItemDetail() { const entry = useLoaderData(); const { t, locale } = useTranslation(); const listingPath = sectionPath("knowledgeHub", locale); const detail = KNOWLEDGE_DETAIL_COPY[locale]; const shared = DETAIL_COPY[locale]; const metaItems: MetaItem[] = [ ...(entry.dateLabel ? [ { label: shared.published, value: , }, ] : []), ...(entry.typeLabel ? [{ label: t("filters.category"), value: entry.typeLabel }] : []), ]; return (
{entry.cover ? ( {entry.cover.alt} ) : null}
{entry.attachments.length > 0 ? (
    {entry.attachments.map((file) => (
  • {file.name} · {file.label}
  • ))}
) : null}
); }