Files
sharper.ly/apps/web/app/lib/seo.ts
T

568 lines
17 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
SEO
----------------------------------------------------------------------------
One function builds the whole `meta` descriptor array for a page: title,
description, robots, canonical, the en/ar/x-default hreflang set, Open Graph,
Twitter, and any JSON-LD the route wants to attach.
Pure and client-safe: `meta` also runs in the browser on a client-side
navigation, so nothing here may touch `process.env`. The origin arrives as
`siteUrl`, which the root loader resolves once per request via
`getSiteUrl(request)` from ~/lib/env.server.
========================================================================= */
import type { MetaDescriptor } from "react-router";
import {
DEFAULT_LOCALE,
2026-09-11 02:46:56 +02:00
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
type Locale,
} from "~/i18n/config";
import { getCounterpartLocale, localizedPath, stripLocale } from "~/i18n/paths";
import { getMessages } from "~/i18n/t";
export const SITE_NAME = "SHARP-ER";
/** Separator between the page title and the site name. */
const TITLE_SEPARATOR = " | ";
/** A JSON-LD node. Kept structural - schema.org has no useful TS type. */
export type JsonLd = Readonly<Record<string, unknown>>;
export interface SeoImage {
/** Absolute URL, or a path rooted at the site (e.g. "/assets/og.png"). */
readonly url: string;
readonly alt?: string;
readonly width?: number;
readonly height?: number;
/** MIME type, e.g. "image/png". */
readonly type?: string;
}
export interface SeoArticle {
readonly publishedTime?: string;
readonly modifiedTime?: string;
readonly authors?: readonly string[];
readonly section?: string;
readonly tags?: readonly string[];
}
export interface SeoInput {
readonly locale: Locale;
/** Pathname of the page INCLUDING its locale prefix, e.g. "/ar/contact". */
readonly pathname: string;
/** Site origin without a trailing slash, from the root loader. */
readonly siteUrl: string;
/** Page title without the site name - it is appended for you. */
readonly title: string;
readonly description?: string;
readonly image?: SeoImage;
readonly type?: "website" | "article";
/** Emit `robots: noindex, nofollow`. Use on search and filter result pages. */
readonly noIndex?: boolean;
readonly keywords?: readonly string[];
readonly article?: SeoArticle;
/** Append " | SHARP-ER" to the title. Default true. */
readonly appendSiteName?: boolean;
/**
* Also emit `<link rel="canonical">` and the hreflang set from THIS page's
* meta. Default false, and it should normally stay false: the app shell
* emits both for every route from `root.tsx`'s `Layout`, so a page that
* writes its own `meta` cannot accidentally drop them. Turn it on only when
* rendering a document outside that shell.
*/
readonly alternates?: boolean;
/** JSON-LD to embed, from the builders below or hand-written. */
readonly jsonLd?: JsonLd | readonly JsonLd[];
}
/* ---------------------------------------------------------------------------
URLS
------------------------------------------------------------------------ */
const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i;
/** Join a site origin and a path into an absolute URL. */
export function absoluteUrl(siteUrl: string, path: string): string {
if (ABSOLUTE_URL.test(path)) return path;
return `${siteUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/** The canonical URL of a page. Trailing slashes are normalised away. */
export function canonicalUrl(siteUrl: string, pathname: string): string {
const path = pathname.replace(/\/+$/, "") || "/";
return absoluteUrl(siteUrl, path);
}
export interface LocaleAlternate {
readonly locale: Locale;
readonly hrefLang: string;
readonly href: string;
}
/**
* The hreflang set for a page: one entry per locale plus `x-default`, which
* points at the default-locale URL because that is what a bare "/" resolves
* to when no locale can be negotiated.
2026-09-11 02:46:56 +02:00
*/
export function alternateUrls(
siteUrl: string,
pathname: string,
): LocaleAlternate[] {
const unprefixed = stripLocale(pathname);
const alternates: LocaleAlternate[] = SUPPORTED_LOCALES.map((locale) => ({
locale,
hrefLang: locale,
href: canonicalUrl(siteUrl, localizedPath(unprefixed, locale)),
}));
return [
...alternates,
{
locale: DEFAULT_LOCALE,
2026-09-11 02:46:56 +02:00
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
2026-09-11 02:46:56 +02:00
},
];
}
/* ---------------------------------------------------------------------------
META
------------------------------------------------------------------------ */
function formatTitle(input: SeoInput): string {
const { title, appendSiteName = true } = input;
if (!appendSiteName) return title;
if (title === SITE_NAME || title.includes(SITE_NAME)) return title;
return `${title}${TITLE_SEPARATOR}${SITE_NAME}`;
}
/**
* The full `meta` descriptor array for a page.
*
* export function meta({ matches }: Route.MetaArgs) {
* const root = matches[0].data;
* return buildMeta({
* locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl,
* title: "Contact", description: "...",
* });
* }
*/
export function buildMeta(input: SeoInput): MetaDescriptor[] {
const {
locale,
pathname,
siteUrl,
description,
image,
type = "website",
noIndex = false,
keywords,
article,
alternates = false,
jsonLd,
} = input;
const messages = getMessages(locale);
const title = formatTitle(input);
const url = canonicalUrl(siteUrl, pathname);
const descriptionText = description ?? messages.site.description;
const counterpart = getCounterpartLocale(locale);
const descriptors: MetaDescriptor[] = [
{ title },
{ name: "description", content: descriptionText },
];
if (keywords && keywords.length > 0) {
descriptors.push({ name: "keywords", content: keywords.join(", ") });
}
descriptors.push({
name: "robots",
content: noIndex ? "noindex, nofollow" : "index, follow",
});
if (alternates) {
descriptors.push({ tagName: "link", rel: "canonical", href: url });
for (const alternate of alternateUrls(siteUrl, pathname)) {
descriptors.push({
tagName: "link",
rel: "alternate",
hrefLang: alternate.hrefLang,
href: alternate.href,
});
}
}
descriptors.push(
{ property: "og:type", content: type },
{ property: "og:site_name", content: SITE_NAME },
{ property: "og:title", content: title },
{ property: "og:description", content: descriptionText },
{ property: "og:url", content: url },
{ property: "og:locale", content: OG_LOCALE[locale] },
{ property: "og:locale:alternate", content: OG_LOCALE[counterpart] },
);
if (image) {
const imageUrl = absoluteUrl(siteUrl, image.url);
descriptors.push({ property: "og:image", content: imageUrl });
if (image.alt) {
descriptors.push({ property: "og:image:alt", content: image.alt });
}
if (image.width) {
descriptors.push({
property: "og:image:width",
content: String(image.width),
});
}
if (image.height) {
descriptors.push({
property: "og:image:height",
content: String(image.height),
});
}
if (image.type) {
descriptors.push({ property: "og:image:type", content: image.type });
}
}
if (type === "article" && article) {
if (article.publishedTime) {
descriptors.push({
property: "article:published_time",
content: article.publishedTime,
});
}
if (article.modifiedTime) {
descriptors.push({
property: "article:modified_time",
content: article.modifiedTime,
});
}
if (article.section) {
descriptors.push({
property: "article:section",
content: article.section,
});
}
for (const author of article.authors ?? []) {
descriptors.push({ property: "article:author", content: author });
}
for (const tag of article.tags ?? []) {
descriptors.push({ property: "article:tag", content: tag });
}
}
descriptors.push(
{
name: "twitter:card",
content: image ? "summary_large_image" : "summary",
},
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: descriptionText },
);
if (image) {
descriptors.push({
name: "twitter:image",
content: absoluteUrl(siteUrl, image.url),
});
if (image.alt) {
descriptors.push({ name: "twitter:image:alt", content: image.alt });
}
}
for (const node of toArray(jsonLd)) {
descriptors.push({ "script:ld+json": node });
}
return descriptors;
}
function toArray(value: JsonLd | readonly JsonLd[] | undefined): JsonLd[] {
if (!value) return [];
return Array.isArray(value) ? [...value] : [value as JsonLd];
}
/* ---------------------------------------------------------------------------
JSON-LD BUILDERS
Facts are taken from the ToR and the approved draft copy: EU-funded,
December 2025 - December 2027, nationwide Libya, Acted (lead) with UEC and
SBE. Nothing here is speculative.
------------------------------------------------------------------------ */
const SCHEMA_CONTEXT = "https://schema.org";
export const CONSORTIUM_PARTNERS = [
{ name: "Acted", role: "Lead implementing partner" },
{ name: "United Efforts Consultancy", role: "Consortium partner" },
{ name: "Stichting Bureau Ensemble", role: "Consortium partner" },
] as const;
export interface OrganizationJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Public profiles, e.g. the project's Facebook page. */
readonly sameAs?: readonly string[];
readonly email?: string;
}
/** Organization JSON-LD for the site itself. Emit it once, from the home page. */
export function buildOrganizationJsonLd({
siteUrl,
locale,
sameAs,
email,
}: OrganizationJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Organization",
name: messages.site.name,
alternateName: messages.site.fullName,
description: messages.site.description,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
logo: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
inLanguage: INTL_LOCALE[locale],
areaServed: { "@type": "Country", name: "Libya" },
funder: { "@type": "Organization", name: "European Union" },
member: CONSORTIUM_PARTNERS.map((partner) => ({
"@type": "Organization",
name: partner.name,
})),
...(email ? { email } : {}),
...(sameAs && sameAs.length > 0 ? { sameAs: [...sameAs] } : {}),
};
}
export interface WebSiteJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
/** Path of the site search route, e.g. "/en/search". Omit to skip the action. */
readonly searchPath?: string;
}
export function buildWebSiteJsonLd({
siteUrl,
locale,
searchPath,
}: WebSiteJsonLdInput): JsonLd {
const messages = getMessages(locale);
return {
"@context": SCHEMA_CONTEXT,
"@type": "WebSite",
name: messages.site.name,
url: canonicalUrl(siteUrl, localizedPath("/", locale)),
inLanguage: INTL_LOCALE[locale],
...(searchPath
? {
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: `${absoluteUrl(siteUrl, searchPath)}?q={search_term_string}`,
},
"query-input": "required name=search_term_string",
},
}
: {}),
};
}
export interface EventJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly name: string;
/** Path of the event page, including the locale prefix. */
readonly pathname: string;
/** ISO 8601. */
readonly startDate: string;
readonly endDate?: string;
readonly description?: string;
readonly image?: string;
/** Venue name, e.g. "Tripoli". Omit for an online-only event. */
readonly locationName?: string;
readonly addressLocality?: string;
readonly addressCountry?: string;
/** Joining URL for an online or hybrid event. */
readonly onlineUrl?: string;
readonly isOnline?: boolean;
readonly registrationUrl?: string;
readonly status?: "scheduled" | "cancelled" | "postponed" | "rescheduled";
readonly organizerName?: string;
}
const EVENT_STATUS: Readonly<Record<NonNullable<EventJsonLdInput["status"]>, string>> = {
scheduled: "https://schema.org/EventScheduled",
cancelled: "https://schema.org/EventCancelled",
postponed: "https://schema.org/EventPostponed",
rescheduled: "https://schema.org/EventRescheduled",
};
/** Event JSON-LD for an event detail page or an events listing. */
export function buildEventJsonLd(input: EventJsonLdInput): JsonLd {
const {
siteUrl,
locale,
name,
pathname,
startDate,
endDate,
description,
image,
locationName,
addressLocality,
addressCountry = "LY",
onlineUrl,
isOnline = false,
registrationUrl,
status = "scheduled",
organizerName = SITE_NAME,
} = input;
const hasPhysicalLocation = Boolean(locationName ?? addressLocality);
const attendanceMode = isOnline
? hasPhysicalLocation
? "https://schema.org/MixedEventAttendanceMode"
: "https://schema.org/OnlineEventAttendanceMode"
: "https://schema.org/OfflineEventAttendanceMode";
const physicalLocation = hasPhysicalLocation
? {
"@type": "Place",
name: locationName ?? addressLocality,
address: {
"@type": "PostalAddress",
...(addressLocality ? { addressLocality } : {}),
addressCountry,
},
}
: undefined;
const virtualLocation = onlineUrl
? { "@type": "VirtualLocation", url: onlineUrl }
: undefined;
const locations = [physicalLocation, virtualLocation].filter(Boolean);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Event",
name,
startDate,
...(endDate ? { endDate } : {}),
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
url: canonicalUrl(siteUrl, pathname),
inLanguage: INTL_LOCALE[locale],
eventAttendanceMode: attendanceMode,
eventStatus: EVENT_STATUS[status],
...(locations.length === 1
? { location: locations[0] }
: locations.length > 1
? { location: locations }
: {}),
organizer: { "@type": "Organization", name: organizerName },
...(registrationUrl
? {
offers: {
"@type": "Offer",
url: registrationUrl,
price: "0",
priceCurrency: "LYD",
availability: "https://schema.org/InStock",
},
}
: {}),
};
}
export interface ArticleJsonLdInput {
readonly siteUrl: string;
readonly locale: Locale;
readonly headline: string;
/** Path of the article, including the locale prefix. */
readonly pathname: string;
readonly datePublished: string;
readonly dateModified?: string;
readonly description?: string;
readonly image?: string;
readonly authorName?: string;
readonly section?: string;
readonly keywords?: readonly string[];
}
/** Article JSON-LD for a news item, a publication or a knowledge-hub page. */
export function buildArticleJsonLd(input: ArticleJsonLdInput): JsonLd {
const {
siteUrl,
locale,
headline,
pathname,
datePublished,
dateModified,
description,
image,
authorName,
section,
keywords,
} = input;
const url = canonicalUrl(siteUrl, pathname);
return {
"@context": SCHEMA_CONTEXT,
"@type": "Article",
headline,
...(description ? { description } : {}),
...(image ? { image: absoluteUrl(siteUrl, image) } : {}),
datePublished,
dateModified: dateModified ?? datePublished,
inLanguage: INTL_LOCALE[locale],
url,
mainEntityOfPage: { "@type": "WebPage", "@id": url },
author: { "@type": "Organization", name: authorName ?? SITE_NAME },
publisher: {
"@type": "Organization",
name: SITE_NAME,
logo: {
"@type": "ImageObject",
url: absoluteUrl(siteUrl, "/assets/sharp-er-logo.png"),
},
},
...(section ? { articleSection: section } : {}),
...(keywords && keywords.length > 0 ? { keywords: keywords.join(", ") } : {}),
};
}
export interface BreadcrumbItem {
readonly name: string;
/** Path including the locale prefix. */
readonly pathname: string;
}
/** BreadcrumbList JSON-LD. Pass the trail from the home page to this page. */
export function buildBreadcrumbJsonLd(
siteUrl: string,
items: readonly BreadcrumbItem[],
): JsonLd {
return {
"@context": SCHEMA_CONTEXT,
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: canonicalUrl(siteUrl, item.pathname),
})),
};
}