SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.1 KB · 100 lines typescript
Raw Blame History
1import { SITE_NAME, SITE_URL, routes } from './site';23/**4 * SEO helpers shared by every page family: title patterns from the product spec and JSON-LD builders.5 * Titles are returned WITHOUT the site suffix when used through the layout template (`%s — CountryAtlas`); use6 * `withSite()` for places that need the full "… | CountryAtlas" string (OG, JSON-LD names).7 */8export const seoTitle = {9  country: (name: string) => `${name} Data, Economy, Population & Statistics`,10  countryTopic: (name: string, topic: string) => `${name} ${topic} — Data & Statistics`,11  indicator: (name: string) => `${name} by Country — Data & Rankings`,12  ranking: (name: string, year: number | string | null | undefined) => (year ? `${name} by Country — ${year} Ranking` : `${name} by Country — Ranking`),13  region: (name: string) => `${name} Data, Countries & Statistics`,14  compare: (names: string[]) => `${names.join(' vs ')} — Country Comparison`,15  story: (title: string) => `${title} — Data Story`,16} as const;1718export function withSite(title: string): string {19  return `${title} | ${SITE_NAME}`;20}2122export function absoluteUrl(path: string): string {23  return path.startsWith('http') ? path : `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`;24}2526type JsonLd = Record<string, unknown>;2728export const jsonLd = {29  website: (): JsonLd => ({30    '@context': 'https://schema.org',31    '@type': 'WebSite',32    name: SITE_NAME,33    url: SITE_URL,34    potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${SITE_URL}${routes.search('{search_term_string}')}`.replace('%7Bsearch_term_string%7D', '{search_term_string}') }, 'query-input': 'required name=search_term_string' },35  }),36  organization: (): JsonLd => ({37    '@context': 'https://schema.org',38    '@type': 'Organization',39    name: SITE_NAME,40    url: SITE_URL,41    logo: `${SITE_URL}/icon.png`,42    email: 'contact@spboucher.ai',43    founder: { '@type': 'Person', name: 'Simon-Pierre Boucher' },44  }),45  breadcrumbs: (items: Array<{ name: string; path: string }>): JsonLd => ({46    '@context': 'https://schema.org',47    '@type': 'BreadcrumbList',48    itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: absoluteUrl(it.path) })),49  }),50  /** schema.org Dataset for an indicator page. */51  dataset: (d: { slug: string; name: string; description?: string | null; unit?: string | null; firstYear?: number | null; lastYear?: number | null; nCountries?: number | null; sources?: Array<{ name: string | null; url: string | null; licence?: string | null }>; modified?: string | null }): JsonLd => ({52    '@context': 'https://schema.org',53    '@type': 'Dataset',54    name: `${d.name} by country`,55    description: d.description ?? `${d.name} for every country, with sources.`,56    url: absoluteUrl(routes.indicator(d.slug)),57    identifier: d.slug,58    isAccessibleForFree: true,59    license: 'https://creativecommons.org/licenses/by/4.0/',60    creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },61    ...(d.modified ? { dateModified: d.modified } : {}),62    ...(d.firstYear && d.lastYear ? { temporalCoverage: `${d.firstYear}/${d.lastYear}` } : {}),63    ...(d.nCountries ? { spatialCoverage: `${d.nCountries} countries and territories` } : {}),64    variableMeasured: d.unit ? { '@type': 'PropertyValue', name: d.name, unitText: d.unit } : d.name,65    distribution: [66      { '@type': 'DataDownload', encodingFormat: 'text/csv', contentUrl: absoluteUrl(routes.indicatorDownload(d.slug, 'csv')) },67      { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: absoluteUrl(routes.indicatorDownload(d.slug, 'json')) },68    ],69    ...(d.sources?.length ? { isBasedOn: d.sources.filter((s) => s.url).map((s) => ({ '@type': 'Dataset', name: s.name ?? undefined, url: s.url })) } : {}),70  }),71  /** schema.org Article for a data story. */72  article: (a: { slug: string; title: string; description: string; published: string; modified?: string | null; image?: string | null }): JsonLd => ({73    '@context': 'https://schema.org',74    '@type': 'Article',75    headline: a.title,76    description: a.description,77    url: absoluteUrl(routes.story(a.slug)),78    datePublished: a.published,79    dateModified: a.modified ?? a.published,80    author: { '@type': 'Person', name: 'Simon-Pierre Boucher' },81    publisher: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL, logo: { '@type': 'ImageObject', url: `${SITE_URL}/icon.png` } },82    ...(a.image ? { image: absoluteUrl(a.image) } : {}),83    isAccessibleForFree: true,84  }),85  /** schema.org Place/Country for a country page. */86  country: (c: { slug: string; name: string; iso3: string; capital?: string | null }): JsonLd => ({87    '@context': 'https://schema.org',88    '@type': 'Country',89    name: c.name,90    identifier: c.iso3,91    url: absoluteUrl(routes.country(c.slug)),92    ...(c.capital ? { containsPlace: { '@type': 'City', name: c.capital } } : {}),93  }),94};9596/** Serialise for a `<script type="application/ld+json">` — escapes `<` so the payload cannot close the tag. */97export function jsonLdString(obj: JsonLd | JsonLd[]): string {98  return JSON.stringify(obj).replace(/</g, '\\u003c');99}100