/* ============================================================================ 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>; 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 `` 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, 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), })), }; }