spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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