667 lines
20 KiB
TypeScript
667 lines
20 KiB
TypeScript
/* ============================================================================
|
|||
|
|
ONE ACTIVITY OR INITIATIVE
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
URL /en/activities-and-initiatives/:slug · /ar/activities-and-initiatives/:slug
|
||
|
|
|
||
|
|
Registered in app/routes.ts as "activitiesDetail", a SIBLING of the listing
|
||
|
|
route under the ":lang" layout, and typed against its generated `./+types/`
|
||
|
|
module. A slug the CMS does not know still falls through to the localised
|
||
|
|
404, which is the correct answer for it.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import { Fragment, type ReactNode } from "react";
|
||
|
|
import {
|
||
|
|
Link,
|
||
|
|
useLoaderData,
|
||
|
|
type MetaDescriptor,
|
||
|
|
} from "react-router";
|
||
|
|
|
||
|
|
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||
|
|
import { Card } from "~/components/ui/Card";
|
||
|
|
import { getMessages, sectionPath, toIsoDate, useTranslation } from "~/i18n";
|
||
|
|
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
|
||
|
|
import {
|
||
|
|
buildArticleJsonLd,
|
||
|
|
buildBreadcrumbJsonLd,
|
||
|
|
buildMeta,
|
||
|
|
type JsonLd,
|
||
|
|
} from "~/lib/seo";
|
||
|
|
import {
|
||
|
|
getBySlug,
|
||
|
|
getMany,
|
||
|
|
isStrapiError,
|
||
|
|
isStrapiNotFound,
|
||
|
|
mediaAlt,
|
||
|
|
mediaUrl,
|
||
|
|
notFound,
|
||
|
|
requireEntity,
|
||
|
|
type StrapiEntity,
|
||
|
|
type StrapiMedia,
|
||
|
|
} from "~/lib/strapi";
|
||
|
|
|
||
|
|
import type { Route } from "./+types/activities-and-initiatives.$slug";
|
||
|
|
import {
|
||
|
|
ACTIVITY_STREAM_CMS_VALUES,
|
||
|
|
activitiesIndexPath,
|
||
|
|
activityPath,
|
||
|
|
getDetailCopy,
|
||
|
|
getStreamCopy,
|
||
|
|
isActivityStatus,
|
||
|
|
readRootMeta,
|
||
|
|
toActivityStreamKey,
|
||
|
|
type ActivityStreamKey,
|
||
|
|
type ActivitySummary,
|
||
|
|
} from "./activities.content";
|
||
|
|
import {
|
||
|
|
collectLocalizedSlugs,
|
||
|
|
detailAlternateDescriptors,
|
||
|
|
LOCALIZATION_POPULATE,
|
||
|
|
SELF_ALTERNATES_HANDLE,
|
||
|
|
type CmsLocalizationRef,
|
||
|
|
type LocalizedSlugs,
|
||
|
|
} from "./_shared/alternates";
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
CMS SHAPES
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A Strapi rich-text value. The Blocks editor emits a node tree; a plain Text
|
||
|
|
* or Markdown field emits a string. Both are rendered below, so the content
|
||
|
|
* type can switch field kinds without touching this route.
|
||
|
|
*/
|
||
|
|
type RichTextValue = string | readonly RichTextNode[] | null;
|
||
|
|
|
||
|
|
interface RichTextImage {
|
||
|
|
readonly url?: string | null;
|
||
|
|
readonly alternativeText?: string | null;
|
||
|
|
readonly width?: number | null;
|
||
|
|
readonly height?: number | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface RichTextNode {
|
||
|
|
readonly type?: string;
|
||
|
|
readonly children?: readonly RichTextNode[];
|
||
|
|
readonly text?: string;
|
||
|
|
readonly bold?: boolean;
|
||
|
|
readonly italic?: boolean;
|
||
|
|
readonly underline?: boolean;
|
||
|
|
readonly strikethrough?: boolean;
|
||
|
|
readonly code?: boolean;
|
||
|
|
readonly url?: string;
|
||
|
|
readonly level?: number;
|
||
|
|
readonly format?: string;
|
||
|
|
readonly image?: RichTextImage;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A related entry that only contributes its display name. */
|
||
|
|
interface NamedEntity {
|
||
|
|
readonly name?: string | null;
|
||
|
|
readonly slug?: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* `api::activity.activity`, field for field from
|
||
|
|
* apps/cms/src/api/activity/content-types/activity/schema.json. There is no
|
||
|
|
* `stream`, no `cover`, no `location` string and no date pair on this type.
|
||
|
|
*/
|
||
|
|
interface ActivityEntity extends StrapiEntity {
|
||
|
|
readonly title: string;
|
||
|
|
readonly slug: string;
|
||
|
|
readonly activityStream?: string | null;
|
||
|
|
readonly activityStatus?: string | null;
|
||
|
|
readonly summary?: string | null;
|
||
|
|
readonly body?: RichTextValue;
|
||
|
|
readonly regions?: readonly NamedEntity[] | null;
|
||
|
|
readonly implementingPartners?: readonly NamedEntity[] | null;
|
||
|
|
readonly coverImage?: StrapiMedia | null;
|
||
|
|
readonly localizations?: readonly CmsLocalizationRef[] | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ACTIVITY_SUMMARY_FIELDS = [
|
||
|
|
"title",
|
||
|
|
"slug",
|
||
|
|
"activityStream",
|
||
|
|
"activityStatus",
|
||
|
|
"summary",
|
||
|
|
] as const;
|
||
|
|
|
||
|
|
const ACTIVITY_SORT = ["publishedAt:desc", "title:asc"] as const;
|
||
|
|
|
||
|
|
/** Names of a relation list, joined - or null when the relation is empty. */
|
||
|
|
function namesOf(entries: readonly NamedEntity[] | null | undefined): string | null {
|
||
|
|
const names = (entries ?? [])
|
||
|
|
.map((entry) => entry?.name?.trim())
|
||
|
|
.filter((name): name is string => Boolean(name));
|
||
|
|
|
||
|
|
return names.length > 0 ? names.join(", ") : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** How many other activities from the same stream are offered at the end. */
|
||
|
|
const RELATED_LIMIT = 3;
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
RICH TEXT - server side
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Absolutise every image URL inside a Blocks tree while still on the server:
|
||
|
|
* `mediaUrl()` reads STRAPI_URL, so a relative "/uploads/x.jpg" cannot be
|
||
|
|
* resolved once the tree has been serialised to the browser.
|
||
|
|
*/
|
||
|
|
function resolveRichTextMedia(value: RichTextValue): RichTextValue {
|
||
|
|
if (value === null || typeof value === "string") return value;
|
||
|
|
|
||
|
|
return value.map((node) => resolveNodeMedia(node));
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveNodeMedia(node: RichTextNode): RichTextNode {
|
||
|
|
const children = node.children?.map((child) => resolveNodeMedia(child));
|
||
|
|
const url = node.image?.url ? mediaUrl(node.image.url) : undefined;
|
||
|
|
|
||
|
|
return {
|
||
|
|
...node,
|
||
|
|
...(children ? { children } : {}),
|
||
|
|
...(node.image ? { image: { ...node.image, url: url ?? null } } : {}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
LOADER
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
/** This route emits its own canonical + hreflang - see ./_shared/alternates.ts. */
|
||
|
|
export const handle = SELF_ALTERNATES_HANDLE;
|
||
|
|
|
||
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
||
|
|
const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
|
||
|
|
const slug = params.slug;
|
||
|
|
|
||
|
|
if (!slug) notFound();
|
||
|
|
|
||
|
|
let entity: ActivityEntity | null;
|
||
|
|
try {
|
||
|
|
entity = await getBySlug<ActivityEntity>("activities", slug, {
|
||
|
|
locale,
|
||
|
|
populate: {
|
||
|
|
coverImage: {
|
||
|
|
fields: ["url", "alternativeText", "width", "height"],
|
||
|
|
},
|
||
|
|
regions: { fields: ["name", "slug"] },
|
||
|
|
implementingPartners: { fields: ["name", "slug"] },
|
||
|
|
// The Arabic translation has its own slug - see ./_shared/alternates.
|
||
|
|
localizations: LOCALIZATION_POPULATE,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
// A 404 from Strapi means "no such activity"; anything else (the CMS is
|
||
|
|
// down, the content type is missing, the token is wrong) is a real server
|
||
|
|
// fault and must not masquerade as a missing page, so it is rethrown for
|
||
|
|
// the root ErrorBoundary to render as an error rather than as a 404.
|
||
|
|
if (isStrapiNotFound(error)) entity = null;
|
||
|
|
else throw error;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Throws the localised 404 that the root ErrorBoundary renders.
|
||
|
|
const activity = requireEntity(entity);
|
||
|
|
|
||
|
|
const stream = toActivityStreamKey(activity.activityStream);
|
||
|
|
|
||
|
|
return {
|
||
|
|
locale,
|
||
|
|
alternates: collectLocalizedSlugs(
|
||
|
|
locale,
|
||
|
|
activity.slug,
|
||
|
|
activity.localizations,
|
||
|
|
) satisfies LocalizedSlugs,
|
||
|
|
activity: {
|
||
|
|
id: activity.documentId,
|
||
|
|
slug: activity.slug,
|
||
|
|
title: activity.title,
|
||
|
|
stream,
|
||
|
|
status: isActivityStatus(activity.activityStatus)
|
||
|
|
? activity.activityStatus
|
||
|
|
: null,
|
||
|
|
summary: activity.summary ?? null,
|
||
|
|
body: resolveRichTextMedia(activity.body ?? null),
|
||
|
|
location: namesOf(activity.regions),
|
||
|
|
implementingPartner: namesOf(activity.implementingPartners),
|
||
|
|
imageUrl: mediaUrl(activity.coverImage?.url) ?? null,
|
||
|
|
imageAlt: mediaAlt(activity.coverImage),
|
||
|
|
imageWidth: activity.coverImage?.width ?? null,
|
||
|
|
imageHeight: activity.coverImage?.height ?? null,
|
||
|
|
publishedAt: activity.publishedAt,
|
||
|
|
updatedAt: activity.updatedAt,
|
||
|
|
},
|
||
|
|
related: await loadRelated(locale, stream, activity.documentId),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ActivityDetailLoaderData = Awaited<ReturnType<typeof loader>>;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Other activities from the same stream. Purely additive, so a failure here
|
||
|
|
* degrades to an empty list instead of taking down a page that already has
|
||
|
|
* everything it needs.
|
||
|
|
*/
|
||
|
|
async function loadRelated(
|
||
|
|
locale: Locale,
|
||
|
|
stream: ActivityStreamKey | null,
|
||
|
|
documentId: string,
|
||
|
|
): Promise<ActivitySummary[]> {
|
||
|
|
if (!stream) return [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await getMany<ActivityEntity>("activities", {
|
||
|
|
locale,
|
||
|
|
filters: {
|
||
|
|
// The CMS enumeration value, which is not always the web key.
|
||
|
|
activityStream: { $eq: ACTIVITY_STREAM_CMS_VALUES[stream] },
|
||
|
|
documentId: { $ne: documentId },
|
||
|
|
},
|
||
|
|
sort: [...ACTIVITY_SORT],
|
||
|
|
fields: [...ACTIVITY_SUMMARY_FIELDS],
|
||
|
|
populate: {
|
||
|
|
coverImage: { fields: ["url", "alternativeText"] },
|
||
|
|
regions: { fields: ["name", "slug"] },
|
||
|
|
implementingPartners: { fields: ["name", "slug"] },
|
||
|
|
},
|
||
|
|
pagination: { pageSize: RELATED_LIMIT, withCount: false },
|
||
|
|
});
|
||
|
|
|
||
|
|
return response.data
|
||
|
|
.filter((entity) => Boolean(entity.slug && entity.title))
|
||
|
|
.map((entity) => ({
|
||
|
|
id: entity.documentId,
|
||
|
|
slug: entity.slug,
|
||
|
|
title: entity.title,
|
||
|
|
stream,
|
||
|
|
summary: entity.summary ?? null,
|
||
|
|
imageUrl: mediaUrl(entity.coverImage?.url) ?? null,
|
||
|
|
imageAlt: mediaAlt(entity.coverImage),
|
||
|
|
location: namesOf(entity.regions),
|
||
|
|
implementingPartner: namesOf(entity.implementingPartners),
|
||
|
|
status: isActivityStatus(entity.activityStatus)
|
||
|
|
? entity.activityStatus
|
||
|
|
: null,
|
||
|
|
}));
|
||
|
|
} catch (error) {
|
||
|
|
if (isStrapiError(error)) {
|
||
|
|
console.error(`[activity:${documentId}] related unavailable: ${error.message}`);
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
META
|
||
|
|
The canonical link and the en / ar / x-default hreflang alternates come from
|
||
|
|
the shell (root.tsx `Layout`) for every route, so `alternates` stays false
|
||
|
|
here - passing it would emit each of those tags twice.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export function meta({ loaderData, matches }: Route.MetaArgs): MetaDescriptor[] {
|
||
|
|
const { locale, pathname, siteUrl } = readRootMeta(
|
||
|
|
matches,
|
||
|
|
activitiesIndexPath(DEFAULT_LOCALE),
|
||
|
|
);
|
||
|
|
|
||
|
|
const { sections } = getMessages(locale).nav;
|
||
|
|
const activity = loaderData?.activity;
|
||
|
|
|
||
|
|
// No loader data means the boundary is rendering (a 404, most often); the
|
||
|
|
// shell's own meta covers that case, so only the section title is claimed.
|
||
|
|
if (!activity) {
|
||
|
|
return buildMeta({ locale, pathname, siteUrl, title: sections.activities });
|
||
|
|
}
|
||
|
|
|
||
|
|
const streamTitle = activity.stream
|
||
|
|
? getStreamCopy(activity.stream, locale).title
|
||
|
|
: undefined;
|
||
|
|
const description = activity.summary ?? undefined;
|
||
|
|
const datePublished = toIsoDate(activity.publishedAt ?? activity.updatedAt);
|
||
|
|
|
||
|
|
const jsonLd: JsonLd[] = [
|
||
|
|
buildBreadcrumbJsonLd(siteUrl, [
|
||
|
|
{ name: sections.home, pathname: sectionPath("home", locale) },
|
||
|
|
{ name: sections.activities, pathname: activitiesIndexPath(locale) },
|
||
|
|
{ name: activity.title, pathname: activityPath(activity.slug, locale) },
|
||
|
|
]),
|
||
|
|
];
|
||
|
|
|
||
|
|
if (datePublished) {
|
||
|
|
jsonLd.push(
|
||
|
|
buildArticleJsonLd({
|
||
|
|
siteUrl,
|
||
|
|
locale,
|
||
|
|
headline: activity.title,
|
||
|
|
pathname: activityPath(activity.slug, locale),
|
||
|
|
datePublished,
|
||
|
|
dateModified: toIsoDate(activity.updatedAt) || undefined,
|
||
|
|
description,
|
||
|
|
image: activity.imageUrl ?? undefined,
|
||
|
|
section: streamTitle,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const descriptors = buildMeta({
|
||
|
|
locale,
|
||
|
|
pathname,
|
||
|
|
siteUrl,
|
||
|
|
title: activity.title,
|
||
|
|
description,
|
||
|
|
type: "article",
|
||
|
|
image: activity.imageUrl
|
||
|
|
? {
|
||
|
|
url: activity.imageUrl,
|
||
|
|
alt: activity.imageAlt,
|
||
|
|
width: activity.imageWidth ?? undefined,
|
||
|
|
height: activity.imageHeight ?? undefined,
|
||
|
|
}
|
||
|
|
: undefined,
|
||
|
|
jsonLd,
|
||
|
|
});
|
||
|
|
|
||
|
|
return [
|
||
|
|
...descriptors,
|
||
|
|
...detailAlternateDescriptors({
|
||
|
|
siteUrl,
|
||
|
|
section: "activities",
|
||
|
|
locale,
|
||
|
|
slugs: loaderData?.alternates ?? {},
|
||
|
|
}),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
VIEW
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
export default function ActivityDetail() {
|
||
|
|
const { activity, related } = useLoaderData<typeof loader>();
|
||
|
|
const { locale, t } = useTranslation();
|
||
|
|
|
||
|
|
const copy = getDetailCopy(locale);
|
||
|
|
const stream = activity.stream ? getStreamCopy(activity.stream, locale) : null;
|
||
|
|
const partner = activity.implementingPartner ?? stream?.partners ?? null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
|
||
|
|
<header className="flex flex-col gap-lg">
|
||
|
|
<Button
|
||
|
|
as={Link}
|
||
|
|
to={activitiesIndexPath(locale)}
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
iconStart={<ArrowIcon direction="backward" />}
|
||
|
|
className="self-start"
|
||
|
|
>
|
||
|
|
{copy.backToIndex}
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
|
||
|
|
{stream ? (
|
||
|
|
<p className="label-md text-text-accent">{stream.title}</p>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<h1 className="display-sm text-text-primary">{activity.title}</h1>
|
||
|
|
|
||
|
|
{activity.summary ? (
|
||
|
|
<p className="body-lg text-text-secondary">{activity.summary}</p>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<dl className="flex flex-wrap gap-x-2xl gap-y-md">
|
||
|
|
{activity.location ? (
|
||
|
|
<MetaPair label={copy.locationLabel} value={activity.location} />
|
||
|
|
) : null}
|
||
|
|
{partner ? (
|
||
|
|
<MetaPair label={copy.partnerLabel} value={partner} />
|
||
|
|
) : null}
|
||
|
|
</dl>
|
||
|
|
|
||
|
|
{activity.imageUrl ? (
|
||
|
|
<img
|
||
|
|
src={activity.imageUrl}
|
||
|
|
alt={activity.imageAlt}
|
||
|
|
width={activity.imageWidth ?? undefined}
|
||
|
|
height={activity.imageHeight ?? undefined}
|
||
|
|
className="w-full rounded-card object-cover"
|
||
|
|
loading="eager"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<section
|
||
|
|
aria-labelledby="activity-details"
|
||
|
|
className="flex max-w-[var(--layout-prose-max)] flex-col gap-md"
|
||
|
|
>
|
||
|
|
<h2 id="activity-details" className="sr-only">
|
||
|
|
{copy.detailsHeading}
|
||
|
|
</h2>
|
||
|
|
<RichText value={activity.body} fallback={copy.bodyMissing} />
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{related.length > 0 ? (
|
||
|
|
<section aria-labelledby="activity-related" className="flex flex-col gap-lg">
|
||
|
|
<h2 id="activity-related" className="heading-h2 text-text-primary">
|
||
|
|
{copy.relatedHeading}
|
||
|
|
</h2>
|
||
|
|
<ul className="flex list-none flex-col gap-lg">
|
||
|
|
{related.map((item) => (
|
||
|
|
<Card.Horizontal
|
||
|
|
as="li"
|
||
|
|
key={item.id}
|
||
|
|
href={activityPath(item.slug, locale)}
|
||
|
|
title={item.title}
|
||
|
|
eyebrow={item.location ?? undefined}
|
||
|
|
excerpt={item.summary ?? undefined}
|
||
|
|
actionLabel={t("actions.viewDetails")}
|
||
|
|
image={
|
||
|
|
item.imageUrl
|
||
|
|
? { src: item.imageUrl, alt: item.imageAlt }
|
||
|
|
: undefined
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
) : null}
|
||
|
|
</article>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** One `<dt>`/`<dd>` pair in the activity's fact list. */
|
||
|
|
function MetaPair({ label, value }: { label: string; value: string }) {
|
||
|
|
return (
|
||
|
|
<div className="flex flex-col gap-2xs">
|
||
|
|
<dt className="label-md text-text-tertiary">{label}</dt>
|
||
|
|
<dd className="body-md-bold text-text-primary">{value}</dd>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
RICH TEXT - rendering
|
||
|
|
Strapi Blocks and plain text both land here. Nothing is injected as HTML:
|
||
|
|
every node is turned into a React element, so CMS content cannot script the
|
||
|
|
page, and the styling comes from type roles and semantic tokens only.
|
||
|
|
------------------------------------------------------------------------ */
|
||
|
|
|
||
|
|
function RichText({
|
||
|
|
value,
|
||
|
|
fallback,
|
||
|
|
}: {
|
||
|
|
value: RichTextValue;
|
||
|
|
fallback: string;
|
||
|
|
}) {
|
||
|
|
if (typeof value === "string") {
|
||
|
|
const paragraphs = value
|
||
|
|
.split(/\n{2,}/)
|
||
|
|
.map((paragraph) => paragraph.trim())
|
||
|
|
.filter(Boolean);
|
||
|
|
|
||
|
|
if (paragraphs.length === 0) return <Prose>{fallback}</Prose>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{paragraphs.map((paragraph, index) => (
|
||
|
|
<Prose key={index}>{paragraph}</Prose>
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!value || value.length === 0) return <Prose>{fallback}</Prose>;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{value.map((node, index) => (
|
||
|
|
<RichTextBlock key={index} node={node} />
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Prose({ children }: { children: ReactNode }) {
|
||
|
|
return <p className="body-md text-text-secondary">{children}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function RichTextBlock({ node }: { node: RichTextNode }) {
|
||
|
|
switch (node.type) {
|
||
|
|
case "heading": {
|
||
|
|
const level = node.level ?? 2;
|
||
|
|
if (level <= 2) {
|
||
|
|
return (
|
||
|
|
<h3 className="heading-h3 mt-lg text-text-primary">
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</h3>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return (
|
||
|
|
<h4 className="body-lg-bold mt-md text-text-primary">
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</h4>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "list": {
|
||
|
|
const List = node.format === "ordered" ? "ol" : "ul";
|
||
|
|
return (
|
||
|
|
<List
|
||
|
|
className={`ps-lg body-md text-text-secondary ${
|
||
|
|
node.format === "ordered" ? "list-decimal" : "list-disc"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{node.children?.map((item, index) => (
|
||
|
|
<li key={index} className="mb-2xs">
|
||
|
|
<RichTextChildren nodes={item.children} />
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</List>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
case "quote":
|
||
|
|
return (
|
||
|
|
<blockquote className="body-lg border-s-2 border-border-accent ps-md text-text-primary">
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</blockquote>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "code":
|
||
|
|
return (
|
||
|
|
<pre className="body-sm overflow-x-auto rounded-card bg-surface-raised p-md text-text-secondary">
|
||
|
|
<code>
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</code>
|
||
|
|
</pre>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "image":
|
||
|
|
return node.image?.url ? (
|
||
|
|
<img
|
||
|
|
src={node.image.url}
|
||
|
|
alt={node.image.alternativeText ?? ""}
|
||
|
|
width={node.image.width ?? undefined}
|
||
|
|
height={node.image.height ?? undefined}
|
||
|
|
className="w-full rounded-card"
|
||
|
|
loading="lazy"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
) : null;
|
||
|
|
|
||
|
|
case "paragraph":
|
||
|
|
return (
|
||
|
|
<Prose>
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</Prose>
|
||
|
|
);
|
||
|
|
|
||
|
|
// A block kind the editor gains later renders as prose rather than as a
|
||
|
|
// gap in the page.
|
||
|
|
default:
|
||
|
|
return node.children?.length ? (
|
||
|
|
<Prose>
|
||
|
|
<RichTextChildren nodes={node.children} />
|
||
|
|
</Prose>
|
||
|
|
) : null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function RichTextChildren({ nodes }: { nodes?: readonly RichTextNode[] }) {
|
||
|
|
if (!nodes) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{nodes.map((node, index) => (
|
||
|
|
<RichTextLeaf key={index} node={node} />
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const EXTERNAL_HREF = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
|
||
|
|
|
||
|
|
function RichTextLeaf({ node }: { node: RichTextNode }) {
|
||
|
|
if (node.type === "link" && node.url) {
|
||
|
|
const children = <RichTextChildren nodes={node.children} />;
|
||
|
|
const className =
|
||
|
|
"text-text-link underline underline-offset-2 hover:text-text-link-hover";
|
||
|
|
|
||
|
|
return EXTERNAL_HREF.test(node.url) ? (
|
||
|
|
<a
|
||
|
|
href={node.url}
|
||
|
|
className={className}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</a>
|
||
|
|
) : (
|
||
|
|
<Link to={node.url} className={className} prefetch="intent">
|
||
|
|
{children}
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (node.children?.length) {
|
||
|
|
return <RichTextChildren nodes={node.children} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
const text = node.text;
|
||
|
|
if (!text) return null;
|
||
|
|
|
||
|
|
let content: ReactNode = text;
|
||
|
|
if (node.code) content = <code className="body-sm">{content}</code>;
|
||
|
|
if (node.bold) content = <strong className="body-md-bold">{content}</strong>;
|
||
|
|
if (node.italic) content = <em>{content}</em>;
|
||
|
|
if (node.underline) content = <u>{content}</u>;
|
||
|
|
if (node.strikethrough) content = <s>{content}</s>;
|
||
|
|
|
||
|
|
return <Fragment>{content}</Fragment>;
|
||
|
|
}
|