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