568 lines
17 KiB
TypeScript
568 lines
17 KiB
TypeScript
/* ============================================================================
|
|
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,
|
|
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.
|
|
*/
|
|
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,
|
|
hrefLang: "x-default",
|
|
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
|
|
},
|
|
];
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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),
|
|
})),
|
|
};
|
|
}
|