initial commit
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
/* ============================================================================
|
||||
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 {
|
||||
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 English URL because English is the default locale that bare
|
||||
* "/" negotiates towards.
|
||||
*/
|
||||
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: "en",
|
||||
hrefLang: "x-default",
|
||||
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user