#!/usr/bin/env node // Build a deterministic, static HTML corpus for crawlable pages that should // live outside the SPA catch-all. Inputs are committed repo data only: no // network calls, no env files, and no live secrets. import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { UMAMI_SCRIPT_TAG, writeResearchSection } from './build-research-reports.mjs'; import { COUNTRY_CENTROIDS_PATH, TENSION_PAIRS_PATH, renderCountryRelatedLinks, selectRelatedCountries, } from './country-related-links.mjs'; import { COMPARISONS_CONTENT_VERSION, COMPARISON_PAGES, writeComparisonPages, renderContextualComparisons, } from './build-comparison-pages.mjs'; import { RELATED_READING_PATH, loadRelatedReading, renderRelatedReading } from './related-reading.mjs'; import { blogPostPathsIn, createTargetResolver, loadSearchLinks, searchLinksPath } from './search-links.mjs'; import { placeSearchLinks } from './lib/search-links.mjs'; import { LIVE_PAGE_LINKS_PATH, chokepointLiveLinks, countryLiveLinks, crisisLiveLinks, renderLiveDataLinks } from './live-pages/related-links.mjs'; import { USE_CASE_PAGES, USE_CASES_CONTENT_VERSION, writeUseCasesSection, } from './build-use-cases.mjs'; import { ACCURACY_CONTENT_VERSION, ACCURACY_PAGE_PATH, writeAccuracySection, } from './build-accuracy-page.mjs'; import { buildSourceCatalog, buildSourcePages, renderSourcesIndex, sourceCardAnchors } from './crawlable-sources-page.mjs'; import { COVERAGE_CONTENT_VERSION, COVERAGE_INPUT_PATHS, COVERAGE_PAGE_PATH, loadCoverageCatalog, writeCoverageSection, } from './build-coverage-catalog.mjs'; import { LIVE_TOPIC_PAGES_CONTENT_VERSION, LIVE_TOPIC_PAGES_INPUT_PATHS, writeLiveTopicPages, } from './build-live-topic-pages.mjs'; import { LIVE_TOPIC_PAGES } from './live-pages/index.mjs'; import { CRISIS_REGION_IDS, SITUATION_CONTENT_VERSION, SITUATION_PAGE_INPUT_PATHS, SITUATION_PAGE_PATH, writeSituationPage, } from './build-situation-page.mjs'; import { slugify } from './slugify.mjs'; import { sourceOriginFilterValue } from './source-origin.mjs'; import { attachCoverageToCatalog, FEED_DECLARATION_FILES, loadSourceGeography, scanNamedFeedDeclarations, } from './source-catalog-identity.mjs'; import { activeSourceAttributionEntries, scanUpstreamHosts, sourceAttributionStats, } from './source-attribution.mjs'; import { CHANGELOG_PAGINATION_ROBOTS_CONTENT, INDEXABLE_ROBOTS_CONTENT, } from '../shared/seo-robots.mjs'; import { CII_COUNTRY_CODES } from '../shared/cii-weights.ts'; import { RANKABLE_UNIVERSE_SIZE, getSovereignStatus } from './shared/rankable-universe.mjs'; // Single source with the browser copy: crawlable-live-tools.mjs is what gets // written verbatim to public/tools/live-tools.js, and importing it here is // side-effect-free in Node (its only module-scope statement is a // `typeof document !== 'undefined'` guard). A mirrored copy could not fail. import { instabilityBand, isLegacyConflatedCiiMovementLabel, MAX_FUTURE_SKEW_MS, MAX_LIVE_SNAPSHOT_AGE_MS, parseCiiMovement, } from './crawlable-live-tools.mjs'; // The chokepoint live section and its formatting helpers are shared with // middleware.ts, which renders the same section from a live reading. import { escapeHtml, formatObservedNumber, formatScore, formatScoreNumber, formatStaticDateTime, hasObservedValue, LEGACY_ABSENT_STATUS_NOTE, liveUpdatedMarkup, OBSERVED_EVIDENCE, renderChokepointLiveSection, } from './crawlable-chokepoint-live.mjs'; export { hasObservedValue, LEGACY_ABSENT_STATUS_NOTE }; import { briefCitationGroundingGap, COUNTRY_INDEX_ORIGIN, developmentsHasDatedItem, isBriefSectionHeader, normalizeFrozenDevelopments, parseBriefSections, } from './crawlable-developments.mjs'; // One predicate for the freeze's coverage counters and this build's // tripwire; re-exported so the corpus tests keep their import path. export { developmentsHasDatedItem }; import { CHOKEPOINT_CONTENT, CHOKEPOINT_PAGE_CONTENT_PATH, CHOKEPOINT_SCORE_CONTEXT_ONLY, CHOKEPOINT_SCORE_INPUTS, CHOKEPOINT_REGISTRY_OBSERVED_AT, EIA_OIL_TRANSIT_BASELINES, TRADE_ROUTES_OBSERVED_AT, } from './chokepoint-page-content.mjs'; import { EIA_OIL_TRANSIT_BASELINES_PATH, EIA_OIL_TRANSIT_REFERENCE_YEAR, EIA_OIL_TRANSIT_SOURCE } from './chokepoint-eia-baselines.mjs'; import { buildMicrostateCoverageStoryContent } from './microstate-coverage-stories.mjs'; import { buildUnrankedCountryInventory } from './unranked-country-inventory.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const DEFAULT_ROOT = resolve(__dirname, '..'); const DEFAULT_OUT_DIR = join(DEFAULT_ROOT, 'public'); const DEFAULT_BASE_URL = 'https://www.worldmonitor.app'; const SCHEMA_ORG_CONTEXT_URL = 'https://schema.org'; const RESILIENCE_SNAPSHOT_DIR = 'docs/snapshots'; const RESILIENCE_SNAPSHOT_RE = /^resilience-ranking-(\d{4}-\d{2}-\d{2})\.json$/; const LIVE_PULSE_SNAPSHOT_RE = /^crawlable-live-pulse-(\d{4}-\d{2}-\d{2})\.json$/; // A crisis reference period must be a real calendar month/day, never a sentinel. const OBSERVATION_PERIOD_RE = /^\d{4}-\d{2}(-\d{2})?$/; // The committed pulse is published as "Current signal". Nothing re-runs the // freeze automatically except .github/workflows/crawlable-pulse-refresh.yml, so // bound the age here: a forgotten or failed refresh must red the build rather // than silently republish stale numbers under a current-state heading. // // Sized to clear the WEEKLY refresh cadence with slack: the cron freezes every // Monday, so this leaves ~3 days to merge a refresh PR before the build reds. // It was 45 days against a monthly cron, which let pages headed "Approx. // 24-hour movement" ship on data up to six weeks old — the freshness // overstatement in #7530. The ceiling and the cron are one contract: relaxing // either without the other reopens the gap, and a guard in // tests/crawlable-corpus.test.mjs asserts they still agree. export const MAX_LIVE_PULSE_SNAPSHOT_AGE_DAYS = 10; // The freeze ceiling above is a pipeline fuse. A page that still says // "approximately 24 hours" needs a tighter claim fuse: a three-day-old // reading is inside the 10-day bound and still a false recency claim (#8072, // recurrence of #7530). Keep this at 2 while any generated page uses the // 24-hour recency phrasing from livePulseMovementClaim(). export const MAX_TWENTY_FOUR_HOUR_MOVEMENT_CLAIM_AGE_DAYS = 2; const CII_MOVEMENT_RECENCY_CLAIMS = Object.freeze([ 'over approximately 24 hours', 'Approx. 24-hour movement', 'Approximate 24-hour movement', 'with 24-hour movement unchanged or not yet measured', 'reports approximate 24-hour movement', 'available approximate 24-hour movement', 'available 24-hour movement', ]); const COUNTRY_NAMES_PATH = 'shared/country-names.json'; const COUNTRY_REGIONS_PATH = 'shared/iso2-to-region.json'; const MICROSTATE_TERRITORIES_PATH = 'server/worldmonitor/resilience/v1/cohorts/microstate-territories.json'; const CHOKEPOINT_REGISTRY_PATH = 'src/config/chokepoint-registry.ts'; const TRADE_ROUTES_PATH = 'src/config/trade-routes.ts'; const GLOSSARY_DATA_PATH = 'blog-site/src/data/glossary.ts'; const CHANGELOG_PATH = 'CHANGELOG.md'; const LIVE_TOOLS_SCRIPT_PATH = 'scripts/crawlable-live-tools.mjs'; const COUNTRY_BBOXES_PATH = 'shared/country-bboxes.js'; const CRISIS_REGISTRY_PATH = 'shared/crawlable-crises.json'; // Resolvable provenance for crisis scope citations: the registry is a repo // file, so pages link the versioned blob URL instead of the bare repo path. const CRISIS_REGISTRY_URL = 'https://github.com/koala73/worldmonitor/blob/main/shared/crawlable-crises.json'; const CRISIS_EDITORIAL_PATH = 'scripts/data/crisis-editorial.json'; const RESEARCH_REPORTS_INDEX_PATH = 'shared/research-reports/index.mjs'; const SOURCE_ATTRIBUTION_MANIFEST_PATH = 'shared/source-attribution-manifest.json'; const SOURCE_PAGE_RENDERER_PATH = 'scripts/crawlable-sources-page.mjs'; const SOURCE_ORIGIN_PATH = 'scripts/source-origin.mjs'; const SHARED_PAGE_TEMPLATE_PATH = 'scripts/build-crawlable-corpus.mjs'; export const SOURCE_CATALOG_LASTMOD_PATHS = Object.freeze([ 'scripts/crawlable-sources-search.mjs', 'scripts/source-catalog-identity.mjs', 'shared/source-geography.json', 'shared/publisher-families.js', CRISIS_REGISTRY_PATH, ...FEED_DECLARATION_FILES, ]); export const CHOKEPOINT_PAGE_LASTMOD_PATHS = Object.freeze([ CHOKEPOINT_REGISTRY_PATH, TRADE_ROUTES_PATH, CHOKEPOINT_PAGE_CONTENT_PATH, EIA_OIL_TRANSIT_BASELINES_PATH, LIVE_PAGE_LINKS_PATH, searchLinksPath('chokepoints'), ]); /** Compare pages interpolate live provider and chokepoint counts (#7744). */ export const COMPARISON_PAGE_LASTMOD_PATHS = Object.freeze([ 'scripts/build-comparison-pages.mjs', 'scripts/comparison-page-narratives.mjs', 'scripts/comparison-shortlist.mjs', 'shared/product-facts.generated.json', 'shared/product-catalog.generated.json', 'public/.well-known/mcp/server-card.json', SOURCE_ATTRIBUTION_MANIFEST_PATH, CHOKEPOINT_REGISTRY_PATH, RELATED_READING_PATH, ]); // Last substantive change to the shared HTML template/content language. Data // families take the later of this version and their own committed source date, // so template changes are reflected without pretending every deploy is fresh. export const CORPUS_GENERATOR_CONTENT_VERSION = '2026-09-01'; export const COUNTRY_PAGE_CONTENT_VERSION = '2026-10-18'; export const CII_COUNTRY_PAGE_CONTENT_VERSION = '2026-09-12'; // Exported so the #7533 guard test can recompute every family clock without // re-implementing the version constants themselves. export const COUNTRIES_INDEX_CONTENT_VERSION = '2026-09-03'; export const CII_RANKING_PAGE_CONTENT_VERSION = '2026-09-12'; // Public ranking / confidence gates. Keep aligned with // server/worldmonitor/resilience/v1/_shared.ts and // docs/methodology/country-resilience-index.mdx. export const HEADLINE_RANKING_MIN_COVERAGE = 0.65; export const HEADLINE_RANKING_MIN_POPULATION = 200_000; export const HEADLINE_RANKING_HIGH_COVERAGE = 0.85; export const LOW_CONFIDENCE_MIN_COVERAGE = 0.55; export const LOW_CONFIDENCE_MAX_IMPUTATION = 0.40; export const RANKING_ELIGIBILITY_CLAUSE = `Ranking requires coverage of at least ${Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100)}%, no low-confidence flag, and either a population of at least ${HEADLINE_RANKING_MIN_POPULATION.toLocaleString('en-US')} or coverage of at least ${Math.round(HEADLINE_RANKING_HIGH_COVERAGE * 100)}%. Low confidence means coverage falls below ${Math.round(LOW_CONFIDENCE_MIN_COVERAGE * 100)}% or imputation share exceeds ${Math.round(LOW_CONFIDENCE_MAX_IMPUTATION * 100)}%.`; const RETIRED_DIMENSION_IDS = new Set(['fuelStockDays', 'reserveAdequacy']); const UNRANKED_INVENTORY_LIMIT = 12; const AVAILABLE_EVIDENCE_LIMIT = 6; // Coverage a dimension needs before a page will call it a supported reading. The // inventory labels anything with a usable series "observed", so a dimension // under this floor is observed and absent from the supported readings at once -- // which reads as a contradiction unless the page states the floor (#7609). // This floor is published on country pages, so keep it aligned with // docs/methodology/country-resilience-index.mdx#supported-readings-on-unranked-country-pages. export const SUPPORTED_READING_MIN_COVERAGE = 0.5; export const CHOKEPOINT_PAGE_CONTENT_VERSION = '2026-10-18'; const SOURCES_PAGE_CONTENT_VERSION = '2026-09-10'; // Dataset schema versions stamp Dataset JSON-LD shape changes, per family. They // must NOT fold into every family's sitemap/page lastmod — that made ~90% of main // sitemap entries share one schema-bump date (#7382). A family's stamp advances // only when a schema change lands in ITS payload, so one shared constant cannot // serve them: bumping it for a crisis-only change advertises every untouched // chokepoint dataset as modified. Country pages are absent by design — their // dateModified is pinned to the snapshot capturedAt as a truthful freshness // contract (#7391), so their recrawl signal is COUNTRY_PAGE_CONTENT_VERSION. export const DATASET_SCHEMA_CONTENT_VERSION = { chokepoint: '2026-09-03', crisis: '2026-09-03', tools: '2026-09-03', }; export const CRISIS_PAGE_CONTENT_VERSION = '2026-10-24'; // TOOLS_PAGE_CONTENT_VERSION and RESEARCH_PAGE_CONTENT_VERSION are exported // for the same reason as the constants above: the #7533 guard recomputes // their families' clocks from the real values. export const TOOLS_PAGE_CONTENT_VERSION = '2026-09-10'; export const RESEARCH_PAGE_CONTENT_VERSION = '2026-09-10'; const DATASET_LICENSE = { '@type': 'CreativeWork', name: 'World Monitor Terms of Service (27 July 2026)', url: 'https://www.worldmonitor.app/docs/terms', }; const CII_INDEX_DATASET_DOWNLOAD = 'cii-ranking.json'; const COUNTRIES_INDEX_DATASET_DOWNLOAD = 'resilience-ranking.json'; const COUNTRY_DATASET_DOWNLOAD = 'resilience.json'; const COUNTRY_CII_DATASET_DOWNLOAD = 'cii.json'; const CHOKEPOINTS_INDEX_DATASET_DOWNLOAD = 'status.json'; const CHOKEPOINT_DATASET_DOWNLOAD = 'reference.json'; const CRISIS_DATASET_DOWNLOAD = 'tracker.json'; const CONVERGENCE_DATASET_DOWNLOAD = 'reference.json'; const ACCURACY_DATASET_DOWNLOAD = 'scorecard.json'; const ACCURACY_PAGE_SOURCE_PATH = 'scripts/build-accuracy-page.mjs'; const DATA_CATALOG_FRAGMENT = '#data-catalog'; // Role filler for Dataset.creator / DataCatalog.publisher. The `@id` folds every // occurrence into the canonical Organization declared on welcome.html (#7459b), // but the `@type` + `name` must stay: structured-data parsers resolve `@id` within // ONE document, and no generated corpus page declares that node. A bare `@id` here // is an unresolvable stub for exactly the naive extractors #7459b set out to serve. // This is the same typed-stub-plus-@id shape blog-site/src/layouts/BlogPost.astro // already uses for the canonical Person. export const WORLD_MONITOR_ORG = Object.freeze({ '@id': 'https://www.worldmonitor.app/#organization', '@type': 'Organization', name: 'World Monitor', url: 'https://www.worldmonitor.app/', }); export const WEBSITE_ID = 'https://www.worldmonitor.app/#website'; const DEFAULT_SPEAKABLE = Object.freeze({ '@type': 'SpeakableSpecification', cssSelector: ['h1', '.lede'], }); const PAGE_TYPES_WITH_SPEAKABLE = new Set([ 'WebPage', 'CollectionPage', 'ItemPage', 'Article', 'Report', 'BlogPosting', ]); const PAGE_TYPES_WITH_WEBPAGE_ID = new Set([ 'WebPage', 'CollectionPage', 'ItemPage', ]); // Every top-level node that STANDS FOR the page — whatever type it declares — // carries attribution (#7980). Deliberately wider than the page-shaped types // above, because a page states its body under more than one type: the two // live-tool pages state only a WebApplication, and 227 pages carry a FAQPage // (a WebPage subtype) or a HowTo sibling alongside their WebPage. An // unattributed sibling is the same gap as an unattributed score — it is a // rich-result surface of its own — so the set covers all of them, not just // the node that happens to hold the canonical page identity. // // Nodes that describe something OTHER than the page stay out: Dataset and // DataCatalog carry `creator`/`publisher` instead (#7459b), and BreadcrumbList // and ItemList are navigation, not authored claims. export const PAGE_TYPES_WITH_ATTRIBUTION = new Set([ ...PAGE_TYPES_WITH_SPEAKABLE, 'WebApplication', 'FAQPage', 'HowTo', ]); // Approximate monitoring footprint around each registry centroid (degrees). // Registry entries are points; GeoShape.box lets crawlers treat the waterway as // a corridor envelope rather than a zero-area pin. const CHOKEPOINT_GEO_HALF_SPAN_DEG = 0.75; const CHANGELOG_PAGE_SIZE = 2; const MAX_TOOL_LATITUDE_SPAN = 45; const MAX_TOOL_LONGITUDE_SPAN = 60; const META_DESCRIPTION_MIN = 155; const META_DESCRIPTION_MAX = 160; const CRISIS_DESCRIPTION_MIN = 120; // Search-friendly common names for ISO2 codes whose source identities use a // formal long form, truncated token ("Uk", "Korea Rep", "Lao Pdr"), or code. // Legacy source-name slugs are redirected to the common-name canonical route. const COUNTRY_DISPLAY_NAMES = { AE: 'United Arab Emirates', AG: 'Antigua and Barbuda', BA: 'Bosnia and Herzegovina', CD: 'DR Congo', CG: 'Republic of the Congo', CI: 'Côte d’Ivoire', EH: 'Western Sahara', FM: 'Micronesia', GB: 'United Kingdom', HK: 'Hong Kong', KG: 'Kyrgyzstan', KN: 'Saint Kitts and Nevis', KP: 'North Korea', KR: 'South Korea', LA: 'Laos', LC: 'Saint Lucia', MM: 'Myanmar', MO: 'Macau', NC: 'New Caledonia', PR: 'Puerto Rico', PS: 'Palestine', RS: 'Serbia', SK: 'Slovakia', ST: 'São Tomé and Príncipe', TL: 'Timor-Leste', TR: 'Turkey', TT: 'Trinidad and Tobago', VC: 'Saint Vincent and the Grenadines', VE: 'Venezuela', XK: 'Kosovo', }; function displayCountryName(code, fallbackName) { return COUNTRY_DISPLAY_NAMES[code] || fallbackName || code; } // Exported so a test can hold .gitignore to this list. Every directory here is // deleted and rewritten on each build, so one missing an ignore rule shows up // as permanent untracked noise and can be committed by a stray `git add -A`. export const GENERATED_DIRS = [ 'country-instability-index', 'countries', 'chokepoints', 'compare', 'crises', 'tools', 'reference/changelog', 'research', 'sources', 'use-cases', 'accuracy', 'monitor-the-situation', ]; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; function repoPath(rootDir, relativePath) { // resolve() (not join()) so an absolute injected snapshot path — the #7533 // override reads temp-dir files — is used as-is while relative paths still // anchor to rootDir exactly as join() did. return resolve(rootDir, relativePath); } function readText(rootDir, relativePath) { return readFileSync(repoPath(rootDir, relativePath), 'utf8'); } function readJson(rootDir, relativePath) { return JSON.parse(readText(rootDir, relativePath)); } /** * The one public wording for resilience coverage: the ranked count is always * stated against the universe it is drawn from, never on its own and never as * the universe alone. */ export function resilienceCoveragePhrase({ ranked, universe }) { return `structural resilience ranked for ${ranked} of ${universe} countries`; } /** Matches every statement written by resilienceCoveragePhrase, whatever its numbers. */ export const RESILIENCE_COVERAGE_PATTERN = /structural resilience ranked for \d+ of \d+ countries/g; /** * The single definition of resilience coverage. A snapshot lists every * country in the rankable universe as either a ranked item or a greyed-out * row, so ranked is the item count and universe is items plus greyed-out. * The universe is cross-checked against the committed whitelist so a stale * snapshot cannot publish the wrong total. The rankable universe (196) and the * published ranking (170) are different facts and third-party coverage has * conflated them, so public copy states both together. */ export function resilienceCoverageOf(snapshot) { if (!Array.isArray(snapshot?.items) || snapshot.items.length === 0) { throw new Error('the published resilience ranking snapshot must contain ranked items'); } const ranked = snapshot.items.length; const greyedOut = Array.isArray(snapshot.greyedOut) ? snapshot.greyedOut.length : 0; const universe = ranked + greyedOut; if (universe !== RANKABLE_UNIVERSE_SIZE) { throw new Error( `the published resilience snapshot covers ${universe} countries (${ranked} ranked + ${greyedOut} greyed out), ` + `but the rankable universe has ${RANKABLE_UNIVERSE_SIZE}`, ); } return { ranked, universe }; } /** Coverage of the newest published resilience snapshot, with its capture date. */ export function publishedRankedCountries(rootDir = DEFAULT_ROOT) { const snapshot = readJson(rootDir, resolveLatestResilienceSnapshotPath(rootDir)); return { ...resilienceCoverageOf(snapshot), capturedAt: snapshot.capturedAt }; } export function resolveLatestResilienceSnapshotPath(rootDir = DEFAULT_ROOT) { const snapshotDir = repoPath(rootDir, RESILIENCE_SNAPSHOT_DIR); const candidates = readdirSync(snapshotDir) .map((filename) => ({ filename, match: filename.match(RESILIENCE_SNAPSHOT_RE) })) .filter(({ match }) => match) .sort((a, b) => b.match[1].localeCompare(a.match[1], 'en-US')); if (candidates.length === 0) { throw new Error(`No canonical resilience ranking snapshot found in ${RESILIENCE_SNAPSHOT_DIR}`); } const [{ filename, match }] = candidates; const relativePath = join(RESILIENCE_SNAPSHOT_DIR, filename); const snapshot = readJson(rootDir, relativePath); if (snapshot.capturedAt !== match[1]) { throw new Error( `${relativePath} filename date ${match[1]} does not match capturedAt ${snapshot.capturedAt}`, ); } return relativePath; } export function livePulseSnapshotAgeDays(capturedAt, now = Date.now()) { const capturedAtMs = Date.parse(`${String(capturedAt || '').slice(0, 10)}T00:00:00Z`); return Number.isFinite(capturedAtMs) ? (now - capturedAtMs) / 86_400_000 : Number.POSITIVE_INFINITY; } export function livePulseMovementClaim(ageDays) { const recencyOk = Number.isFinite(ageDays) && ageDays <= MAX_TWENTY_FOUR_HOUR_MOVEMENT_CLAIM_AGE_DAYS; if (recencyOk) { return { recencyOk: true, intervalPhrase: 'over approximately 24 hours', metricLabel: 'Approx. 24-hour movement', propertyName: 'Approximate 24-hour movement', rankingReports: 'reports approximate 24-hour movement when available', metaStable: 'with 24-hour movement unchanged or not yet measured', availableMovement: 'available 24-hour movement', availableApproximateMovement: 'available approximate 24-hour movement', }; } return { recencyOk: false, intervalPhrase: 'over a 24-hour comparison window', metricLabel: '24-hour comparison-window movement', propertyName: '24-hour comparison-window movement', rankingReports: 'reports 24-hour comparison-window movement when available', metaStable: 'with 24-hour comparison-window movement unchanged or not yet measured', availableMovement: 'available 24-hour comparison-window movement', availableApproximateMovement: 'available 24-hour comparison-window movement', }; } export function livePulseMovementClaimLastmod(capturedAt, now = Date.now()) { const ageDays = livePulseSnapshotAgeDays(capturedAt, now); if (!(ageDays > MAX_TWENTY_FOUR_HOUR_MOVEMENT_CLAIM_AGE_DAYS)) return null; const capturedAtMs = Date.parse(`${String(capturedAt || '').slice(0, 10)}T00:00:00Z`); if (!Number.isFinite(capturedAtMs)) return null; return new Date( capturedAtMs + MAX_TWENTY_FOUR_HOUR_MOVEMENT_CLAIM_AGE_DAYS * 86_400_000, ).toISOString().slice(0, 10); } export function assertLivePulseMovementClaim(html, { pagePath, ageDays }) { if (!(ageDays > MAX_TWENTY_FOUR_HOUR_MOVEMENT_CLAIM_AGE_DAYS)) return; const haystack = String(html || ''); for (const claim of CII_MOVEMENT_RECENCY_CLAIMS) { if (haystack.includes(claim)) { throw new Error( `${pagePath} claims "${claim}" against a ${Math.round(ageDays)}-day-old live-pulse snapshot; ` + 'derive the movement phrase from snapshot age or freeze a current pulse', ); } } } export function resolveLatestLivePulseSnapshotPath(rootDir = DEFAULT_ROOT) { const snapshotDir = repoPath(rootDir, RESILIENCE_SNAPSHOT_DIR); const candidates = readdirSync(snapshotDir) .map((filename) => ({ filename, match: filename.match(LIVE_PULSE_SNAPSHOT_RE) })) .filter(({ match }) => match) .sort((a, b) => b.match[1].localeCompare(a.match[1], 'en-US')); if (candidates.length === 0) { throw new Error(`No crawlable live-pulse snapshot found in ${RESILIENCE_SNAPSHOT_DIR}`); } const [{ filename, match }] = candidates; const relativePath = join(RESILIENCE_SNAPSHOT_DIR, filename); const snapshot = readJson(rootDir, relativePath); if (snapshot.capturedAt !== match[1]) { throw new Error( `${relativePath} filename date ${match[1]} does not match capturedAt ${snapshot.capturedAt}`, ); } if (!snapshot.countries || !snapshot.chokepoints || !snapshot.crises || !snapshot.signalConvergence) { throw new Error(`${relativePath} is missing required live-pulse sections`); } const ageDays = livePulseSnapshotAgeDays(snapshot.capturedAt); if (ageDays > MAX_LIVE_PULSE_SNAPSHOT_AGE_DAYS) { throw new Error( `${relativePath} is ${Math.round(ageDays)} days old (max ${MAX_LIVE_PULSE_SNAPSHOT_AGE_DAYS}); ` + 'run `npm run freeze:crawlable-live-pulse` to republish current values', ); } return relativePath; } // parseCiiMovement returns movementText null when the pulse supports no // movement claim (no earlier reading, or a legacy label that conflated "no // change" with "no data"). Sentences then drop the clause instead of printing // a placeholder. function ciiMovementClause(ciiEntry) { return ciiEntry?.movementText ? `, ${ciiEntry.movementText}` : ''; } // Reader-facing movement cell. Legacy labels cannot say which of their two // meanings applied, so they render as unavailable rather than as either one. function ciiMovementDisplayLabel(trend) { const label = String(trend || '').trim(); return !label || isLegacyConflatedCiiMovementLabel(label) ? 'Unavailable' : label; } function isCanonicalIsoInstant(value) { if (typeof value !== 'string') return false; const timestamp = Date.parse(value); return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value; } function pulseDateOnly(asOf, fallback) { if (typeof asOf === 'string' && /^\d{4}-\d{2}-\d{2}/.test(asOf)) { return asOf.slice(0, 10); } return fallback; } export function buildCiiRankingEntries(countries, livePulse, { now = Date.now() } = {}) { const countryByCode = new Map(countries.map((country) => [country.code, country])); const methodologyVersions = new Set(); const entries = []; const ageDays = livePulseSnapshotAgeDays(livePulse?.capturedAt, now); const movementClaim = livePulseMovementClaim(ageDays); for (const [code, pulse] of Object.entries(livePulse?.countries || {})) { if (pulse?.partial === true || pulse?.score == null || pulse.score === '') continue; const country = countryByCode.get(code); if (!country) throw new Error(`CII pulse contains unknown country ${code}`); const score = Number(pulse.score); const band = instabilityBand(score); if (!Number.isFinite(score) || band === null || band !== pulse.band) { throw new Error(`CII pulse score or band is invalid for ${code}`); } const asOf = String(pulse.asOf || '').trim(); if (!isCanonicalIsoInstant(asOf)) { throw new Error(`CII pulse timestamp is invalid for ${code}`); } const methodologyVersion = String(pulse.methodologyVersion || '').trim(); if (!methodologyVersion) throw new Error(`CII pulse methodology is unavailable for ${code}`); methodologyVersions.add(methodologyVersion); entries.push({ country, code, score, band, trend: String(pulse.trend || '').trim(), asOf, methodologyVersion, ageDays, movementClaim, ...parseCiiMovement(pulse.trend, { intervalPhrase: movementClaim.intervalPhrase }), }); } entries.sort((left, right) => right.score - left.score || left.country.name.localeCompare(right.country.name, 'en-US')); const entryCodes = new Set(entries.map((entry) => entry.code)); const expectedCodes = new Set(CII_COUNTRY_CODES); const missingCodes = CII_COUNTRY_CODES.filter((code) => !entryCodes.has(code)); const unexpectedCodes = [...entryCodes].filter((code) => !expectedCodes.has(code)); if (missingCodes.length > 0 || unexpectedCodes.length > 0) { throw new Error( `CII ranking country set is invalid: missing ${missingCodes.join(', ') || 'none'}; ` + `unexpected ${unexpectedCodes.join(', ') || 'none'}`, ); } if (methodologyVersions.size !== 1) { throw new Error('CII ranking mixes methodology versions'); } return { entries, byCode: new Map(entries.map((entry) => [entry.code, entry])), methodologyVersion: entries[0].methodologyVersion, updatedAt: entries.reduce( (latest, entry) => Date.parse(entry.asOf) > Date.parse(latest) ? entry.asOf : latest, entries[0].asOf, ), ageDays, movementClaim, }; } export function laterDate(...values) { return values .filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '')) .sort() .at(-1) ?? null; } function isCanonicalCalendarDate(value) { if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const timestamp = Date.parse(`${value}T00:00:00Z`); return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; } /** Observation window for chokepoint Dataset temporalCoverage and table stamps. * Git lastmod wins when history is present; committed dates keep Docker * corpus builds (no `.git`) from publishing capturedAt: null. */ export function resolveChokepointObservation({ registryGitLastmod = null, tradeRoutesGitLastmod = null, } = {}) { return { capturedAt: laterDate( registryGitLastmod, tradeRoutesGitLastmod, CHOKEPOINT_REGISTRY_OBSERVED_AT, TRADE_ROUTES_OBSERVED_AT, ), volumeObservedAt: laterDate( tradeRoutesGitLastmod, TRADE_ROUTES_OBSERVED_AT, ), }; } export function sourcePageLastmod({ manifestLastmod, rendererLastmod, originLastmod, catalogInputLastmods = [], sharedTemplateLastmod, snapshotDate, generatorContentVersion = CORPUS_GENERATOR_CONTENT_VERSION, pageContentVersion = SOURCES_PAGE_CONTENT_VERSION, }) { return laterDate( manifestLastmod, rendererLastmod, originLastmod, ...catalogInputLastmods, sharedTemplateLastmod, snapshotDate, generatorContentVersion, pageContentVersion, ); } export function comparisonPageLastmod({ contentVersion = COMPARISONS_CONTENT_VERSION, pathLastmods = [], snapshotDate, } = {}) { return laterDate(contentVersion, ...pathLastmods, snapshotDate); } function normalizeBaseUrl(baseUrl) { return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); } function absoluteUrl(baseUrl, pathname) { return `${normalizeBaseUrl(baseUrl)}${pathname}`; } // The dashboard view each audience hub hands off to, with that job's layers on. // Layer keys must exist in src/config/map-layer-definitions.ts (the corpus test checks). const HUB_DASHBOARD_VIEWS = { crises: { path: '/dashboard?layers=conflicts,hotspots,military,outages,natural&timeRange=7d', label: 'Open conflict and crisis layers on the live map →', }, chokepoints: { path: '/dashboard?layers=ais,waterways,tradeRoutes,hotspots,sanctions,cables&timeRange=24h', label: 'Open shipping and chokepoint layers on the live map →', }, // ciiChoropleth shades countries on desktop only (the mobile SVG map skips it); // the label promises country risk, which every renderer gives via the country brief. countries: { path: '/dashboard?layers=ciiChoropleth,hotspots,conflicts&timeRange=7d', label: 'Open country risk on the live map →', }, }; function hubDashboardCta(hub, baseUrl) { const view = HUB_DASHBOARD_VIEWS[hub]; return `${escapeHtml(view.label)}`; } function dataCatalogId(baseUrl) { return `${normalizeBaseUrl(baseUrl)}/${DATA_CATALOG_FRAGMENT}`; } export function dataCatalogLd(baseUrl) { return { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'DataCatalog', '@id': dataCatalogId(baseUrl), name: 'World Monitor open data catalog', description: 'Crawlable World Monitor datasets for country instability, country resilience, maritime chokepoint reference, bounded crisis trackers, and research snapshots, with static downloads generated from committed data.', url: `${normalizeBaseUrl(baseUrl)}/`, publisher: { ...WORLD_MONITOR_ORG }, creator: { ...WORLD_MONITOR_ORG }, inLanguage: 'en-US', isAccessibleForFree: true, license: DATASET_LICENSE, }; } function includedInDataCatalog(baseUrl) { return { '@type': 'DataCatalog', '@id': dataCatalogId(baseUrl), name: 'World Monitor open data catalog', }; } function dataDownload(contentUrl, encodingFormat = 'application/json') { return { '@type': 'DataDownload', encodingFormat, contentUrl, }; } // The daily archive is licensed apart from the corpus downloads (CC BY 4.0, not // the Terms of Service) and is served by api/open-data-chokepoints.js, so its // files are never written by this build. tests/crawlable-corpus.test.mjs pins // these values to scripts/build-open-data-chokepoints.mjs. export const CHOKEPOINT_OPEN_DATA = Object.freeze({ identifier: 'worldmonitor-chokepoint-status', basePath: '/data/v1/chokepoints/', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/', }); function chokepointOpenDataLd(baseUrl, chokepointCount) { const file = (name) => absoluteUrl(baseUrl, `${CHOKEPOINT_OPEN_DATA.basePath}${name}`); return { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'Dataset', '@id': `${absoluteUrl(baseUrl, '/chokepoints/')}#daily-archive`, name: 'World Monitor chokepoint status daily archive', description: `One snapshot per day of the ${chokepointCount} maritime chokepoints World Monitor tracks: disruption score, status, navigational warning and AIS disruption counts, AIS congestion, transit counts and flow ratio. Published as JSON and CSV with a JSON Schema and a datasheet, free to reuse with attribution, no key required.`, url: file('README.md'), identifier: CHOKEPOINT_OPEN_DATA.identifier, keywords: ['maritime chokepoints', 'shipping disruption', 'open data', 'daily time series', 'AIS congestion'], creator: { ...WORLD_MONITOR_ORG }, license: CHOKEPOINT_OPEN_DATA.licenseUrl, spatialCoverage: 'Worldwide', isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), measurementTechnique: 'World Monitor chokepoint disruption scoring methodology', variableMeasured: [ { '@type': 'PropertyValue', name: 'Disruption score', minValue: 0, maxValue: 100, unitText: 'index points' }, { '@type': 'PropertyValue', name: 'Status' }, { '@type': 'PropertyValue', name: 'AIS congestion' }, { '@type': 'PropertyValue', name: 'Flow ratio', minValue: 0, maxValue: 1.5 }, { '@type': 'PropertyValue', name: 'Daily transit count' }, ], distribution: [ dataDownload(file('latest.json')), dataDownload(file('latest.csv'), 'text/csv'), ], }; } const OBSERVATION_COVERAGE_RE = /^\d{4}-\d{2}-\d{2}(?:\/\d{4}-\d{2}-\d{2})?$/; /** Dataset temporalCoverage from a committed observation date or closed interval. * Omit when the artifact has no build-time window. Never pass page lastmod. */ export function datasetTemporalCoverage(observationInterval) { if (typeof observationInterval !== 'string') return undefined; const trimmed = observationInterval.trim(); return OBSERVATION_COVERAGE_RE.test(trimmed) ? trimmed : undefined; } export function datasetObservationCoverage(observedAtValues) { const dates = [...new Set( observedAtValues .map((observedAt) => pulseDateOnly(observedAt, null)) .filter(Boolean), )].sort(); if (dates.length === 0) return undefined; return datasetTemporalCoverage(dates.length === 1 ? dates[0] : `${dates[0]}/${dates.at(-1)}`); } function datasetDownloadHref(pagePath, filename) { return `${pagePath}${filename}`; } function datasetDownloadFile(pagePath, filename) { return datasetDownloadHref(pagePath, filename).replace(/^\/+/, ''); } function stableJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } export function countryDatasetDownload(country, { capturedAt, methodologyFormula, rankedCount, snapshotPath, developments = null, }) { const scorePublished = country.headlineEligible !== false; return stableJson({ dataset: 'country-resilience-snapshot', countryCode: country.code, countryName: country.name, slug: country.slug, rank: scorePublished ? country.rank : null, overallScore: scorePublished ? country.overallScore : null, dimensionCoverage: country.dimensionCoverage, confidence: country.lowConfidence ? 'low' : 'standard', level: scorePublished ? country.level : 'unpublished', sourceStatus: country.sourceStatus, headlineEligible: country.headlineEligible, capturedAt, methodologyFormula, rankedCount, source: snapshotPath, license: DATASET_LICENSE.url, // Frozen recent developments (#7615): dated, attributed headlines, the // intel brief with its grounding sources, and timeline events. Null when the // snapshot captured nothing for this country — the post-enrichment input // for the residual regional-hub consolidation decision. developments: developmentsHasDatedItem(developments) ? developments : null, }); } function countryCiiDatasetDownload(country, ciiEntry, { capturedAt, snapshotPath }) { return stableJson({ dataset: 'country-instability-index', countryCode: country.code, countryName: country.name, methodologyVersion: ciiEntry.methodologyVersion, score: ciiEntry.score, approximate24HourMovement: ciiEntry.change24h, instabilityLevel: ciiEntry.band, observedAt: ciiEntry.asOf, capturedAt, source: snapshotPath, license: DATASET_LICENSE.url, }); } function countriesIndexDatasetDownload(countries, { capturedAt, snapshotPath, developmentsByCode = null }) { return stableJson({ dataset: 'country-resilience-ranking', capturedAt, source: snapshotPath, license: DATASET_LICENSE.url, countries: countries.map((country) => ({ code: country.code, name: country.name, // Whether the country page carries a dated development (#7748): the // enrichment tail is otherwise invisible to an agent reading the index. ...(developmentsByCode ? { hasDevelopments: developmentsByCode.has(country.code) } : {}), rank: country.headlineEligible === false ? null : country.rank, overallScore: country.headlineEligible === false ? null : country.overallScore, dimensionCoverage: country.dimensionCoverage, confidence: country.lowConfidence ? 'low' : 'standard', level: country.headlineEligible === false ? 'unpublished' : country.level, })), }); } function chokepointDatasetDownload(chokepoint, { tradeRoutesById, capturedAt = null, volumeObservedAt = null, }) { const content = CHOKEPOINT_CONTENT[chokepoint.id] || {}; const modelledTradeRoutes = chokepoint.routeIds .map((id) => tradeRoutesById.get(id)) .filter(Boolean) .map((route) => ({ id: route.id, name: route.name, volumeDesc: route.volumeDesc || null, category: route.category || null, })); return stableJson({ dataset: 'chokepoint-reference', id: chokepoint.id, displayName: chokepoint.displayName, slug: chokepoint.slug, lat: Number.isFinite(chokepoint.lat) ? chokepoint.lat : null, lon: Number.isFinite(chokepoint.lon) ? chokepoint.lon : null, region: content.region || null, shockModelSupported: chokepoint.shockModelSupported, modelledTradeRoutes, capturedAt: capturedAt || null, volumeObservedAt: volumeObservedAt || capturedAt || null, source: [CHOKEPOINT_REGISTRY_PATH, TRADE_ROUTES_PATH], license: DATASET_LICENSE.url, }); } function ciiIndexDatasetDownload(ciiRanking, { capturedAt, snapshotPath }) { return stableJson({ dataset: 'country-instability-index', methodologyVersion: ciiRanking.methodologyVersion, capturedAt, source: snapshotPath, license: DATASET_LICENSE.url, countries: ciiRanking.entries.map((entry) => ({ code: entry.code, name: entry.country.name, score: entry.score, approximate24HourMovement: entry.change24h, instabilityLevel: entry.band, observedAt: entry.asOf, })), }); } function chokepointsIndexDatasetDownload(chokepointHubRows, { capturedAt, snapshotPath }) { return stableJson({ dataset: 'maritime-chokepoint-status', capturedAt, source: snapshotPath, license: DATASET_LICENSE.url, chokepoints: chokepointHubRows.map((row) => ({ id: row.chokepoint.id, name: row.chokepoint.displayName, slug: row.chokepoint.slug, region: row.region, disruptionScore: row.score, status: row.status, aisCongestion: row.congestion, aisSnapshotAvailable: row.aisSnapshotAvailable, observedAt: row.asOf, })), }); } function sumRows(rows, key) { return rows.reduce((total, row) => total + (Number.isFinite(row?.[key]) ? row[key] : 0), 0); } /** Numeric crisis totals for the machine-readable download, or nulls when withheld. */ function pulseTotals(pulse) { const rows = Array.isArray(pulse.rows) ? pulse.rows : []; if (pulse.eventsTotal === null || rows.length === 0) { return { eventsTotal: null, fatalities: null, politicalViolenceEvents: null }; } return { eventsTotal: sumRows(rows, 'events'), fatalities: sumRows(rows, 'fatalities'), politicalViolenceEvents: sumRows(rows, 'political'), }; } function crisisDatasetDownload(crisis, pulse = null, numericTotals = null) { return stableJson({ dataset: 'crisis-tracker', slug: crisis.slug, title: crisis.title, shortTitle: crisis.shortTitle, description: crisis.description, overview: crisis.overview, coverage: crisis.coverage, source: CRISIS_REGISTRY_PATH, license: DATASET_LICENSE.url, ...(pulse ? { maintainedPulse: { state: pulse.state, // The pulse carries Intl-formatted display strings ("9,824"); a Dataset // The build loop shares numeric totals with the page schema. The pulse // carries Intl-formatted display strings, so these values stay // machine-readable; `eventsTotal === null` remains the marker for // "reference periods differ, combined total withheld". ...numericTotals, referencePeriod: pulse.referencePeriod, asOf: pulse.asOf, missingCountries: pulse.missingCountries, rows: pulse.rows, }, } : {}), }); } function geoShapeBox(south, west, north, east) { const round = (value) => Math.round(Number(value) * 1000) / 1000; return { '@type': 'GeoShape', box: `${round(south)} ${round(west)} ${round(north)} ${round(east)}`, }; } function chokepointGeoShape(lat, lon, halfSpan = CHOKEPOINT_GEO_HALF_SPAN_DEG) { return geoShapeBox(lat - halfSpan, lon - halfSpan, lat + halfSpan, lon + halfSpan); } // Google's Dataset parser accepts only Text or a literal Place here — the Country // subtype is rejected as "Invalid object type for field spatialCoverage". function countrySpatialCoverage(country, bbox) { const place = { '@type': 'Place', name: country.name, identifier: country.code, }; if (Array.isArray(bbox) && bbox.length === 4 && bbox.every((value) => Number.isFinite(Number(value)))) { const [south, west, north, east] = bbox.map(Number); place.geo = west > east ? [geoShapeBox(south, west, north, 180), geoShapeBox(south, -180, north, east)] : geoShapeBox(south, west, north, east); } return place; } function propertyValue(name, value) { return { '@type': 'PropertyValue', name, value, }; } const OG_IMAGE_PATH = '/favico/og-image.png'; const OG_IMAGE_ALT = 'World Monitor — real-time global intelligence dashboard with live markets, geopolitical data, and infrastructure monitoring'; function escapeJsonScript(value) { return JSON.stringify(value).replace(/ part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function reverseCountryNames(forward) { const reverse = new Map(); for (const [name, code] of Object.entries(forward || {})) { const iso2 = String(code || '').toUpperCase(); if (!/^[A-Z]{2}$/.test(iso2) || reverse.has(iso2)) continue; reverse.set(iso2, titleCaseName(name)); } return reverse; } function prettyDate(isoDate) { const match = String(isoDate || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) return String(isoDate || ''); const [, year, month, day] = match; return `${MONTHS[Number(month) - 1]} ${Number(day)}, ${year}`; } function timeMarkup(isoDate, label = prettyDate(isoDate)) { if (typeof isoDate !== 'string' || !/^\d{4}(-\d{2}(-\d{2})?)?$/.test(isoDate)) { return escapeHtml(label); } return ``; } function formatPercent(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return 'not available'; return `${Math.round(numeric * 100)}%`; } function dimensionScoreEvidence(dimension) { return { coverage: dimension.coverage, evidenceState: dimension.imputationClass, }; } function hasObservedDimensionScore(dimension) { return hasObservedValue(dimension.score, dimensionScoreEvidence(dimension)); } function domainScoreEvidence(domain) { return { coverage: domain.dimensions.some(hasObservedDimensionScore) }; } // Weakest/strongest claims may only rank entries that actually have a reading. // A withheld pillar or domain is not the weakest one, it is no reading at all -- // ranking it would name it in prose and then print an em dash for its score. function observedPillarsOf(pillars) { return pillars.filter((pillar) => hasObservedValue(pillar.score, { coverage: pillar.coverage })); } function observedDomainsOf(domains) { return domains.filter((domain) => hasObservedValue(domain.score, domainScoreEvidence(domain))); } function dimensionEvidenceState(dimension) { const evidenceState = String(dimension.imputationClass || ''); if (evidenceState) return humanizeId(evidenceState); if (!hasObservedDimensionScore(dimension)) return 'Unavailable'; return humanizeId(dimension.freshness?.staleness || 'observed'); } function formatCoordinates(lat, lon) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return 'not available'; const latText = `${Math.abs(lat)}°${lat >= 0 ? 'N' : 'S'}`; const lonText = `${Math.abs(lon)}°${lon >= 0 ? 'E' : 'W'}`; return `${latText}, ${lonText}`; } function longestEligibleMetaDescription(candidates) { // Linear scan, first-longest wins — the same pick a stable descending sort // by length would make, without copying and sorting the (potentially ~12k) // candidate array. let best; const seen = new Set(); for (const raw of candidates) { const candidate = String(raw ?? '').trim(); if (seen.has(candidate)) continue; seen.add(candidate); if (candidate.length > META_DESCRIPTION_MAX) continue; if (best === undefined || candidate.length > best.length) best = candidate; } return best; } function formatMetaDescriptionList(items) { if (items.length === 1) return items[0]; if (items.length === 2) return `${items[0]} and ${items[1]}`; return `${items.slice(0, -1).join(', ')}, and ${items.at(-1)}`; } function signalMetaDescriptionCandidates({ subjects, signals }) { const candidates = []; const subsetCount = 2 ** signals.length; // The formatted signal list depends only on the mask, so format each subset // once instead of once per subject×verb combination. The triple loop below // keeps its original nesting so the candidate order — and therefore which // equal-length candidate longestEligibleMetaDescription picks — is unchanged. const listByMask = new Array(subsetCount); for (let mask = 1; mask < subsetCount; mask += 1) { const selectedSignals = signals.filter((_, index) => mask & (2 ** index)); if (selectedSignals.length < 3) continue; listByMask[mask] = formatMetaDescriptionList(selectedSignals); } for (const subject of subjects) { for (const verb of ['tracks', 'monitors', 'covers']) { for (let mask = 1; mask < subsetCount; mask += 1) { if (listByMask[mask] === undefined) continue; candidates.push(`${subject}: ${verb} ${listByMask[mask]}.`); } } } return candidates; } // `subject` names the page so a failure is actionable. Without it the build // dies with a bare "No meta description candidate fits 155-160 characters" // across 250 pages and says nothing about which one or how close it came. function selectMetaDescription(candidates, fallbackCandidates, subject) { const selected = longestEligibleMetaDescription(candidates); if (selected?.length >= META_DESCRIPTION_MIN) return selected; const fallback = longestEligibleMetaDescription(fallbackCandidates?.() ?? []); if (fallback?.length >= META_DESCRIPTION_MIN) return fallback; const lengths = [...candidates, ...(fallbackCandidates?.() ?? [])] .map((candidate) => candidate.length) .sort((left, right) => left - right); const near = lengths.length > 0 ? ` Candidate lengths ranged ${lengths[0]}-${lengths.at(-1)} over ${lengths.length} candidate(s).` : ' No candidates were generated.'; throw new Error( `No meta description candidate fits ${META_DESCRIPTION_MIN}-${META_DESCRIPTION_MAX} characters` + `${subject ? ` for ${subject}` : ''}.${near}`, ); } export function countryMetaDescription({ name, rank, rankedCount, lowConfidence = false, ciiEntry = null, }) { if (ciiEntry) { const movementFact = ciiEntry.change24h == null ? (ciiEntry.movementClaim ?? livePulseMovementClaim(0)).metaStable : `and is ${ciiEntry.movementText}`; const subjects = [ `${name} Country Instability Index`, `${name} instability index`, ]; const facts = [ `is ${formatScore(ciiEntry.score, OBSERVED_EVIDENCE)}/100 (${ciiEntry.band}) ${movementFact}`, `scores ${formatScore(ciiEntry.score, OBSERVED_EVIDENCE)}/100 (${ciiEntry.band}) ${movementFact}`, ]; // Graded long-to-short. The window is a narrow 155-160, so a long country // name plus a long movement clause can push EVERY candidate over: "United // Arab Emirates" made all 24 land in 162-196 and reds the whole build // (#7530). The short tail is what keeps a 20-character name in range. const contexts = [ 'with World Monitor country-risk, resilience, advisory, and sanctions context.', 'with World Monitor risk, resilience, advisory, and sanctions context.', 'alongside World Monitor risk, resilience, advisory, and sanctions context.', 'with World Monitor country-risk, resilience, and security context.', 'with World Monitor country-risk and resilience context.', 'alongside World Monitor country-risk and resilience context.', 'with World Monitor risk, resilience and sanctions context.', 'with World Monitor risk and resilience context.', 'with World Monitor risk context.', // Shortest tail for the "unchanged or not yet measured" clause, which // pushed United Arab Emirates to 161+ on every other candidate. 'with World Monitor context.', ]; const candidates = subjects.flatMap((subject) => facts.flatMap( (fact) => contexts.map((context) => `${subject} ${fact}, ${context}`), )); return selectMetaDescription(candidates, undefined, `${name} (CII country)`); } const subjects = [ `${name} country risk and resilience`, `${name} country risk`, `${name} risk and resilience`, ]; const unpublishedStandings = lowConfidence ? [ `a low-confidence listing in World Monitor's Country Resilience Index`, `a low-confidence listing in World Monitor's resilience index`, `a low-confidence listing in World Monitor's index`, `a low-confidence World Monitor index listing`, `a low-confidence index listing`, ] : [ `an unpublished listing in World Monitor's Country Resilience Index`, `an unpublished listing in World Monitor's resilience index`, `an unpublished listing in World Monitor's index`, `an unpublished World Monitor index listing`, `an unpublished index listing`, ]; const standings = rank == null ? unpublishedStandings : [ `ranked #${rank} of ${rankedCount} in World Monitor's Country Resilience Index`, `ranked #${rank} of ${rankedCount} in World Monitor's resilience index`, `ranked #${rank} of ${rankedCount} in World Monitor's index`, `#${rank} of ${rankedCount} in World Monitor's resilience index`, `#${rank} of ${rankedCount} in World Monitor's index`, ]; const signals = [ 'with live instability, travel advisories, sanctions and security signals.', 'with current instability, travel advisories, sanctions and security signals.', 'with live instability, travel-advisory and sanctions signals.', 'plus live instability, travel advisories, sanctions and security signals.', 'with live instability, advisories, sanctions and security signals.', 'with instability, travel advisories, sanctions and security signals.', 'with current instability, advisories, sanctions and security signals.', ]; const candidates = subjects.flatMap((subject) => standings.flatMap( (standing) => signals.map((signal) => `${subject}: ${standing}, ${signal}`), )); return selectMetaDescription(candidates, () => signalMetaDescriptionCandidates({ subjects: [ `${name} country risk profile`, `${name} risk profile`, `${name} country risk`, `${name} risk`, ], signals: [ 'live instability', 'travel advisories', 'sanctions', 'security signals', 'resilience rankings', 'current conditions', 'global context', 'regional context', 'risk trends', 'public data', ], }), `${name} (ranked country)`); } export function chokepointMetaDescription(name) { const subjects = [ `${name} chokepoint status`, `${name} live chokepoint status`, `${name} maritime chokepoint status`, `${name} live status`, ]; const signals = [ 'monitor live disruption risk, vessel transits, congestion, AIS warnings and key trade routes through this strategic waterway.', 'track live disruption risk, vessel transits, congestion, AIS warnings and major trade routes through this strategic waterway.', 'track disruption risk, vessel transits, congestion, AIS warnings and major trade routes through this strategic waterway.', 'monitor disruption risk, transits, congestion, AIS warnings and trade routes through this strategic waterway.', ]; const candidates = subjects.flatMap( (subject) => signals.map((signal) => `${subject}: ${signal}`), ); return selectMetaDescription(candidates, () => signalMetaDescriptionCandidates({ subjects: [ `${name} maritime chokepoint`, `${name} chokepoint status`, `${name} chokepoint`, `${name} status`, ], signals: [ 'live disruption risk', 'vessel transits', 'congestion', 'AIS warnings', 'trade routes', 'maritime security', 'current conditions', 'regional context', 'shipping signals', 'public data', ], }), `${name} (chokepoint)`); } function metricTile(label, value) { return `
${escapeHtml(label)}${escapeHtml(value)}
`; } async function importRepoModule(rootDir, relativePath) { return import(pathToFileURL(repoPath(rootDir, relativePath)).href); } function normalizeGlossaryTerms(terms) { return (terms || []) .map((term) => ({ slug: term.slug, term: term.term, abbr: term.abbr || undefined, short: term.short, })) .filter((term) => term.slug && term.term) .sort((a, b) => a.term.localeCompare(b.term, 'en-US')); } function normalizeChokepoints(entries) { return (entries || []) .map((entry) => ({ id: entry.id, displayName: entry.displayName, transitMeasurementNote: entry.transitMeasurementNote, baselineId: entry.baselineId, shockModelSupported: Boolean(entry.shockModelSupported), routeIds: Array.isArray(entry.routeIds) ? [...entry.routeIds] : [], lat: Number(entry.lat), lon: Number(entry.lon), slug: slugify(entry.displayName || entry.id), })) .filter((entry) => entry.id && entry.displayName) .sort((a, b) => a.displayName.localeCompare(b.displayName, 'en-US')); } export function buildChokepointPageLinks({ chokepoints, countries, crises, blogPostPaths = new Set(), content = CHOKEPOINT_CONTENT }) { const countryByCode = new Map(countries.map((country) => [country.code, country])); const crisisBySlug = new Map(crises.map((crisis) => [crisis.slug, crisis])); const byChokepointId = new Map(); const byCountryCode = new Map(); const byCrisisSlug = new Map(); for (const chokepoint of chokepoints) { const declaration = content[chokepoint.id] || {}; const editorialLinks = declaration.editorialLinks ?? []; if (!Array.isArray(editorialLinks)) throw new Error(`${chokepoint.id}: editorialLinks must be an array`); const editorial = new Map(); for (const link of editorialLinks) { if (!blogPostPaths.has(link?.href) || typeof link.label !== 'string' || !link.label.trim()) { throw new Error(`${chokepoint.id}: editorialLinks requires a canonical blog post path and label: ${link?.href}`); } if (!editorial.has(link.href)) editorial.set(link.href, link); } const resolve = (field, targets, inverse) => { const ids = declaration[field] ?? []; if (!Array.isArray(ids)) throw new Error(`${chokepoint.id}: ${field} must be an array`); return [...new Set(ids)].map((id) => { const target = targets.get(id); if (!target) throw new Error(`${chokepoint.id}: ${field} contains unknown target ${id}`); const entries = inverse.get(id) || []; entries.push(chokepoint); inverse.set(id, entries); return target; }); }; byChokepointId.set(chokepoint.id, { countries: resolve('countryCodes', countryByCode, byCountryCode), crises: resolve('crisisSlugs', crisisBySlug, byCrisisSlug), editorial: [...editorial.values()], }); } return { byChokepointId, byCountryCode, byCrisisSlug }; } function renderRelatedChokepoints(chokepoints) { if (!chokepoints.length) return ''; return `

Related chokepoint trackers

`; } // Near the top of a crisis page, so the open/closed question a reader brings // to a shipping crisis links straight to the waterway's live reading. It // replaces the crisis page's bottom tracker list: each waterway is linked once. export function renderChokepointStatusLinks(chokepoints) { if (!chokepoints.length) return ''; return `

Shipping status: ${chokepoints.map((chokepoint) => `is the ${escapeHtml(chokepoint.displayName)} open?`).join(' · ')} Live disruption score and IMF PortWatch daily transits.

`; } function normalizeCountry(item, sourceStatus, seen, reverseNames) { const code = String(item.countryCode || '').toUpperCase(); const identity = item.identity || {}; const commonName = displayCountryName( code, identity.commonName || item.countryName || reverseNames.get(code) || code, ); const officialName = identity.officialName || commonName; const sameAs = String(identity.sameAs || ''); if (!/^[A-Z]{2}$/.test(code) || !commonName || !/^https:\/\/www\.wikidata\.org\/wiki\/Q\d+$/.test(sameAs)) { throw new Error(`Invalid country identity in resilience snapshot: ${code || 'unknown'}`); } const slug = uniqueSlug(commonName, code, seen); const legacySlugs = [...new Set((identity.legacyNames || []) .map((name) => slugify(name)) .filter((legacySlug) => legacySlug && legacySlug !== slug))]; return { code, name: commonName, slug, legacySlugs, identity: { commonName, officialName, alternateNames: [...new Set((identity.alternateNames || []).map((name) => ( name === 'Macao S A R' ? 'Macao SAR' : name )))], sameAs, }, rank: sourceStatus === 'ranked' ? Number(item.rank) : null, overallScore: item.overallScore, baselineScore: item.baselineScore ?? null, stressScore: item.stressScore ?? null, stressFactor: item.stressFactor ?? null, level: item.level || (sourceStatus === 'ranked' ? 'unclassified' : 'unpublished'), // Greyed-out rows include covered-ineligible countries. Preserve the // snapshot flag so unpublished copy can name the population/85% rule // instead of calling every unpublished page low-confidence. lowConfidence: Boolean(item.lowConfidence), dimensionCoverage: item.dimensionCoverage ?? item.overallCoverage ?? null, headlineEligible: item.headlineEligible === true, trend: item.trend || 'unknown', change30d: item.change30d ?? null, imputationShare: item.imputationShare ?? null, dataVersion: item.dataVersion || '', domains: Array.isArray(item.domains) ? item.domains : [], pillars: Array.isArray(item.pillars) ? item.pillars : [], sourceStatus, }; } function normalizeCountries(snapshot, reverseNames) { const seen = new Set(); const ranked = (snapshot.items || []).map( (item) => normalizeCountry(item, 'ranked', seen, reverseNames), ); const rankedCodes = new Set(ranked.map((country) => country.code)); const greyedOut = (snapshot.greyedOut || []) .filter((item) => !rankedCodes.has(String(item.countryCode || '').toUpperCase())) .map((item) => normalizeCountry( item, item.lowConfidence ? 'low-confidence' : 'unpublished', seen, reverseNames, )); return [...ranked, ...greyedOut].sort((a, b) => { if (a.rank == null && b.rank == null) return a.name.localeCompare(b.name, 'en-US'); if (a.rank == null) return 1; if (b.rank == null) return -1; return a.rank - b.rank; }); } // The identity half of `loadCorpusData().countries`: the same resilience // snapshot through the same `normalizeCountries`, so the code/name/slug triple // is byte-identical to the one `buildCorpus` writes `/countries//` from. // Split out because legacy `api/*.js` needs the slug map (generated by // scripts/generate-country-corpus-slugs.mjs) without the live-pulse snapshot, // whose freshness ceiling would fail a generator that only wants slugs. export function loadCountryCorpusIdentities(rootDir = DEFAULT_ROOT) { const reverseNames = reverseCountryNames(readJson(rootDir, COUNTRY_NAMES_PATH)); const resilience = readJson(rootDir, resolveLatestResilienceSnapshotPath(rootDir)); return normalizeCountries(resilience, reverseNames) .map(({ code, name, slug }) => ({ code, name, slug })); } function normalizeCountryBounds(countryBboxes, countries, reverseNames = new Map()) { const names = new Map(countries.map((country) => [country.code, country.name])); return Object.entries(countryBboxes || {}) .map(([code, rawBounds]) => { if (!/^[A-Z]{2}$/.test(code) || !Array.isArray(rawBounds) || rawBounds.length !== 4) return null; const bounds = rawBounds.map(Number); const [south, west, north, east] = bounds; if ( bounds.some((value) => !Number.isFinite(value)) || south < -90 || north > 90 || south > north || west < -180 || west > 180 || east < -180 || east > 180 ) { return null; } if ( north - south > MAX_TOOL_LATITUDE_SPAN || (west <= east ? east - west : 360 - (west - east)) > MAX_TOOL_LONGITUDE_SPAN ) { return null; } return { code, // Bare ISO2 codes ("EH", "XK") must never surface as user-facing // labels in the tool selects — alias, then reverse-name, then code. name: displayCountryName(code, names.get(code) || reverseNames.get(code)), bounds, }; }) .filter(Boolean) .sort((a, b) => a.name.localeCompare(b.name, 'en-US')); } function normalizeCrises(entries) { const seen = new Set(); return (entries || []).map((entry) => { const slug = String(entry.slug || ''); const region = String(entry.region || ''); const dashboardPath = String(entry.dashboardPath || ''); const coverage = Array.isArray(entry.coverage) ? entry.coverage.map((country) => ({ code: String(country.code || '').toUpperCase(), name: String(country.name || ''), })) : []; const coverageCodes = new Set(coverage.map((country) => country.code)); if ( !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || seen.has(slug) || !entry.title || !entry.shortTitle || !entry.description || !entry.overview || !CRISIS_REGION_IDS.includes(region) || !dashboardPath.startsWith('/') || dashboardPath.startsWith('//') || coverage.length === 0 || coverageCodes.size !== coverage.length || coverage.some((country) => !/^[A-Z]{2}$/.test(country.code) || !country.name) ) { throw new Error(`Invalid crawlable crisis registry entry: ${slug || '(missing slug)'}`); } // The description is the tracker's meta description and lede; past the // snippet limit search engines cut it mid-sentence. const descriptionLength = String(entry.description).length; if (descriptionLength < CRISIS_DESCRIPTION_MIN || descriptionLength > META_DESCRIPTION_MAX) { throw new Error( `Crisis ${slug} description must be ${CRISIS_DESCRIPTION_MIN}-${META_DESCRIPTION_MAX} characters, got ${descriptionLength}`, ); } seen.add(slug); return { slug, title: String(entry.title), shortTitle: String(entry.shortTitle), description: String(entry.description), overview: String(entry.overview), region, coverage, dashboardPath, }; }); } // Dashboard surfaces a crisis "what to watch" indicator may name. Each value is // a panel title from src/config/panels.ts or a layer label from // src/config/map-layer-definitions.ts, so the page never points a reader at a // view that does not exist. Only views that change with events belong here: // static reference layers (curated Conflict Zones, Nuclear Sites, Military // Bases, the Chokepoints outline, Sanctions shading) cannot show a reader the // change an indicator asks them to watch. export const CRISIS_TRACKED_BY = Object.freeze(new Map([ ...[ 'Country Instability', 'UCDP Conflict Events', 'UNHCR Displacement', 'Strategic Risk Overview', 'Escalation Monitor', 'Force Posture', 'Security Advisories', 'Sanctions Pressure', 'Chokepoint Status', 'Strait of Hormuz Tracker', 'Hormuz Trade Tracker', 'Internet Disruptions', 'Israel Sirens', 'Telegram Intel', 'Intel Feed', 'Live Intelligence', 'Regional Intelligence', 'Threat Timeline', 'Thermal Escalation', 'Disease Outbreaks', 'Energy Disruptions Log', 'FAO Food Price Index', 'Global Fuel Shortage Registry', 'Predictions', ].map((name) => [name, 'panel']), ...[ 'Armed Conflict Events', 'Displacement Flows', 'Protests', 'Military Activity', 'Ship Traffic', 'Live Tanker Positions', 'Middle East Strikes', 'Intel Hotspots', 'GPS Jamming', 'Fires', 'Aviation', ].map((name) => [name, 'map layer']), ])); const EDITORIAL_DATE_RE = /^\d{4}-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?$/; function editorialSourceUrl(value, where) { let url; try { url = new URL(String(value || '')); } catch { throw new Error(`${where} needs a valid source URL`); } if (url.protocol !== 'https:') throw new Error(`${where} source must use https`); if (/(^|\.)wikipedia\.org$/.test(url.hostname)) throw new Error(`${where} cites Wikipedia, which is not an accepted source`); return url.href; } function editorialText(value, where) { const text = String(value || '').replace(/\s+/g, ' ').trim(); if (!text) throw new Error(`${where} is empty`); return text; } // Search demand for a conflict is phrased as a question ("what is happening in // Sudan" ~8,100/mo US) far more than as " tracker" (~200). The year // comes from the review date, so a title never claims a year the copy was not // checked in. Past ~70 characters Google cuts the brand, not the question. const CRISIS_TITLE_MAX = 72; export function crisisPageTitle(question, lastReviewed) { return `${question} ${lastReviewed.slice(0, 4)} tracker | World Monitor`; } // Sourced, dated editorial copy for each crisis tracker (#8605). Build-only, so // it lives in scripts/data rather than the shared registry the seeders read. // Every factual item carries the URL it was checked against; a missing, // unsourced or misnamed entry fails the build instead of shipping a thin page. export function normalizeCrisisEditorial(entries, crises) { const bySlug = new Map(); for (const entry of entries || []) { const slug = String(entry?.slug || ''); const where = `Crisis editorial ${slug || '(missing slug)'}`; if (bySlug.has(slug)) throw new Error(`${where} is duplicated`); if (!crises.some((crisis) => crisis.slug === slug)) throw new Error(`${where} has no registry entry`); const lastReviewed = String(entry.lastReviewed || ''); if (!isCanonicalCalendarDate(lastReviewed)) { throw new Error(`${where} needs an ISO lastReviewed date`); } const question = editorialText(entry.question, `${where} question`); if (!question.endsWith('?')) throw new Error(`${where} question must end with a question mark`); const pageTitle = crisisPageTitle(question, lastReviewed); if ([...pageTitle].length > CRISIS_TITLE_MAX) { throw new Error(`${where} title "${pageTitle}" exceeds ${CRISIS_TITLE_MAX} characters`); } const background = (entry.background || []).map((paragraph, index) => ({ text: editorialText(paragraph?.text, `${where} background ${index + 1}`), sourceUrls: (paragraph?.sourceUrls || []).map((url) => editorialSourceUrl(url, `${where} background ${index + 1}`)), })); if (background.length < 2 || background.length > 4) throw new Error(`${where} needs 2-4 background paragraphs`); if (background.some((paragraph) => paragraph.sourceUrls.length === 0)) { throw new Error(`${where} has a background paragraph without a source`); } const keyActors = (entry.keyActors || []).map((actor, index) => ({ name: editorialText(actor?.name, `${where} actor ${index + 1} name`), role: editorialText(actor?.role, `${where} actor ${index + 1} role`), sourceUrl: editorialSourceUrl(actor?.sourceUrl, `${where} actor ${index + 1}`), })); if (keyActors.length < 3) throw new Error(`${where} needs at least 3 key actors`); const timeline = (entry.timeline || []).map((item, index) => { const date = String(item?.date || ''); if (!EDITORIAL_DATE_RE.test(date) || (date.length === 10 && !isCanonicalCalendarDate(date))) throw new Error(`${where} timeline ${index + 1} needs a YYYY-MM or YYYY-MM-DD date`); return { date, event: editorialText(item?.event, `${where} timeline ${index + 1}`), sourceUrl: editorialSourceUrl(item?.sourceUrl, `${where} timeline ${index + 1}`), }; }); if (timeline.length < 4) throw new Error(`${where} needs at least 4 timeline milestones`); if (timeline.some((item, index) => index > 0 && item.date < timeline[index - 1].date)) { throw new Error(`${where} timeline must be in chronological order`); } if (timeline.at(-1).date.slice(0, 10) > lastReviewed) { throw new Error(`${where} timeline runs past its lastReviewed date`); } const whatToWatch = (entry.whatToWatch || []).map((item, index) => { const trackedBy = String(item?.trackedBy || ''); if (!CRISIS_TRACKED_BY.has(trackedBy)) { throw new Error(`${where} watch item ${index + 1} names an unknown dashboard surface: ${trackedBy || '(none)'}`); } return { indicator: editorialText(item?.indicator, `${where} watch item ${index + 1}`), why: editorialText(item?.why, `${where} watch item ${index + 1} reason`), trackedBy, trackedByKind: CRISIS_TRACKED_BY.get(trackedBy), }; }); if (whatToWatch.length < 3 || whatToWatch.length > 5) throw new Error(`${where} needs 3-5 watch items`); bySlug.set(slug, { question, pageTitle, lastReviewed, background, keyActors, timeline, whatToWatch }); } const missing = crises.filter((crisis) => !bySlug.has(crisis.slug)).map((crisis) => crisis.slug); if (missing.length) throw new Error(`Crisis editorial missing for ${missing.join(', ')}`); return bySlug; } const REGION_LABELS = { 'north-america': 'North America', latam: 'Latin America and the Caribbean', europe: 'Europe', mena: 'the Middle East and North Africa', 'sub-saharan-africa': 'sub-Saharan Africa', 'south-asia': 'South Asia', 'east-asia': 'East Asia and the Pacific', }; export function compareUnpublishedRankedPeers(left, right, country, regionId, regionsByCode) { const regionDifference = Number(regionsByCode[right.code] === regionId) - Number(regionsByCode[left.code] === regionId); const countryScore = Number(country.overallScore); const leftScore = Number(left.overallScore); const rightScore = Number(right.overallScore); const scoreDistance = Number.isFinite(countryScore) && Number.isFinite(leftScore) && Number.isFinite(rightScore) ? Math.abs(leftScore - countryScore) - Math.abs(rightScore - countryScore) : 0; return regionDifference || scoreDistance || left.rank - right.rank || left.name.localeCompare(right.name, 'en-US'); } function addCountryContext(countries, regionsByCode, crises) { const ranked = countries.filter((country) => country.rank != null); return countries.map((country) => { const regionId = regionsByCode[country.code] || 'global'; const unpublished = country.rank == null; const peers = ranked .filter((candidate) => candidate.code !== country.code) .sort((left, right) => { if (unpublished) { return compareUnpublishedRankedPeers(left, right, country, regionId, regionsByCode); } const distanceDifference = Math.abs(left.rank - country.rank) - Math.abs(right.rank - country.rank); return distanceDifference || left.rank - right.rank || left.name.localeCompare(right.name, 'en-US'); }) .slice(0, 4); const regionalPeers = (unpublished ? ranked : countries) .filter((candidate) => ( candidate.code !== country.code && regionsByCode[candidate.code] === regionId && (unpublished || Number.isFinite(candidate.overallScore)) )) .sort((left, right) => unpublished ? compareUnpublishedRankedPeers(left, right, country, regionId, regionsByCode) : Math.abs(left.overallScore - country.overallScore) - Math.abs(right.overallScore - country.overallScore) || left.name.localeCompare(right.name, 'en-US')) .slice(0, 5); const crisisMemberships = crises.filter( (crisis) => crisis.coverage.some((coveredCountry) => coveredCountry.code === country.code), ); return { ...country, regionId, regionName: REGION_LABELS[regionId] || 'the global comparison set', peers, regionalPeers, crisisMemberships, crisisRegistrySize: crises.length, }; }); } function stripMarkdownInline(value) { return String(value || '') .replace(/<((?:https?:\/\/|mailto:)[^<>\s]+|[^<>\s@]+@[^<>\s@]+)>/gi, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*+/g, '') .replace(/<[^>]+>/g, '') .replace(/\s+/g, ' ') .trim(); } export function parseChangelog(source) { const matches = [...source.matchAll(/^## \[([^\]]+)\](?: - ([0-9-]+))?\s*$/gm)]; return matches.map((match, index) => { const next = matches[index + 1]; const body = source.slice(match.index + match[0].length, next ? next.index : source.length); const bulletItems = []; let currentBullet = null; for (const line of body.split(/\r?\n/)) { const bulletMatch = line.match(/^- (.+)$/); if (bulletMatch) { if (currentBullet) bulletItems.push(currentBullet.join(' ')); currentBullet = [bulletMatch[1]]; } else if (currentBullet && /^\s{2,}\S/.test(line)) { currentBullet.push(line.trim()); } else if (currentBullet && line.trim() === '') { } else if (currentBullet) { bulletItems.push(currentBullet.join(' ')); currentBullet = null; } } if (currentBullet) bulletItems.push(currentBullet.join(' ')); const bullets = bulletItems .map((line) => stripMarkdownInline(line)) .filter(Boolean) .slice(0, 8); const headings = [...body.matchAll(/^###\s+(.+)$/gm)] .map(([, heading]) => stripMarkdownInline(heading)) .filter(Boolean); return { label: match[1], date: match[2] || null, slug: slugify(match[1] === 'Unreleased' ? 'unreleased' : match[1]), headings, bullets, }; }).filter((release) => release.label && release.bullets.length > 0); } // Exported for the #7533 family-clock guard, which recomputes the changelog // family's fold the same way it does for the content-version constants. export function latestDatedChangelogRelease(changelog) { const dates = changelog .map((release) => release.date) .filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date ?? '')) .sort(); return dates[dates.length - 1] || null; } // Git's local env vars (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, ...) override // `cwd`, so a build running inside a git hook would silently resolve these // queries against the hook's repository instead of the rootDir it was given. // Strip them, and render dates in UTC (see gitFileLastmod). let gitLocalEnvVars = null; function gitEnvForRoot() { if (gitLocalEnvVars === null) { try { gitLocalEnvVars = execFileSync('git', ['rev-parse', '--local-env-vars'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }).trim().split('\n'); } catch { gitLocalEnvVars = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY']; } } const env = { ...process.env, TZ: 'UTC' }; for (const name of gitLocalEnvVars) delete env[name]; return env; } // One build asks for the same few dozen paths in every loadCorpusData call, // and each answer cost two git subprocesses. The corpus test file alone spawned // 2,657 of them (41 s of its 55 s locally). Answers are cached per process: // every caller is a one-shot build or a test, none commits to a root and then // re-asks about it. const completeHistoryByRoot = new Map(); function hasCompleteGitHistory(rootDir) { if (!completeHistoryByRoot.has(rootDir)) { completeHistoryByRoot.set(rootDir, probeCompleteGitHistory(rootDir)); } return completeHistoryByRoot.get(rootDir); } function probeCompleteGitHistory(rootDir) { try { return execFileSync('git', ['rev-parse', '--is-shallow-repository'], { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnvForRoot(), }).trim() === 'false'; } catch { return false; } } const gitLastmodByPath = new Map(); export function gitFileLastmod(rootDir, relativePath) { const key = `${rootDir}\0${relativePath}`; if (!gitLastmodByPath.has(key)) { gitLastmodByPath.set(key, probeGitFileLastmod(rootDir, relativePath)); } return gitLastmodByPath.get(key); } function probeGitFileLastmod(rootDir, relativePath) { if (!hasCompleteGitHistory(rootDir)) return null; try { // Committer date rendered in UTC, not the commit's recorded timezone — // a +04:00 evening commit would otherwise date as "tomorrow" against a // UTC build clock and trip build-sitemap's future-lastmod guard. const out = execFileSync( 'git', ['log', '-1', '--date=format-local:%Y-%m-%d', '--format=%cd', '--', relativePath], { cwd: rootDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnvForRoot(), }, ).trim(); return /^\d{4}-\d{2}-\d{2}$/.test(out) ? out : null; } catch { return null; } } // livePulseSnapshotPath (#7533) lets tests inject a pulse snapshot so date // clocks stop inheriting whatever the freeze last committed. Relative paths // resolve against rootDir (production never passes this), absolute paths are // read as-is so tests can point at temp-dir snapshots. Omitting it preserves // the default "newest committed snapshot" behaviour exactly. Injected pulses // keep the resolver's shape contract (required sections, filename↔capturedAt // coherence) but skip its 10-day staleness fuse — that fuse is what tests // legitimately need to bypass. export async function loadCorpusData({ rootDir = DEFAULT_ROOT, livePulseSnapshotPath, now = Date.now(), } = {}) { const resilienceSnapshotPath = resolveLatestResilienceSnapshotPath(rootDir); const pulsePath = livePulseSnapshotPath ?? resolveLatestLivePulseSnapshotPath(rootDir); const resilience = readJson(rootDir, resilienceSnapshotPath); const livePulse = readJson(rootDir, pulsePath); if (!livePulse.countries || !livePulse.chokepoints || !livePulse.crises || !livePulse.signalConvergence) { throw new Error(`${pulsePath} is missing required live-pulse sections`); } if (!isCanonicalCalendarDate(livePulse.capturedAt)) { throw new Error(`${pulsePath} capturedAt ${livePulse.capturedAt} is not a canonical calendar date`); } const filenameDate = basename(pulsePath).match(LIVE_PULSE_SNAPSHOT_RE)?.[1]; if (filenameDate && livePulse.capturedAt !== filenameDate) { throw new Error( `${pulsePath} filename date ${filenameDate} does not match capturedAt ${livePulse.capturedAt}`, ); } const microstateTerritoryCodes = new Set( (readJson(rootDir, MICROSTATE_TERRITORIES_PATH).iso2 || []) .map((code) => String(code || '').toUpperCase()) .filter((code) => /^[A-Z]{2}$/.test(code)), ); const reverseNames = reverseCountryNames(readJson(rootDir, COUNTRY_NAMES_PATH)); const regionsByCode = readJson(rootDir, COUNTRY_REGIONS_PATH); const [ { CHOKEPOINT_REGISTRY }, { TRADE_ROUTES }, { GLOSSARY_TERMS }, { default: countryBboxes }, { RESEARCH_REPORTS }, ] = await Promise.all([ importRepoModule(rootDir, CHOKEPOINT_REGISTRY_PATH), importRepoModule(rootDir, TRADE_ROUTES_PATH), importRepoModule(rootDir, GLOSSARY_DATA_PATH), importRepoModule(rootDir, COUNTRY_BBOXES_PATH), importRepoModule(rootDir, RESEARCH_REPORTS_INDEX_PATH), ]); const researchReports = RESEARCH_REPORTS.map((report) => ({ report, snapshot: readJson(rootDir, report.snapshotPath), })); const countryBboxByCode = new Map( Object.entries(countryBboxes || {}) .map(([code, rawBounds]) => { if (!/^[A-Z]{2}$/.test(code) || !Array.isArray(rawBounds) || rawBounds.length !== 4) return null; const bounds = rawBounds.map(Number); if (bounds.some((value) => !Number.isFinite(value))) return null; return [code, bounds]; }) .filter(Boolean), ); const registryCrises = normalizeCrises(readJson(rootDir, CRISIS_REGISTRY_PATH)); const crisisEditorial = normalizeCrisisEditorial(readJson(rootDir, CRISIS_EDITORIAL_PATH), registryCrises); const crises = registryCrises.map((crisis) => ({ ...crisis, editorial: crisisEditorial.get(crisis.slug) })); const countries = addCountryContext( normalizeCountries(resilience, reverseNames), regionsByCode, crises, ).map((country) => ({ ...country, microstateTerritory: microstateTerritoryCodes.has(country.code), })); // In-body related-country links, derived from the crisis registry, the GDELT // tension pairs, bounding-box and centroid proximity and region. Only published country // pages are candidates (scripts/country-related-links.mjs). const relatedCountriesByCode = selectRelatedCountries({ countries, bboxByCode: countryBboxByCode, centroidByCode: readJson(rootDir, COUNTRY_CENTROIDS_PATH), tensionPairs: readJson(rootDir, TENSION_PAIRS_PATH), regionsByCode, regionLabels: REGION_LABELS, }); // Every page that states coverage reads this one pair. The country rows are // built from the same snapshot, so their ranked rows must match it exactly, // or the /countries/ table would list a different count than its copy. const resilienceCoverage = resilienceCoverageOf(resilience); const rankedRows = countries.filter((country) => country.rank != null).length; if (rankedRows !== resilienceCoverage.ranked || countries.length !== resilienceCoverage.universe) { throw new Error( `the resilience country rows (${rankedRows} ranked of ${countries.length}) do not match the snapshot ` + `(${resilienceCoverage.ranked} of ${resilienceCoverage.universe})`, ); } // Publish rules for the frozen developments (#7738, #7748), applied once // here so the page, its dataset download, its WebPage dateModified and the // coverage tripwire all read the same rows: a brief withheld for thin // grounding must not still stamp dateModified or ship in the JSON. for (const country of countries) { const row = livePulse.countries[country.code]; if (row && typeof row === 'object' && 'developments' in row) { // Validate the committed shape before the rules run: a malformed brief // (no sources, no citation) must red the build here, not be quietly // withheld as thin grounding. const brief = row.developments?.brief; if (brief && typeof brief === 'object') assertDevelopmentsBrief(brief); row.developments = normalizeFrozenDevelopments(row.developments, { countryCode: country.code, countryName: country.name, }); } } const ciiRanking = buildCiiRankingEntries(countries, livePulse, { now }); const countryBounds = normalizeCountryBounds(countryBboxes, countries, reverseNames); const chokepoints = normalizeChokepoints(CHOKEPOINT_REGISTRY); const tradeRoutesById = new Map( (TRADE_ROUTES || []).map((route) => [route.id, { id: route.id, name: route.name, volumeDesc: route.volumeDesc, category: route.category, }]), ); const glossaryTerms = normalizeGlossaryTerms(GLOSSARY_TERMS); const changelog = parseChangelog(readText(rootDir, CHANGELOG_PATH)); // Family lastmods are change-dates, not build-dates (#7463). Do not fold // CORPUS_GENERATOR_CONTENT_VERSION into any family. All country pages include // the pulse date, while CII-targeted pages use a separate content clock. const countriesLastmod = laterDate( resilience.capturedAt, livePulse.capturedAt, livePulseMovementClaimLastmod(livePulse.capturedAt, now), gitFileLastmod(rootDir, COUNTRY_REGIONS_PATH), gitFileLastmod(rootDir, MICROSTATE_TERRITORIES_PATH), gitFileLastmod(rootDir, RELATED_READING_PATH), gitFileLastmod(rootDir, LIVE_PAGE_LINKS_PATH), gitFileLastmod(rootDir, searchLinksPath('countries')), // Inputs of the related-country links (scripts/country-related-links.mjs). gitFileLastmod(rootDir, CRISIS_REGISTRY_PATH), gitFileLastmod(rootDir, TENSION_PAIRS_PATH), gitFileLastmod(rootDir, COUNTRY_CENTROIDS_PATH), gitFileLastmod(rootDir, COUNTRY_BBOXES_PATH), COUNTRY_PAGE_CONTENT_VERSION, ); const ciiCountriesLastmod = laterDate( countriesLastmod, CII_COUNTRY_PAGE_CONTENT_VERSION, ); const countriesIndexLastmod = laterDate( countriesLastmod, COUNTRIES_INDEX_CONTENT_VERSION, ); const countryInstabilityIndexLastmod = laterDate( countriesLastmod, CII_RANKING_PAGE_CONTENT_VERSION, gitFileLastmod(rootDir, searchLinksPath('country-instability-index')), ); const changelogLastmod = laterDate( gitFileLastmod(rootDir, CHANGELOG_PATH), latestDatedChangelogRelease(changelog), ); const { capturedAt: chokepointCapturedAt, volumeObservedAt: chokepointVolumeObservedAt } = resolveChokepointObservation({ registryGitLastmod: gitFileLastmod(rootDir, CHOKEPOINT_REGISTRY_PATH), tradeRoutesGitLastmod: gitFileLastmod(rootDir, TRADE_ROUTES_PATH), }); const chokepointsLastmod = laterDate( ...CHOKEPOINT_PAGE_LASTMOD_PATHS.map((path) => gitFileLastmod(rootDir, path)), livePulse.capturedAt, CHOKEPOINT_PAGE_CONTENT_VERSION, ); // The signal-convergence page states when its methodology was last // reviewed, so a search-link edit moves the tools lastmod but not that date. const toolsReviewedOn = laterDate( gitFileLastmod(rootDir, LIVE_TOOLS_SCRIPT_PATH), TOOLS_PAGE_CONTENT_VERSION, ); const toolsLastmod = laterDate(toolsReviewedOn, gitFileLastmod(rootDir, searchLinksPath('tools'))); // The hub links every live topic page, so their code moves it too. const toolsIndexLastmod = laterDate( toolsLastmod, LIVE_TOPIC_PAGES_CONTENT_VERSION, ...LIVE_TOPIC_PAGES_INPUT_PATHS.map((path) => gitFileLastmod(rootDir, path)), ); // The built readings come from the pulse, so a new capture advances them. const liveTopicsLastmod = laterDate( LIVE_TOPIC_PAGES_CONTENT_VERSION, ...LIVE_TOPIC_PAGES_INPUT_PATHS.map((path) => gitFileLastmod(rootDir, path)), gitFileLastmod(rootDir, searchLinksPath('tools')), livePulse.liveTopics ? livePulse.capturedAt : null, ); const crisesLastmod = laterDate( gitFileLastmod(rootDir, CRISIS_REGISTRY_PATH), gitFileLastmod(rootDir, CRISIS_EDITORIAL_PATH), gitFileLastmod(rootDir, RELATED_READING_PATH), gitFileLastmod(rootDir, LIVE_PAGE_LINKS_PATH), gitFileLastmod(rootDir, searchLinksPath('crises')), livePulse.capturedAt, // The country readings table renders resilience scores and ranks, and // each page states when its editorial copy was last reviewed. resilience.capturedAt, ...crises.map((crisis) => crisis.editorial.lastReviewed), CRISIS_PAGE_CONTENT_VERSION, ); const researchLastmod = laterDate( ...researchReports.map(({ report }) => report.dateModified), RESEARCH_PAGE_CONTENT_VERSION, ); const useCasesLastmod = laterDate( USE_CASES_CONTENT_VERSION, gitFileLastmod(rootDir, 'scripts/build-use-cases.mjs'), gitFileLastmod(rootDir, 'scripts/data/use-case-workflows.mjs'), gitFileLastmod(rootDir, searchLinksPath('use-cases')), ); const coverageCatalog = await loadCoverageCatalog({ importModule: (relativePath) => importRepoModule(rootDir, relativePath), readJson: (relativePath) => readJson(rootDir, relativePath), }); const coverageLastmod = laterDate( COVERAGE_CONTENT_VERSION, ...COVERAGE_INPUT_PATHS.map((path) => gitFileLastmod(rootDir, path)), ); // The pulse folds in because the scorecard the page publishes is captured by // the same freeze: a new capture changes every number on the page. const accuracyLastmod = laterDate( ACCURACY_CONTENT_VERSION, gitFileLastmod(rootDir, ACCURACY_PAGE_SOURCE_PATH), livePulse.capturedAt, ); // The page prints the crisis registry and three pulse readings, so the // registry and the capture move it alongside its own sources. const situationLastmod = laterDate( SITUATION_CONTENT_VERSION, ...SITUATION_PAGE_INPUT_PATHS.map((path) => gitFileLastmod(rootDir, path)), gitFileLastmod(rootDir, CRISIS_REGISTRY_PATH), gitFileLastmod(rootDir, searchLinksPath('monitor-the-situation')), livePulse.capturedAt, ); const comparisonsLastmod = comparisonPageLastmod({ contentVersion: COMPARISONS_CONTENT_VERSION, pathLastmods: COMPARISON_PAGE_LASTMOD_PATHS.map((path) => gitFileLastmod(rootDir, path)), snapshotDate: livePulse.capturedAt, }); const attributionManifest = readJson(rootDir, SOURCE_ATTRIBUTION_MANIFEST_PATH); // Production generators share the validated attribution predicate and stats. // Tests retain a separate raw-manifest oracle so a mutation here cannot make // both the expected and actual provider sets agree with the same bug. const sourceInventory = scanUpstreamHosts(rootDir); const sourceStats = sourceAttributionStats(sourceInventory, attributionManifest); const activeSourceEntries = activeSourceAttributionEntries(attributionManifest); const sourceCatalog = attachCoverageToCatalog( buildSourceCatalog(activeSourceEntries, { logicalProviders: attributionManifest.logicalProviders || [], }), scanNamedFeedDeclarations(rootDir), loadSourceGeography(rootDir), ); if (sourceCatalog.length !== sourceStats.providerCount) { throw new Error('Source catalog provider count drifted from the attribution manifest'); } const sourcesLastmod = sourcePageLastmod({ manifestLastmod: gitFileLastmod(rootDir, SOURCE_ATTRIBUTION_MANIFEST_PATH), rendererLastmod: gitFileLastmod(rootDir, SOURCE_PAGE_RENDERER_PATH), originLastmod: gitFileLastmod(rootDir, SOURCE_ORIGIN_PATH), catalogInputLastmods: SOURCE_CATALOG_LASTMOD_PATHS.map((path) => gitFileLastmod(rootDir, path)), sharedTemplateLastmod: gitFileLastmod(rootDir, SHARED_PAGE_TEMPLATE_PATH), snapshotDate: livePulse.capturedAt, }); return { generatorContentVersion: CORPUS_GENERATOR_CONTENT_VERSION, sources: { resilienceSnapshot: resilienceSnapshotPath, livePulseSnapshot: pulsePath, microstateTerritories: MICROSTATE_TERRITORIES_PATH, countryNames: COUNTRY_NAMES_PATH, countryRegions: COUNTRY_REGIONS_PATH, chokepointRegistry: CHOKEPOINT_REGISTRY_PATH, glossaryData: GLOSSARY_DATA_PATH, changelog: CHANGELOG_PATH, tradeRoutes: TRADE_ROUTES_PATH, liveToolsScript: LIVE_TOOLS_SCRIPT_PATH, liveTopicPagesInputs: [...LIVE_TOPIC_PAGES_INPUT_PATHS], countryBboxes: COUNTRY_BBOXES_PATH, crisisRegistry: CRISIS_REGISTRY_PATH, crisisEditorial: CRISIS_EDITORIAL_PATH, tensionPairs: TENSION_PAIRS_PATH, countryCentroids: COUNTRY_CENTROIDS_PATH, researchReports: RESEARCH_REPORTS_INDEX_PATH, useCases: 'scripts/build-use-cases.mjs', coverageInputs: [...COVERAGE_INPUT_PATHS], accuracy: ACCURACY_PAGE_SOURCE_PATH, situationInputs: [...SITUATION_PAGE_INPUT_PATHS], comparisons: 'scripts/build-comparison-pages.mjs', sourceAttributionManifest: SOURCE_ATTRIBUTION_MANIFEST_PATH, sourcePageRenderer: SOURCE_PAGE_RENDERER_PATH, sourceOrigin: SOURCE_ORIGIN_PATH, sourceCatalogInputs: [...SOURCE_CATALOG_LASTMOD_PATHS], sharedPageTemplate: SHARED_PAGE_TEMPLATE_PATH, }, livePulse, activeWars: normalizeActiveWars(livePulse.activeWars, livePulse.capturedAt), lastmod: { countryInstabilityIndex: countryInstabilityIndexLastmod, countriesIndex: countriesIndexLastmod, countries: countriesLastmod, ciiCountries: ciiCountriesLastmod, changelog: changelogLastmod, chokepoints: chokepointsLastmod, tools: toolsLastmod, toolsIndex: toolsIndexLastmod, liveTopics: liveTopicsLastmod, crises: crisesLastmod, research: researchLastmod, useCases: useCasesLastmod, coverage: coverageLastmod, accuracy: accuracyLastmod, situation: situationLastmod, comparisons: comparisonsLastmod, sources: sourcesLastmod, }, toolsReviewedOn, sourceStats, sourceCatalog, coverageCatalog, resilience, resilienceCoverage, ciiRanking, countries, countryBounds, countryBboxByCode, relatedCountriesByCode, crises, chokepoints, chokepointObservation: { capturedAt: chokepointCapturedAt, volumeObservedAt: chokepointVolumeObservedAt, }, tradeRoutesById, glossaryTerms, changelog, researchReports, }; } function breadcrumbLd(baseUrl, items) { const currentPath = items[items.length - 1]?.path; return { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'BreadcrumbList', ...(currentPath ? { '@id': `${absoluteUrl(baseUrl, currentPath)}#breadcrumb` } : {}), itemListElement: items.map((item, index) => ({ '@type': 'ListItem', position: index + 1, name: item.name, item: absoluteUrl(baseUrl, item.path), })), }; } function faqPageLd(faqs) { return { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'FAQPage', mainEntity: faqs.map((faq) => ({ '@type': 'Question', name: faq.question, acceptedAnswer: { '@type': 'Answer', text: faq.answer }, })), }; } function jsonLdTypes(entry) { const type = entry?.['@type']; return Array.isArray(type) ? type : type ? [type] : []; } function withSpeakableAndGraph(entry, { canonical, breadcrumbId }) { if (!entry || typeof entry !== 'object') return entry; const types = jsonLdTypes(entry); if (!types.some((type) => PAGE_TYPES_WITH_ATTRIBUTION.has(type))) return entry; const next = { ...entry }; if (types.some((type) => PAGE_TYPES_WITH_SPEAKABLE.has(type)) && !next.speakable) { next.speakable = { ...DEFAULT_SPEAKABLE }; } if (types.some((type) => PAGE_TYPES_WITH_WEBPAGE_ID.has(type))) { if (!next['@id']) next['@id'] = `${canonical}#webpage`; if (!next.isPartOf) next.isPartOf = { '@id': WEBSITE_ID }; if (!next.breadcrumb && breadcrumbId) next.breadcrumb = { '@id': breadcrumbId }; if (!next.publisher) next.publisher = { ...WORLD_MONITOR_ORG }; } // Attribution, not just provenance (#7980). Every page here publishes a // score, a ranking, or a reference claim, and `publisher` says only who put // it online. `Organization` is the honest author for a generated page — no // human wrote the sentence, and claiming one would be worse than silence — // so the canonical Organization stands as author wherever a page does not // already name a more specific one. if (!next.author) next.author = { ...WORLD_MONITOR_ORG }; return next; } function imageObjectLd(baseUrl, ogImage, ogImageAlt) { return { '@type': 'ImageObject', contentUrl: absoluteUrl(baseUrl, ogImage), url: absoluteUrl(baseUrl, ogImage), width: 1200, height: 630, caption: ogImageAlt, name: ogImageAlt, }; } const PAGE_TYPES_WITH_PRIMARY_IMAGE = new Set([ 'WebPage', 'CollectionPage', 'ItemPage', ]); function withPrimaryImage(entry, image) { if (!entry || typeof entry !== 'object') return entry; const type = entry['@type']; const types = Array.isArray(type) ? type : [type]; if (!types.some((value) => PAGE_TYPES_WITH_PRIMARY_IMAGE.has(value))) { return entry; } if (entry.primaryImageOfPage || entry.image) return entry; return { ...entry, primaryImageOfPage: image, image, }; } // Every top-level JSON-LD block binds its own vocabulary. A root node without // `@context` is not a lenient block — it has no vocabulary at all, so `@type` // resolves to nothing and schema.org consumers discard it silently rather than // erroring. Stamping here rather than at each call site is deliberate: #7491 // shipped 62 unparseable blocks across the 31 CII-covered country pages (found // in #7502) because two hand-written sibling Datasets were promoted out of a // `@context`'d parent and nobody re-declared it. A builder that forgets can no // longer produce one. // Nested nodes inherit the root context and are left untouched. export function withSchemaContext(entry) { if (!entry || typeof entry !== 'object') return entry; // A deliberate `@context` is preserved; a missing OR empty one is replaced. // Destructure the dead key out rather than spreading `entry` over the stamp, // or `'@context': null` would be re-applied on top of it and the "cannot // forget" invariant above would hold only for the absent case. if (entry['@context']) return entry; const { '@context': unusable, ...rest } = entry; return { '@context': SCHEMA_ORG_CONTEXT_URL, ...rest }; } // Source page path -> its search links for the build in progress. pageDocument // reads it because a dozen renderers here and every external builder's `tpl` // reach the template, and only the template sees the composed
. let searchLinksByPath = new Map(); function pageDocument({ baseUrl, path, title, description, lastmod, paginationLinks = [], jsonLd, breadcrumbs, body, scriptSrcs = [], inlineScript = '', bodyClass = '', headerNav = '', footerBody = '', extraStyles = '', ogType = 'article', ogImage = OG_IMAGE_PATH, ogImageAlt = OG_IMAGE_ALT, robots = INDEXABLE_ROBOTS_CONTENT, }) { const canonical = absoluteUrl(baseUrl, path); const pageImage = imageObjectLd(baseUrl, ogImage, ogImageAlt); // Allow a single JSON-LD object or an array of sibling graphs (e.g. WebPage // + FAQPage + ItemList for AI-extractable use-case workflows — #7381). // Attach ImageObject to page-shaped graphs so multimodal crawlers see a // primary image even when the HTML body is mostly text (#7382). const breadcrumbId = breadcrumbs?.['@id'] || null; const ld = [ ...(Array.isArray(jsonLd) ? jsonLd : jsonLd ? [jsonLd] : []), breadcrumbs, ] .filter(Boolean) .map((entry) => withPrimaryImage(entry, pageImage)) .map((entry) => withSpeakableAndGraph(entry, { canonical, breadcrumbId })) .map((entry) => withSchemaContext(entry)); const renderedNav = headerNav || ` World Monitor Coverage Sources Compare Countries Chokepoints Crises Live tools Research Accuracy Situation Use cases Changelog Glossary`; const renderedFooter = footerBody || 'World Monitor reference pages. Dated snapshots are published here; live readings are labelled separately.'; const mainHtml = `${body}\n${renderContextualComparisons(path, escapeHtml)}`; const searchLinks = placeSearchLinks(mainHtml, path, searchLinksByPath.get(path), escapeHtml); // The rendered half of #7980. `author` in JSON-LD is what a machine reads; // a quality rater and a human reader want a name on the page that publishes // the score. Rendered here rather than inside each body so no page family // and no `footerBody` override can ship without it. // The methodology link targets /docs/algorithms, not /docs: the docs root is // a 307 to /docs/documentation (vercel.json), so every one of these pages // published a temporary redirect as an internal crawl signal (#8603). // /docs/methodology is a 307 too, to a single nowcast page, so it is not a // replacement. /docs/algorithms is the scoring-rules page the text names and // answers 200. /docs/about and /docs/corrections were probed at 200. const renderedByline = ''; return ` ${escapeHtml(title)} ${lastmod ? `` : []} ${paginationLinks.map((link) => ``).join(String.fromCharCode(10) + " ")} ${ld.map((entry) => ``).join('\n ')}

${escapeHtml(ogImageAlt)}

${mainHtml}${searchLinks ? `\n${searchLinks}` : ''}
${renderedByline}${renderedFooter}
${scriptSrcs.map((src) => ``).join('\n ')} ${inlineScript ? `` : ''} ${UMAMI_SCRIPT_TAG} `; } function ciiMovementProperties(change24h, propertyName) { return change24h == null ? [] : [{ '@type': 'PropertyValue', name: propertyName, value: change24h, unitText: 'index points', }]; } function renderCountryInstabilityIndexPage({ ciiRanking, resilienceCoverage, baseUrl, capturedAt, lastmod, snapshotPath, }) { const path = '/country-instability-index/'; const movementClaim = ciiRanking.movementClaim ?? livePulseMovementClaim(livePulseSnapshotAgeDays(capturedAt)); const description = `Live Country Instability Index rankings: scores, ${movementClaim.availableMovement}, severity and update times for ${ciiRanking.entries.length} Tier-1 countries.`; const datasetId = `${absoluteUrl(baseUrl, path)}#dataset`; const rankingId = `${absoluteUrl(baseUrl, path)}#ranking`; const versionLabel = `CII ${ciiRanking.methodologyVersion}`; const rankingFaq = { question: 'Which countries are most unstable right now?', answer: `As of ${formatStaticDateTime(ciiRanking.updatedAt)}, the highest Country Instability Index scores are ${formatProseList(ciiRanking.entries.slice(0, 3).map((entry) => entry.country.name))}. World Monitor derives this ranking from the committed ${versionLabel} pulse for ${ciiRanking.entries.length} monitored countries. The published table can refresh from the current API after the page loads.`, }; const itemList = ciiRanking.entries.map((entry, index) => { const url = absoluteUrl(baseUrl, `/countries/${entry.country.slug}/`); return { '@type': 'ListItem', position: index + 1, name: entry.country.name, url, item: { '@type': 'Country', name: entry.country.name, url, additionalProperty: [ { '@type': 'PropertyValue', name: 'Country Instability Index score', value: entry.score, minValue: 0, maxValue: 100 }, ...ciiMovementProperties(entry.change24h, movementClaim.propertyName), { '@type': 'PropertyValue', name: 'Instability level', value: entry.band }, ], }, }; }); const body = `

Current country stress

Country Instability Index

The World Monitor Country Instability Index (CII) measures current country-level stress on a 0-100 scale using conflict, unrest, security and information signals. ${escapeHtml(versionLabel)} currently monitors ${ciiRanking.entries.length} countries and ${escapeHtml(movementClaim.rankingReports)}.

${escapeHtml(rankingFaq.question)}

${escapeHtml(rankingFaq.answer)}

Live rankings

Current CII scores

Published rankings
${ciiRanking.entries.map((entry) => ` `).join('\n')}
Published ${escapeHtml(formatStaticDateTime(ciiRanking.updatedAt))} from the committed crawlable pulse. The table refreshes from the current API after load.
CountryCII24hLevelUpdated
${escapeHtml(entry.country.name)}${escapeHtml(formatScore(entry.score, OBSERVED_EVIDENCE))}${escapeHtml(ciiMovementDisplayLabel(entry.trend))}${escapeHtml(entry.band)}

What the CII measures

CII combines a 40% structural baseline with 60% live event pressure. The event score weights conflict at 30%, unrest at 25%, information at 25%, and security at 20%. It also applies bounded boosts and conflict or advisory floors. Read the CII ${escapeHtml(ciiRanking.methodologyVersion)} methodology before using a score in an analysis.

CII measures short-term stress. The separate Country Resilience Index measures longer-term structural capacity, with ${escapeHtml(resilienceCoveragePhrase(resilienceCoverage))}. Do not combine the scores.

CII is a current-conditions score, not a forecast. Where World Monitor does forecast, the record and its methodology are published on the forecast accuracy scorecard.

Open the live CII panel in World Monitor →

Source: World Monitor Country Instability Index snapshot, ${escapeHtml(prettyDate(capturedAt))}. Current results: /api/intelligence/v1/get-risk-scores.

`; const html = pageDocument({ baseUrl, path, title: 'Country Instability Index: Live Rankings | World Monitor', description, lastmod, jsonLd: [ { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'CollectionPage', name: 'Country Instability Index', description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', mainEntity: { '@id': datasetId }, }, faqPageLd([rankingFaq]), { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'Dataset', '@id': datasetId, name: `World Monitor Country Instability Index (CII) ${ciiRanking.methodologyVersion}`, description: `Current 0-100 instability scores, ${movementClaim.availableApproximateMovement}, and instability levels for ${ciiRanking.entries.length} monitored countries.`, url: absoluteUrl(baseUrl, path), identifier: `world-monitor-cii-${ciiRanking.methodologyVersion}`, keywords: ['country instability', 'country risk', 'instability index', 'geopolitical risk'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: capturedAt, dateModified: ciiRanking.updatedAt, temporalCoverage: datasetObservationCoverage(ciiRanking.entries.map((entry) => entry.asOf)), spatialCoverage: 'Worldwide', isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), measurementTechnique: `World Monitor CII ${ciiRanking.methodologyVersion}`, variableMeasured: [ { '@type': 'PropertyValue', name: 'Instability score', minValue: 0, maxValue: 100, unitText: 'index points' }, { '@type': 'PropertyValue', name: movementClaim.propertyName, unitText: 'index points' }, { '@type': 'PropertyValue', name: 'Instability level' }, ], distribution: [dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, CII_INDEX_DATASET_DOWNLOAD)))], mainEntity: { '@id': rankingId }, }, { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'ItemList', '@id': rankingId, name: 'Country Instability Index ranking', numberOfItems: ciiRanking.entries.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: itemList, }, dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Country Instability Index', path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); assertLivePulseMovementClaim(html, { pagePath: path, ageDays: ciiRanking.ageDays ?? livePulseSnapshotAgeDays(capturedAt), }); return html; } function renderCountriesIndex({ countries, ciiRanking, resilienceCoverage, baseUrl, capturedAt, lastmod, snapshotPath }) { const path = '/countries/'; const description = `Browse World Monitor country risk pages: ${resilienceCoveragePhrase(resilienceCoverage)} and high-frequency instability scores for ${ciiRanking.entries.length} Tier-1 countries.`; const datasetId = `${absoluteUrl(baseUrl, path)}#dataset`; const rankedCountries = countries .filter((country) => country.rank != null) .sort((a, b) => a.rank - b.rank); const topRankedNames = rankedCountries.slice(0, 3).map((country) => country.name); const rankingYear = capturedAt.slice(0, 4); const hubFaqs = [ { question: `Which countries are most resilient in ${rankingYear}?`, answer: `The ${prettyDate(capturedAt)} Country Resilience Index snapshot ranks ${rankedCountries.length} of ${countries.length} countries. Its top three are ${topRankedNames.join(', ')}, with ${topRankedNames[0]} at rank 1. The index measures capacity to absorb shocks and recover; high scores reflect fiscal room, institutions, infrastructure, and supplies, while countries below the headline cutoff remain unranked instead of being guessed.`, }, { question: 'How is the Country Resilience Index calculated?', answer: 'Seventy-two published indicators are rescaled to 0-100, blended into 21 dimensions, grouped into six domains, then three pillars. Those pillars combine at 0.40/0.35/0.25, then a min-pillar penalty drags the total toward the weakest link. Coverage ships with every score; thin evidence is flagged low-confidence and held out of the ranking.', }, ]; const itemList = countries.map((country, index) => { const scorePublished = country.headlineEligible !== false; const url = absoluteUrl(baseUrl, `/countries/${country.slug}/`); return { '@type': 'ListItem', position: index + 1, name: country.name, url, item: { '@type': 'Country', name: country.name, url, ...(scorePublished ? { additionalProperty: { '@type': 'PropertyValue', name: 'Country Resilience Index score', value: country.overallScore, minValue: 0, maxValue: 100, }, } : {}), }, }; }); const body = `

For country-risk analysts

Country risk, instability and resilience by country

${escapeHtml(description)}

Live Country Instability Index rankings · Country Resilience Index ranking

For the evergreen monitoring procedure that uses these pages as evidence, see Monitor country risk.

${hubDashboardCta('countries', baseUrl)}

${hubFaqs.map((faq) => `

${escapeHtml(faq.question)}

${escapeHtml(faq.answer)}

`).join('\n')}
${countries.map((country) => { const scorePublished = country.headlineEligible !== false; const scoreCell = scorePublished ? `${escapeHtml(formatScore(country.overallScore, { coverage: scorePublished }))}` : '—'; return ` `; }).join('\n')}
${escapeHtml(prettyDate(capturedAt))} Country Resilience Index snapshot
RankCountryScoreCoverageConfidence
${country.rank == null ? 'Outside headline ranking' : `#${country.rank}`}${escapeHtml(country.name)} ${escapeHtml(country.code)}${scoreCell}${escapeHtml(formatPercent(country.dimensionCoverage))}${country.lowConfidence ? 'Low' : 'Standard'}

Source: World Monitor Country Resilience Index snapshot, ${escapeHtml(prettyDate(capturedAt))}. Methodology: Country Resilience Index.

`; return pageDocument({ baseUrl, path, title: 'Country Risk Scores and Rankings by Country | World Monitor', description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Country risk, instability and resilience by country', description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', mainEntity: { '@id': datasetId }, }, faqPageLd(hubFaqs), { '@context': 'https://schema.org', '@type': 'ItemList', name: 'Country Resilience Index ranking', numberOfItems: countries.length, itemListOrder: 'https://schema.org/ItemListOrderAscending', itemListElement: itemList, }, { '@context': 'https://schema.org', '@type': 'Dataset', '@id': datasetId, name: `World Monitor Country Resilience Index snapshot for ${capturedAt}`, description, url: absoluteUrl(baseUrl, path), identifier: 'country-resilience-ranking', keywords: ['country resilience', 'country risk', 'resilience index', 'global indicators'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: capturedAt, dateModified: capturedAt, temporalCoverage: datasetTemporalCoverage(capturedAt), spatialCoverage: 'Worldwide', isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), variableMeasured: { '@type': 'PropertyValue', name: 'Country resilience score', minValue: 0, maxValue: 100, unitText: 'index points', }, distribution: dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, COUNTRIES_INDEX_DATASET_DOWNLOAD))), }, dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Countries', path }, ]), body, }); } const PILLAR_LABELS = { 'structural-readiness': 'Structural readiness', 'live-shock-exposure': 'Live shock exposure', 'recovery-capacity': 'Recovery capacity', }; const DOMAIN_LABELS = { economic: 'Economic capacity', infrastructure: 'Infrastructure', energy: 'Energy security', 'social-governance': 'Social and governance', 'health-food': 'Health, food and water', recovery: 'Recovery capacity', }; const DIMENSION_LABELS = { macroFiscal: 'Macro-fiscal position', currencyExternal: 'Currency and external balance', tradePolicy: 'Trade policy resilience', financialSystemExposure: 'Financial-system exposure', cyberDigital: 'Cyber and digital capacity', logisticsSupply: 'Logistics and supply chains', infrastructure: 'Core infrastructure', energy: 'Energy system resilience', governanceInstitutional: 'Governance and institutions', socialCohesion: 'Social cohesion', borderSecurity: 'Border security', informationCognitive: 'Information environment', education: 'Education capacity', healthPublicService: 'Health and public services', foodWater: 'Food and water security', fiscalSpace: 'Fiscal space', reserveAdequacy: 'Reserve adequacy', externalDebtCoverage: 'External-debt coverage', importConcentration: 'Import concentration', stateContinuity: 'State continuity', fuelStockDays: 'Fuel-stock buffer', liquidReserveAdequacy: 'Liquid-reserve adequacy', sovereignFiscalBuffer: 'Sovereign fiscal buffer', }; const DIMENSION_PRIMARY_SOURCES = Object.freeze({ macroFiscal: ['IMF'], currencyExternal: ['IMF', 'World Bank'], tradePolicy: ['WTO', 'World Bank'], financialSystemExposure: ['BIS', 'World Bank IDS', 'FATF'], cyberDigital: ['cyber and outage monitors'], logisticsSupply: ['World Bank'], infrastructure: ['World Bank'], energy: ['World Bank', 'IEA', 'OWID'], governanceInstitutional: ['World Bank WGI'], socialCohesion: ['IEP', 'UNHCR', 'UCDP'], borderSecurity: ['UCDP', 'UNHCR'], informationCognitive: ['Reporters Without Borders'], education: ['World Bank'], healthPublicService: ['WHO'], foodWater: ['FAO IPC', 'World Bank'], fiscalSpace: ['IMF'], liquidReserveAdequacy: ['World Bank'], externalDebtCoverage: ['World Bank'], importConcentration: ['UN Comtrade'], stateContinuity: ['World Bank WGI', 'UCDP', 'UNHCR'], sovereignFiscalBuffer: ['sovereign-wealth records'], }); function humanizeId(value) { return String(value || '') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/-/g, ' ') .replace(/^./, (letter) => letter.toUpperCase()); } function formatProseList(items) { const values = [...new Set(items.map((item) => String(item || '').trim()).filter(Boolean))]; if (values.length === 0) return ''; if (values.length === 1) return values[0]; if (values.length === 2) return `${values[0]} and ${values[1]}`; return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`; } function dimensionLabel(dimension) { return DIMENSION_LABELS[dimension.id] || humanizeId(dimension.id); } function activeCountryDimensions(country) { return (country.domains || []).flatMap((domain) => ( (domain.dimensions || []) .filter((dimension) => !RETIRED_DIMENSION_IDS.has(dimension.id)) .map((dimension) => ({ ...dimension, domainId: domain.id, })) )); } function isCoverageGap(dimension) { const imputationClass = String(dimension.imputationClass || ''); if (imputationClass === 'not-applicable') return false; if (imputationClass === 'source-failure' || imputationClass === 'unmonitored') return true; return Number(dimension.coverage) === 0; } function compareDimensionsByCoverageAsc(left, right) { return Number(left.coverage) - Number(right.coverage) || left.id.localeCompare(right.id, 'en-US'); } function compareDimensionsByCoverageDesc(left, right) { return Number(right.coverage) - Number(left.coverage) || left.id.localeCompare(right.id, 'en-US'); } function dimensionSources(dimension) { return DIMENSION_PRIMARY_SOURCES[dimension.id] || []; } export function describeHeadlineIneligibilityReason(country) { const coverage = Number(country.dimensionCoverage); const imputation = Number(country.imputationShare); const coverageText = formatPercent(country.dimensionCoverage); const imputationText = formatPercent(country.imputationShare); if (country.lowConfidence) { const coverageMiss = Number.isFinite(coverage) && coverage < LOW_CONFIDENCE_MIN_COVERAGE; const imputationMiss = Number.isFinite(imputation) && imputation > LOW_CONFIDENCE_MAX_IMPUTATION; if (coverageMiss && imputationMiss) { return `${country.name}'s coverage is ${coverageText} and imputation share is ${imputationText}, so the snapshot is low-confidence.`; } if (coverageMiss) { return `${country.name}'s coverage is ${coverageText}, below the ${Math.round(LOW_CONFIDENCE_MIN_COVERAGE * 100)}% confidence gate and the ${Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100)}% ranking floor.`; } if (imputationMiss) { return `${country.name}'s imputation share is ${imputationText}, above the ${Math.round(LOW_CONFIDENCE_MAX_IMPUTATION * 100)}% confidence limit.`; } return `${country.name}'s snapshot is flagged low-confidence, so no rank is published.`; } if (Number.isFinite(coverage) && coverage < HEADLINE_RANKING_MIN_COVERAGE) { return `${country.name}'s coverage is ${coverageText}, below the ${Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100)}% ranking floor.`; } const strongest = activeCountryDimensions(country) .filter((dimension) => ( Number(dimension.coverage) > 0 && String(dimension.imputationClass || '') !== 'not-applicable' )) .sort(compareDimensionsByCoverageDesc) .slice(0, 3) .map((dimension) => `${dimensionLabel(dimension)} at ${formatPercent(dimension.coverage)}`); const strongestClause = strongest.length > 0 ? ` Strongest observed coverage is ${formatProseList(strongest)}.` : ''; return `${country.name}'s coverage is ${coverageText}, which meets the ${Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100)}% floor, but a published rank also needs a recorded population of at least ${HEADLINE_RANKING_MIN_POPULATION.toLocaleString('en-US')} or coverage of at least ${Math.round(HEADLINE_RANKING_HIGH_COVERAGE * 100)}%.${strongestClause}`; } export function describeHeadlineIneligibility(country) { return `World Monitor does not publish a resilience score or rank for ${country.name} because it does not meet the published ranking eligibility criteria.`; } export function describeCoverageGaps(country) { const dimensions = activeCountryDimensions(country); const gaps = dimensions.filter(isCoverageGap).sort(compareDimensionsByCoverageAsc); if (gaps.length === 0) { const strongest = dimensions .filter((dimension) => Number(dimension.coverage) > 0) .sort(compareDimensionsByCoverageDesc) .slice(0, 3) .map((dimension) => `${dimensionLabel(dimension)} at ${formatPercent(dimension.coverage)}`); const strongestClause = strongest.length > 0 ? `, including ${formatProseList(strongest)}` : ''; return `The active dimensions are mostly observed${strongestClause}. The unpublished rank is an eligibility-rule outcome, not a hole in the source inventory.`; } const labels = formatProseList(gaps.map(dimensionLabel)); const uniqueSources = [...new Set(gaps.flatMap(dimensionSources))]; const verb = gaps.length === 1 ? 'has' : 'have'; const sourceClause = uniqueSources.length > 0 ? ` Those slots depend on ${formatProseList(uniqueSources)}, which ${uniqueSources.length === 1 ? 'does' : 'do'} not contribute observed series for those dimensions.` : ''; const failures = gaps.filter((dimension) => dimension.imputationClass === 'source-failure'); const unmonitored = gaps.filter((dimension) => dimension.imputationClass === 'unmonitored'); const failureClause = failures.length > 0 ? ` ${formatProseList(failures.map(dimensionLabel))} ${failures.length === 1 ? 'is' : 'are'} marked source unavailable in this snapshot.` : ''; const unmonitoredClause = unmonitored.length > 0 ? ` ${formatProseList(unmonitored.map(dimensionLabel))} ${unmonitored.length === 1 ? 'is' : 'are'} tagged unmonitored because the source does not cover ${country.name}.` : ''; return `${labels} ${verb} no usable observed series.${sourceClause}${failureClause}${unmonitoredClause}`; } function observedEvidenceDimensions(country) { return activeCountryDimensions(country).filter((dimension) => ( !isCoverageGap(dimension) && Number(dimension.coverage) >= SUPPORTED_READING_MIN_COVERAGE )); } export function describeAvailableEvidence(country) { const observed = observedEvidenceDimensions(country) .sort(compareDimensionsByCoverageDesc) .slice(0, AVAILABLE_EVIDENCE_LIMIT); if (observed.length === 0) { return `The snapshot still records coverage and evidence state for ${country.name}, even though no dimension clears a usable observed threshold. Input coverage is ${formatPercent(country.dimensionCoverage)} and imputation share is ${formatPercent(country.imputationShare)}.`; } return `Observed evidence is present for ${formatProseList(observed.map((dimension) => `${dimensionLabel(dimension)} (${formatPercent(dimension.coverage)})`))}. Input coverage is ${formatPercent(country.dimensionCoverage)} and imputation share is ${formatPercent(country.imputationShare)}.`; } function buildMicrostateEvidenceProfile(country) { const dimensions = activeCountryDimensions(country); const supportedDimensions = dimensions .filter((dimension) => ( !isCoverageGap(dimension) && String(dimension.imputationClass || '') === '' && Number(dimension.coverage) >= SUPPORTED_READING_MIN_COVERAGE && typeof dimension.score === 'number' && Number.isFinite(dimension.score) )); const gaps = dimensions.filter(isCoverageGap).sort(compareDimensionsByCoverageAsc); const supportedSourceFamilies = [...new Set(supportedDimensions.flatMap(dimensionSources))]; const gapSourceFamilies = [...new Set(gaps.flatMap(dimensionSources))]; return { supportedDimensions, supportedSourceFamilies, gapOnlySourceFamilies: gapSourceFamilies.filter((source) => !supportedSourceFamilies.includes(source)), overlappingSourceFamilies: gapSourceFamilies.filter((source) => supportedSourceFamilies.includes(source)), }; } function formatMicrostateReadings(profile) { return profile.supportedDimensions .map((dimension) => `${dimensionLabel(dimension)} ${formatScore(dimension.score, dimensionScoreEvidence(dimension))} (${formatPercent(dimension.coverage)})`); } function highlightMicrostateDimensions(profile) { return profile.supportedDimensions .slice(-3) .map(dimensionLabel); } function selectMicrostateSourceExamples(sources) { const preferred = ['World Bank', 'UCDP'].filter((source) => sources.includes(source)); return [...new Set([ ...sources.slice(0, 3), ...preferred, sources.at(-1), ])].filter(Boolean).slice(0, 6); } export function describeMicrostateEvidence(country) { if (country.microstateTerritory !== true) return ''; const profile = buildMicrostateEvidenceProfile(country); if (profile.supportedDimensions.length === 0) { return `${country.name} is in the microstate and territory cohort, but this snapshot has no observed dimension reading that can support a country-specific evidence interpretation. No overall resilience score or country rank is published.`; } const readings = formatMicrostateReadings(profile); const readingClause = `${country.name} has ${readings.length} supported dimension readings with observed inputs: ${readings.join('; ')}. Scores use a 0-100 scale; percentages show coverage.`; const sourceExamples = selectMicrostateSourceExamples(profile.supportedSourceFamilies); const supportedSourceClause = sourceExamples.length > 0 ? ` Possible dimension inputs for ${country.name}: ${formatProseList(sourceExamples)}.` : ''; const gapOnlyClause = profile.gapOnlySourceFamilies.length > 0 ? ` Inputs tied only to missing or unmonitored dimensions in ${country.name}: ${formatProseList(profile.gapOnlySourceFamilies)}.` : ''; const overlapClause = profile.overlappingSourceFamilies.length > 0 ? ` For ${country.name}, some feed families span supported and missing dimensions: ${formatProseList(profile.overlappingSourceFamilies)}.` : ''; return `This is a partial evidence snapshot. ${readingClause}${supportedSourceClause}${gapOnlyClause}${overlapClause} These readings are partial evidence, not a published overall score or a country rank.`; } export function describeMicrostateEvidenceSummary(country) { const profile = buildMicrostateEvidenceProfile(country); if (profile.supportedDimensions.length === 0) { return `${country.name} has no observed dimension reading that can support a country-specific evidence interpretation. No overall resilience score or country rank is published.`; } const highlightedDimensions = highlightMicrostateDimensions(profile); const sourceExamples = profile.supportedSourceFamilies.slice(-3); const feedClause = sourceExamples.length > 0 ? ` Possible inputs for ${country.name} include ${formatProseList(sourceExamples)}.` : ''; return `${country.name} has ${profile.supportedDimensions.length} supported dimension readings with observed inputs, including ${formatProseList(highlightedDimensions)}.${feedClause} These readings are partial evidence, not a published overall score or a country rank.`; } function microstateCoverageStoryFacts(country) { const profile = buildMicrostateEvidenceProfile(country); const coveragePercent = Math.round(Number(country.dimensionCoverage) * 100); const coverageFloor = Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100); const readings = formatMicrostateReadings(profile); const highlightedDimensions = highlightMicrostateDimensions(profile); const shortfallPoints = coverageFloor - coveragePercent; const gaps = activeCountryDimensions(country) .filter(isCoverageGap) .map((dimension) => ({ id: dimension.id, imputationClass: String(dimension.imputationClass || ''), sources: dimensionSources(dimension), })); return { code: country.code, coverage: formatPercent(country.dimensionCoverage), coverageFloor, coveragePercent, crisisRegistrySize: country.crisisRegistrySize, gapCount: gaps.length, gaps, imputationShare: formatPercent(country.imputationShare), readingCount: readings.length, readings: readings.join('; '), highlightedDimensions: formatProseList(highlightedDimensions), sourceExamples: formatProseList(selectMicrostateSourceExamples(profile.supportedSourceFamilies)), supportedDimensionIds: profile.supportedDimensions.map((dimension) => dimension.id), shortfall: `${shortfallPoints} ${shortfallPoints === 1 ? 'point' : 'points'}`, }; } export function buildMicrostateCoverageStory({ country, capturedAt, methodologyFormula, }) { if (country.microstateTerritory !== true) return null; const story = buildMicrostateCoverageStoryContent({ ...microstateCoverageStoryFacts(country), capturedDate: prettyDate(capturedAt), methodologyFormula, }); if (!story) return null; return { ...story, crisis: formatCrisisContext(country, { noMembershipText: story.crisis }), }; } export function dimensionInventoryNote(country, dimension) { const sources = dimensionSources(dimension); const sourceLabel = formatProseList(sources); const imputationClass = String(dimension.imputationClass || ''); if (imputationClass === 'not-applicable') return 'not applicable'; if (imputationClass === 'stable-absence') return 'stable absence in the source feed'; if (imputationClass === 'source-failure') { return sourceLabel ? `${sourceLabel} did not deliver a usable series in this snapshot` : 'source unavailable in this snapshot'; } if (imputationClass === 'unmonitored' || Number(dimension.coverage) === 0) { return sourceLabel ? `${sourceLabel} ${sources.length === 1 ? 'does' : 'do'} not contribute observed series for ${country.name}` : `no observed series for ${country.name}`; } return 'observed'; } // Dimensions the page can call supported readings. The microstate branch // enumerates this set exhaustively and counts it out loud; the generic branch // prints only the strongest AVAILABLE_EVIDENCE_LIMIT of it and makes no claim to // be complete, so the display cap is not part of the set either way. function supportedReadingDimensions(country) { return country.microstateTerritory === true ? buildMicrostateEvidenceProfile(country).supportedDimensions : observedEvidenceDimensions(country); } function buildCountryUnrankedInventory(country) { const supported = new Set(supportedReadingDimensions(country).map((dimension) => dimension.id)); const dimensions = activeCountryDimensions(country).map((dimension) => ({ id: dimension.id, label: dimensionLabel(dimension), coverage: dimension.coverage, isCoverageGap: isCoverageGap(dimension), isNotApplicable: String(dimension.imputationClass || '') === 'not-applicable', inventoryNote: dimensionInventoryNote(country, dimension), supported: supported.has(dimension.id), value: dimension, })); return buildUnrankedCountryInventory({ countryCode: country.code, dimensions, inventoryLimit: UNRANKED_INVENTORY_LIMIT, supportFloor: SUPPORTED_READING_MIN_COVERAGE, }); } export function describeInventoryScope(country) { return buildCountryUnrankedInventory(country).inventoryScope; } export function describeSupportThreshold(country) { return buildCountryUnrankedInventory(country).supportThreshold; } export function assertUnrankedInventoryIntegrity(country, rendered = {}) { buildCountryUnrankedInventory(country).assertIntegrity(rendered); } function formatSignedScore(value, evidence) { return formatObservedNumber(value, evidence, (numeric) => ( `${numeric > 0 ? '+' : ''}${formatScoreNumber(numeric)}` ), 'not available'); } function countryFaqs(country, capturedAt, rankedCount, ciiEntry = null) { const ciiFaq = ciiEntry ? [{ question: `What is ${country.name}'s Country Instability Index?`, answer: `${country.name}'s Country Instability Index is ${formatScore(ciiEntry.score, OBSERVED_EVIDENCE)}/100 (${ciiEntry.band})${ciiMovementClause(ciiEntry)}, as of ${formatStaticDateTime(ciiEntry.asOf)}.`, }] : []; const scorePublished = country.headlineEligible !== false; if (!scorePublished) { return [ ...ciiFaq, { question: `What is ${country.name}'s resilience score?`, answer: `No resilience score or rank is published for ${country.name}. ${country.name}'s published rank would require coverage of at least ${Math.round(HEADLINE_RANKING_MIN_COVERAGE * 100)}%, no low-confidence flag, and either a population of at least ${HEADLINE_RANKING_MIN_POPULATION.toLocaleString('en-US')} or coverage of at least ${Math.round(HEADLINE_RANKING_HIGH_COVERAGE * 100)}%. Low confidence for ${country.name} means coverage falls below ${Math.round(LOW_CONFIDENCE_MIN_COVERAGE * 100)}% or imputation share exceeds ${Math.round(LOW_CONFIDENCE_MAX_IMPUTATION * 100)}%.`, }, { question: `What evidence is available for ${country.name}?`, answer: describeCountryAvailableEvidenceFaq(country), }, { question: `How should readers use ${country.name}'s page?`, answer: `Use the evidence inventory and nearest ranked comparators with the live monitor. A missing score does not mean risk is absent.`, }, ]; } const pillars = observedPillarsOf([...country.pillars].sort((left, right) => left.score - right.score)); const weakest = pillars[0]; const second = pillars[1]; const weakestPillarsAnswer = weakest && second ? `${PILLAR_LABELS[weakest.id] || humanizeId(weakest.id)} is lowest at ${formatScore(weakest.score, { coverage: weakest.coverage })}, followed by ${PILLAR_LABELS[second.id] || humanizeId(second.id)} at ${formatScore(second.score, { coverage: second.coverage })}. Their evidence coverage is ${formatPercent(weakest.coverage)} and ${formatPercent(second.coverage)}.` : weakest ? `${PILLAR_LABELS[weakest.id] || humanizeId(weakest.id)} is the only pillar with an observed reading, at ${formatScore(weakest.score, { coverage: weakest.coverage })} with ${formatPercent(weakest.coverage)} evidence coverage. The other pillars are withheld because this snapshot has no observed evidence for them.` : `No pillar has an observed reading in this snapshot, so no pillar can be ranked weakest.`; const rankText = country.rank == null ? 'outside the headline ranking because the snapshot labels its evidence low-confidence' : `#${country.rank} of ${rankedCount} ranked countries`; return [ ...ciiFaq, { question: `What is ${country.name}'s resilience score?`, answer: `${country.name} scores ${formatScore(country.overallScore, { coverage: scorePublished })} out of 100 in the ${prettyDate(capturedAt)} structural snapshot and sits ${rankText}. This is a comparative index, not a crisis probability.`, }, { question: `Which resilience pillars are weakest for ${country.name}?`, answer: weakestPillarsAnswer, }, { question: `Is ${country.name}'s resilience score rising or falling?`, answer: `The 30-day reading is ${country.trend || 'unknown'}, with a change of ${formatSignedScore(country.change30d, { coverage: scorePublished })} points. It is structural and separate from the live instability monitor.`, }, ]; } function describeCountryAvailableEvidence(country) { return country.microstateTerritory === true ? describeMicrostateEvidence(country) : describeAvailableEvidence(country); } function describeCountryAvailableEvidenceFaq(country) { return country.microstateTerritory === true ? describeMicrostateEvidenceSummary(country) : describeAvailableEvidence(country); } function formatCrisisContext(country, { links = false, noMembershipText = null } = {}) { const countryName = links ? escapeHtml(country.name) : country.name; if (country.crisisMemberships.length > 0) { const memberships = country.crisisMemberships .map((crisis) => links ? `${escapeHtml(crisis.shortTitle)}` : crisis.shortTitle) .join(', '); return `The crisis registry links ${countryName} to ${memberships}. Tracker scopes are fixed and do not cover every crisis.`; } if (noMembershipText != null) return links ? escapeHtml(noMembershipText) : noMembershipText; return `${countryName} has no dedicated crisis tracker; the ${country.crisisRegistrySize} trackers cover selected crises. That is a coverage limit, not a statement about risk.`; } export function renderCountryAnalysis({ country, capturedAt, methodologyFormula, rankedCount, ciiEntry = null }) { const scorePublished = country.headlineEligible !== false; if ((country.pillars?.length ?? 0) < 3 || (country.domains?.length ?? 0) < 6) { throw new Error(`${country.code} is missing country-analysis pillar or domain details`); } // A peer whose own page publishes no headline score gets no parenthetical at all. // Rendering an empty "(—)" would advertise a withheld value instead of omitting it. const peerLink = (peer) => { const score = scorePublished ? formatScore(peer.overallScore, { coverage: peer.headlineEligible !== false }) : '—'; // Peers are ordered by rank distance, not score, so the rank is shown // with the score; scores alone read as an unsorted list. const rankLabel = peer.rank == null ? '' : `#${peer.rank}, `; const suffix = score === '—' ? '' : ` (${escapeHtml(rankLabel)}${escapeHtml(score)})`; return `${escapeHtml(peer.name)}${suffix}`; }; const peerLinks = country.peers.map(peerLink).join(', '); const regionalLinks = country.regionalPeers.map(peerLink).join(', '); const coverageStory = !scorePublished ? buildMicrostateCoverageStory({ country, capturedAt, methodologyFormula }) : null; const crisisText = formatCrisisContext(country, { links: true, noMembershipText: coverageStory?.crisis ?? null, }); const faqs = coverageStory?.faqs || countryFaqs(country, capturedAt, rankedCount, ciiEntry); if (!scorePublished) { const unrankedInventory = buildCountryUnrankedInventory(country); const inventory = unrankedInventory.shown; const inventoryItems = inventory.length > 0 ? inventory.map((dimension) => `
  • ${escapeHtml(dimensionLabel(dimension))}: ${escapeHtml(formatPercent(dimension.coverage))} coverage; ${escapeHtml(dimensionInventoryNote(country, dimension))}.
  • `).join('\n') : `
  • ${escapeHtml(country.name)} has no active dimension inventory after retired slots are removed.
  • `; const { inventoryScope, supportThreshold } = unrankedInventory; unrankedInventory.assertIntegrity({ inventoryScope, supportThreshold }); const inventoryPreamble = [ inventoryScope ? `

    ${escapeHtml(inventoryScope)}

    \n` : '', supportThreshold ? `

    ${escapeHtml(supportThreshold)}

    \n` : '', ].join(''); if (coverageStory) { const comparatorLinks = coverageStory.useRegionalComparators ? regionalLinks : peerLinks; const html = `

    ${escapeHtml(country.name)} resilience evidence

    ${escapeHtml(coverageStory.introduction)}

    Source inventory gaps

    ${escapeHtml(coverageStory.gap)}

    What the snapshot does cover

    ${escapeHtml(coverageStory.evidence)}

    Dimension evidence inventory

    ${inventoryPreamble}
      ${inventoryItems}

    Nearest ranked comparators

    ${escapeHtml(coverageStory.comparatorLead)} Nearest ranked comparators: ${comparatorLinks}. ${escapeHtml(coverageStory.comparatorTail)}

    Tracked crisis context

    ${crisisText}

    Reading limits

    ${escapeHtml(coverageStory.limits)}

    Questions about ${escapeHtml(country.name)}

    ${faqs.map((faq) => `
    ${escapeHtml(faq.question)}

    ${escapeHtml(faq.answer)}

    `).join('\n')}
    `; // readingGuide and suppressScoreDisclosure make renderCountryPage swap the // shared "How to read this page" block, its snapshot note and the // score-disclosure paragraph for the country-specific reading guide (#7527). return { html, faqs, readingGuide: coverageStory.readingGuide, suppressScoreDisclosure: true, }; } const officialName = country.identity?.officialName; const officialBit = officialName && officialName !== country.name ? ` Officially ${officialName}.` : ''; const status = getSovereignStatus(country.code); // The internal sar bucket also includes Taiwan; describe membership only. const statusBit = status === 'sar' ? ` ${country.name} is included separately in the rankable universe.` : status === 'un-member' ? ` ${country.name} is in the rankable universe as a UN member.` : ''; const availableEvidence = describeCountryAvailableEvidence(country); const html = `

    ${escapeHtml(country.name)} resilience analysis

    ${escapeHtml(describeHeadlineIneligibility(country))}${escapeHtml(officialBit)}${escapeHtml(statusBit)} Ranked comparisons use ${escapeHtml(country.regionName)} peers rather than other unpublished pages. The snapshot records ${escapeHtml(country.name)} as ${escapeHtml(country.code)}.

    Why ${escapeHtml(country.name)} is unpublished

    ${escapeHtml(describeHeadlineIneligibilityReason(country))}

    Source inventory gaps

    ${escapeHtml(describeCoverageGaps(country))}

    What the snapshot does cover

    ${escapeHtml(availableEvidence)}

    Dimension evidence inventory

    ${inventoryPreamble}
      ${inventoryItems}

    Nearest ranked comparators

    Nearest ranked comparators: ${peerLinks}. Ranked comparisons in ${escapeHtml(country.regionName)}: ${regionalLinks}. Links do not use unpublished scores.

    Tracked crisis context

    ${crisisText}

    Reading limits

    Evidence comes from the ${escapeHtml(prettyDate(capturedAt))} snapshot. ${escapeHtml(country.name)} coverage is ${escapeHtml(formatPercent(country.dimensionCoverage))} with imputation share ${escapeHtml(formatPercent(country.imputationShare))}. No score is published.

    Questions about ${escapeHtml(country.name)}

    ${faqs.map((faq) => `
    ${escapeHtml(faq.question)}

    ${escapeHtml(faq.answer)}

    `).join('\n')}
    `; return { html, faqs }; } const pillars = [...country.pillars].sort((left, right) => left.score - right.score); const domains = [...country.domains].sort((left, right) => left.score - right.score); const observedPillars = observedPillarsOf(pillars); const observedDomains = observedDomainsOf(domains); const [weakestPillar, secondPillar] = pillars; const strongestPillar = pillars.at(-1); const [weakestDomain] = domains; const trendSentence = hasObservedValue(country.change30d, { coverage: scorePublished }) ? `Across the recorded 30-day window, the index is ${country.trend} at ${formatSignedScore(country.change30d, { coverage: scorePublished })} points.` : 'The committed snapshot does not contain a comparable 30-day change.'; const weakestPillarLabel = PILLAR_LABELS[weakestPillar.id] || humanizeId(weakestPillar.id); const secondPillarLabel = PILLAR_LABELS[secondPillar.id] || humanizeId(secondPillar.id); const strongestPillarLabel = PILLAR_LABELS[strongestPillar.id] || humanizeId(strongestPillar.id); const weakestDomainLabel = DOMAIN_LABELS[weakestDomain.id] || humanizeId(weakestDomain.id); // Every pillar and domain observed is the normal case and keeps the published // wording exactly. Otherwise the claim is rebuilt from observed entries only, // so prose never names a withheld pillar or domain as weakest or strongest. const pillarDomainClause = observedPillars.length === pillars.length && observedDomains.length === domains.length ? `${weakestPillarLabel} is the weakest pillar at ${formatScore(weakestPillar.score, { coverage: weakestPillar.coverage })}, with ${secondPillarLabel.toLowerCase()} next at ${formatScore(secondPillar.score, { coverage: secondPillar.coverage })}. ${strongestPillarLabel} is strongest at ${formatScore(strongestPillar.score, { coverage: strongestPillar.coverage })}, while ${weakestDomainLabel} is the lowest of the six underlying domains at ${formatScore(weakestDomain.score, domainScoreEvidence(weakestDomain))}.` : [ observedPillars.length > 0 ? `Pillars with an observed reading, weakest first: ${observedPillars.map((pillar) => `${PILLAR_LABELS[pillar.id] || humanizeId(pillar.id)} ${formatScore(pillar.score, { coverage: pillar.coverage })}`).join('; ')}. ${pillars.length - observedPillars.length} of ${pillars.length} pillars are withheld for lack of observed evidence.` : `No pillar has an observed reading in this snapshot.`, observedDomains.length > 0 ? `${DOMAIN_LABELS[observedDomains[0].id] || humanizeId(observedDomains[0].id)} is the lowest of the ${observedDomains.length} domains with an observed reading, at ${formatScore(observedDomains[0].score, domainScoreEvidence(observedDomains[0]))}.` : `No domain has an observed reading in this snapshot.`, ].join(' '); const topObservedDomain = observedDomains.at(-1); const topDomainSentence = topObservedDomain ? `Top domain: ${DOMAIN_LABELS[topObservedDomain.id] || humanizeId(topObservedDomain.id)}, ${formatScore(topObservedDomain.score, domainScoreEvidence(topObservedDomain))}.` : 'No domain has an observed reading to report as the top domain.'; const summary = `${country.name} ranks #${country.rank} of ${rankedCount} countries with an overall resilience score of ${formatScore(country.overallScore, { coverage: scorePublished })} out of 100. ${pillarDomainClause} ${trendSentence} Dimension coverage is ${formatPercent(country.dimensionCoverage)}, and the page labels confidence as ${country.lowConfidence ? 'low' : 'standard'}.`; const allDimensionRows = domains.flatMap((domain) => domain.dimensions.map((dimension) => ({ ...dimension, domainId: domain.id, }))); const observedDimensionRows = allDimensionRows .filter(hasObservedDimensionScore) .sort((left, right) => left.score - right.score || left.id.localeCompare(right.id, 'en-US')); const withheldDimensionRows = allDimensionRows .filter((dimension) => !hasObservedDimensionScore(dimension)) .sort((left, right) => left.domainId.localeCompare(right.domainId, 'en-US') || left.id.localeCompare(right.id, 'en-US')); const dimensionRows = [...observedDimensionRows, ...withheldDimensionRows]; const html = `

    ${escapeHtml(country.name)} resilience analysis

    ${escapeHtml(summary)}

    Pillar profile

      ${pillars.map((pillar) => `
    • ${escapeHtml(PILLAR_LABELS[pillar.id] || humanizeId(pillar.id))}: ${escapeHtml(formatScore(pillar.score, { coverage: pillar.coverage }))}; coverage ${escapeHtml(formatPercent(pillar.coverage))}; domains ${pillar.domainIds.map((id) => escapeHtml(DOMAIN_LABELS[id] || humanizeId(id))).join(', ')}.
    • `).join('\n')}

    Six-domain profile

      ${domains.map((domain) => { const dimensions = domain.dimensions .filter(hasObservedDimensionScore) .sort((left, right) => left.score - right.score); const weakest = dimensions[0]; const strongest = dimensions.at(-1); const dimensionSummary = dimensions.length === 0 ? 'no observed dimension score' : dimensions.length === 1 ? `${DIMENSION_LABELS[weakest.id] || humanizeId(weakest.id)} ${formatScore(weakest.score, dimensionScoreEvidence(weakest))}` : `low ${DIMENSION_LABELS[weakest.id] || humanizeId(weakest.id)} ${formatScore(weakest.score, dimensionScoreEvidence(weakest))}; high ${DIMENSION_LABELS[strongest.id] || humanizeId(strongest.id)} ${formatScore(strongest.score, dimensionScoreEvidence(strongest))}`; return `
    • ${escapeHtml(DOMAIN_LABELS[domain.id] || humanizeId(domain.id))}: ${escapeHtml(formatScore(domain.score, domainScoreEvidence(domain)))}; weight ${escapeHtml(formatPercent(domain.weight))}; ${escapeHtml(dimensionSummary)}.
    • `; }).join('\n')}

    Dimension evidence, observed scores weakest first

    ${dimensionRows.map((dimension) => ` `).join('\n')}
    DimensionDomainScoreCoverageEvidence state
    ${escapeHtml(DIMENSION_LABELS[dimension.id] || humanizeId(dimension.id))}${escapeHtml(DOMAIN_LABELS[dimension.domainId] || humanizeId(dimension.domainId))}${escapeHtml(formatScore(dimension.score, dimensionScoreEvidence(dimension)))}${escapeHtml(formatPercent(dimension.coverage))}${escapeHtml(dimensionEvidenceState(dimension))}

    Comparison set

    Nearest ranked peers: ${peerLinks}. Comparisons in ${escapeHtml(country.regionName)}: ${regionalLinks}. Similar scores do not mean equal conditions.

    Tracked crisis context

    ${crisisText}

    Reading limits

    Scores come from the ${escapeHtml(prettyDate(capturedAt))} snapshot. ${escapeHtml(topDomainSentence)} Weak pillars pull the overall score down; read each score alongside its coverage and imputation.

    Questions about ${escapeHtml(country.name)}

    ${faqs.map((faq) => `
    ${escapeHtml(faq.question)}

    ${escapeHtml(faq.answer)}

    `).join('\n')}
    `; return { html, faqs }; } // "Recent developments in {Country}" (#7615). Every rendered item is a dated, // sourced, country-specific frozen row — headline (linked title, outlet, // publication time), intel brief (generated text with its cited sources), or // timeline event. Rows are validated on the way in: render throws rather than // publishing an unattributable item, so a malformed frozen row reds the build // instead of reaching a crawler. // // The movement sentence states co-occurrence, never causation: the frozen // numbers moved in the same window the reporting was captured. Asserting that // a headline *drove* a score move would be fabrication — only an analyst (or // the brief, which cites its sources) may draw that link. function unwrapBriefEmphasisLine(line) { let current = String(line || '').trim(); for (let i = 0; i < 4; i++) { const next = current .replace(/^#{1,6}\s+/, '') .replace(/^\*\*(.*)\*\*$/, '$1') .trim(); if (next === current) break; current = next; } return current; } function applyCrawlableBriefEmphasis(escaped) { return escaped.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*\*/g, ''); } // Frozen intel briefs are markdown-ish LLM text. Country pages are prerendered // HTML for crawlers, so convert emphasis and promote the five section titles // rather than injecting the string into a

    . Always rewrite the "means for" // title from the page's country name: stored briefs still contain ISO codes // from the TIER1-only prompt fallback (#7738). export function formatCrawlableIntelBrief(text, countryName) { const name = String(countryName || '').trim(); if (!name) throw new Error('formatCrawlableIntelBrief requires a country name'); const out = []; let listOpen = false; const closeList = () => { if (listOpen) { out.push(' '); listOpen = false; } }; const openList = () => { if (!listOpen) { out.push('

      '); listOpen = true; } }; for (const rawLine of String(text || '').split('\n')) { const trimmed = unwrapBriefEmphasisLine(rawLine.trim()); if (!trimmed) { closeList(); continue; } if (isBriefSectionHeader(trimmed, { countryName: name })) { closeList(); const heading = /^WHAT THIS MEANS FOR\b/i.test(trimmed) ? `What this means for ${name}` : trimmed.replace(/:\s*$/, '').toLowerCase().replace(/^./, (letter) => letter.toUpperCase()); out.push(`

      ${escapeHtml(heading)}

      `); continue; } if (/^(?:[•\-]\s*|\*\s+)/.test(trimmed)) { openList(); const item = applyCrawlableBriefEmphasis(escapeHtml(trimmed.replace(/^(?:[•\-]\s*|\*\s+)/, ''))); out.push(`
    • ${item}
    • `); continue; } closeList(); if (/^NEXT \d/i.test(trimmed)) { const colonIdx = trimmed.indexOf(':'); if (colonIdx !== -1) { const label = applyCrawlableBriefEmphasis(escapeHtml(trimmed.slice(0, colonIdx))); const body = applyCrawlableBriefEmphasis(escapeHtml(trimmed.slice(colonIdx + 1).trim())); out.push(`

      ${label}: ${body}

      `); continue; } } out.push(`

      ${applyCrawlableBriefEmphasis(escapeHtml(trimmed))}

      `); } closeList(); return out.join('\n'); } function briefHeadingText(heading, name) { return /^WHAT THIS MEANS FOR\b/i.test(heading) ? `What this means for ${name}` : heading.replace(/:\s*$/, '').toLowerCase().replace(/^./, (letter) => letter.toUpperCase()); } // Sections a page shows from an evidence-grounded brief. Situation is left // out: it restates the Recent developments list rendered directly above it. function publishedBriefSections(brief, country) { return parseBriefSections(brief.text, country).filter((section) => section.key !== 'situation'); } // Evidence-grounded brief (sections + World Monitor data points). Returns '' // when no section beyond Situation survived: an empty brief block tells a // reader nothing, so the page shows none. function renderStructuredIntelBrief(brief, name, countryCode) { const sections = publishedBriefSections(brief, { countryCode, countryName: name }); if (sections.length === 0) return ''; const citedSources = new Set(); const citedEvidence = new Set(); const blocks = sections.map((section) => { const claims = section.claims.map((claim) => { for (const index of claim.sourceIndexes) citedSources.add(index); for (const id of claim.evidenceIds) citedEvidence.add(id); const markers = [...claim.sourceIndexes.map((index) => `[${index}]`), ...claim.evidenceIds.map((id) => `[${id}]`)].join(''); return `

      ${escapeHtml(claim.text)} ${escapeHtml(markers)}

      `; }); return [`

      ${escapeHtml(briefHeadingText(section.heading, name))}

      `, ...claims].join('\n'); }); const evidence = (Array.isArray(brief.evidence) ? brief.evidence : []).filter((item) => citedEvidence.has(item.id)); const evidenceItems = evidence.map((item) => { const label = `${escapeHtml(item.id)} · ${escapeHtml(item.label)}: ${escapeHtml(item.value)}`; const linked = item.url ? `${label}` : label; return `
    • ${linked} ·
    • `; }); const evidenceList = evidenceItems.length > 0 ? `\n

      World Monitor data cited

      \n
        \n${evidenceItems.join('\n')}\n
      ` : ''; const counts = [ citedSources.size > 0 ? `${citedSources.size} cited ${citedSources.size === 1 ? 'report' : 'reports'}` : '', evidence.length > 0 ? `${evidence.length} World Monitor data ${evidence.length === 1 ? 'point' : 'points'}` : '', ].filter(Boolean).join(' and '); const credit = `Automated summary of ${counts}, generated .`; return `
      \n

      Country brief

      \n${blocks.join('\n')}${evidenceList}\n

      ${credit}

      \n
      `; } function isWorldMonitorUrl(url) { try { return new URL(url).hostname === 'www.worldmonitor.app'; } catch { return false; } } export function renderCountryDevelopments({ countryCode = '', countryName, developments, ciiEntry = null, pulse = null }) { const name = String(countryName || '').trim(); if (!name) throw new Error('renderCountryDevelopments requires a country name'); const rawRows = developments && typeof developments === 'object' ? developments : null; // Validate the frozen shape before the publish rules run: a malformed brief // must red the build, not be quietly withheld as thin grounding. if (rawRows?.brief && typeof rawRows.brief === 'object') assertDevelopmentsBrief(rawRows.brief); // Publish rules (#7738, #7748): markdown emphasis stripped, model preamble // dropped, the ISO-code heading repaired to the country name, and briefs // grounded on fewer than MIN_BRIEF_GROUNDING_PUBLISHERS withheld. loadCorpusData // already applied them to the committed snapshot; this call is idempotent // so direct callers get the same page. const rows = rawRows ? normalizeFrozenDevelopments(rawRows, { countryCode, countryName: name }) : null; const headlines = Array.isArray(rows?.headlines) ? rows.headlines : []; const brief = rows?.brief && typeof rows.brief === 'object' ? rows.brief : null; const timeline = Array.isArray(rows?.timeline) ? rows.timeline : []; for (const headline of headlines) assertDevelopmentsHeadline(headline); if (brief) assertDevelopmentsBrief(brief); for (const event of timeline) assertDevelopmentsTimelineEvent(event); const movementSentence = developmentsHasDatedItem(rows) ? describeDevelopmentsMovement({ countryName: name, ciiEntry, pulse }) : ''; const briefExtraSources = brief ? (Array.isArray(brief.sources) ? brief.sources : []) .filter((source) => source && typeof source.url === 'string' && !headlines.some((headline) => headline.url === source.url)) : []; for (const source of briefExtraSources) assertDevelopmentsHeadline(source); const parts = []; if (movementSentence) parts.push(`

      ${movementSentence}

      `); if (headlines.length > 0 || briefExtraSources.length > 0) { const items = [...headlines, ...briefExtraSources] // An index row (#7748) links to whatever host GDELT crawled, not a // curated feed: it is published as a dated, sourced development but // earns no link equity from an indexed page. .map((headline) => `
    • ${escapeHtml(headline.title)} ${escapeHtml(headline.source)} ·
    • `) .join('\n'); parts.push(`
        \n${items}\n
      `); } // A skipped or withheld brief renders nothing: the reason is pipeline // bookkeeping (it stays in the snapshot and the build log), and printing it // told readers the page had failed. The model id is never shown. if (brief && Array.isArray(brief.evidence)) { const briefHtml = renderStructuredIntelBrief(brief, name, countryCode); if (briefHtml) parts.push(briefHtml); } else if (brief) { const briefHtml = formatCrawlableIntelBrief(brief.text, name); const credit = `Automated summary of ${brief.sources.length} cited ${brief.sources.length === 1 ? 'report' : 'reports'}, generated .`; parts.push(`
      \n

      Country brief

      \n${briefHtml}\n

      ${credit}

      \n
      `); } if (timeline.length > 0) { const events = timeline .map((event) => { const sourceBit = ` source`; const summaryBit = event.summary ? ` — ${escapeHtml(event.summary)}` : ''; const domainBit = event.domain ? ` ${escapeHtml(event.domain)}` : ''; return `
    • ${escapeHtml(event.title)}${summaryBit} ${domainBit}${sourceBit}
    • `; }) .join('\n'); parts.push(`
        \n${events}\n
      `); } if (parts.length === 0) { parts.push(`

      No recent reporting on ${escapeHtml(name)} was captured in this snapshot.

      `); } const inner = parts.join('\n'); return `
      \n

      Recent developments in ${escapeHtml(name)}

      \n${inner}\n
      `; } function isValidHttpsUrl(value) { if (typeof value !== 'string') return false; try { const parsed = new URL(value); return parsed.protocol === 'https:' && Boolean(parsed.hostname); } catch { return false; } } function assertDevelopmentsHeadline(headline) { const valid = headline && typeof headline === 'object' && typeof headline.title === 'string' && headline.title.trim() && typeof headline.source === 'string' && headline.source.trim() && isValidHttpsUrl(headline.url) && typeof headline.publishedAt === 'string' && isCanonicalIsoInstant(headline.publishedAt); if (!valid) throw new Error('country developments headline is missing title, source, https URL, or ISO publication time'); } function assertDevelopmentsBrief(brief) { if (typeof brief.text !== 'string' || !brief.text.trim()) { throw new Error('country developments brief carries no text'); } if (typeof brief.generatedAt !== 'string' || !isCanonicalIsoInstant(brief.generatedAt)) { throw new Error('country developments brief is missing a canonical ISO generation time'); } if (!Array.isArray(brief.sources) || brief.sources.length === 0) { throw new Error('country developments brief carries no grounding sources'); } for (const source of brief.sources) assertDevelopmentsHeadline(source); const citations = [...brief.text.matchAll(/\[(\d+)\]/g)] .map((match) => Number(match[1])); if (citations.length === 0) { throw new Error('country developments brief carries no source citation'); } if (citations.some((citation) => citation < 1 || citation > brief.sources.length)) { throw new Error('country developments brief carries an out-of-range source citation'); } if (brief.evidence === undefined) return; if (!Array.isArray(brief.evidence)) throw new Error('country developments brief carries malformed evidence'); const evidenceIds = new Set(); for (const item of brief.evidence) { const valid = item && typeof item === 'object' && /^E\d{1,2}$/.test(item.id) && ['kind', 'label', 'value', 'factText'].every((field) => typeof item[field] === 'string' && item[field].trim()) && typeof item.asOf === 'string' && isCanonicalIsoInstant(item.asOf) && (item.url === undefined || item.url === '' || isValidHttpsUrl(item.url)); if (!valid) throw new Error('country developments brief evidence is missing id, label, value, fact text, ISO time, or an https URL'); evidenceIds.add(item.id); } for (const match of brief.text.matchAll(/\[(E\d{1,2})\]/g)) { if (!evidenceIds.has(match[1])) throw new Error('country developments brief cites evidence it does not carry'); } } function assertDevelopmentsTimelineEvent(event) { const valid = event && typeof event === 'object' && typeof event.title === 'string' && event.title.trim() && typeof event.occurredAt === 'string' && isCanonicalIsoInstant(event.occurredAt) && isValidHttpsUrl(event.sourceUrl); if (!valid) throw new Error('country developments timeline event is missing title, ISO occurrence time, or a valid source URL'); } function describeDevelopmentsMovement({ countryName, ciiEntry, pulse }) { const name = escapeHtml(countryName); if (ciiEntry && hasObservedValue(ciiEntry.score, OBSERVED_EVIDENCE) && typeof ciiEntry.asOf === 'string' && isCanonicalIsoInstant(ciiEntry.asOf)) { return `${name}'s Country Instability Index is ${escapeHtml(formatScore(ciiEntry.score, OBSERVED_EVIDENCE))}/100 · ${escapeHtml(ciiEntry.band)}${escapeHtml(ciiMovementClause(ciiEntry))}, as of . Reporting captured in the same window is listed below.`; } if (pulse && pulse.partial !== true && hasObservedValue(pulse.score, { coverage: true })) { const asOf = typeof pulse.asOf === 'string' && isCanonicalIsoInstant(pulse.asOf) ? pulse.asOf : null; return `${name}'s frozen instability pulse records ${escapeHtml(formatScore(pulse.score, { coverage: true }))} (${escapeHtml(pulse.band || 'unbanded')}), trend ${escapeHtml(isLegacyConflatedCiiMovementLabel(pulse.trend) ? 'unavailable' : pulse.trend || 'unavailable')}${asOf ? `, as of ` : ''}. Reporting captured in the same window is listed below.`; } return ''; } // Newest frozen developments instant for WebPage dateModified (#7615: // recency machine-readable per country). Null when the country captured no // dated items. Never falls back to the harvest clock: an absent date has no // page representation, same rule as the pulse asOf contract. export function newestDevelopmentsInstant(developments) { const instants = []; if (developments && typeof developments === 'object') { for (const headline of developments.headlines || []) { if (typeof headline?.publishedAt === 'string' && isCanonicalIsoInstant(headline.publishedAt)) { instants.push(headline.publishedAt); } } if (typeof developments.brief?.generatedAt === 'string' && isCanonicalIsoInstant(developments.brief.generatedAt)) { instants.push(developments.brief.generatedAt); } for (const event of developments.timeline || []) { if (typeof event?.occurredAt === 'string' && isCanonicalIsoInstant(event.occurredAt)) { instants.push(event.occurredAt); } } } instants.sort(); return instants.length > 0 ? instants.at(-1) : null; } // Durable guard (#7615): the enrichment must be permanent, not a one-off // content pass. After rendering, every frozen developments row for this // country must be present in the page HTML — a silent drop (wrong slug, lost // prop, over-eager filter) fails the build instead of shipping a page whose // snapshot claims items the crawler cannot see. export function assertCountryDevelopmentsRendered({ pagePath, html, developments, countryCode = '', countryName = '', }) { const rawRows = developments && typeof developments === 'object' ? developments : null; // Same publish rules as the renderer, so a withheld thin brief is not // reported as dropped and a repaired heading is looked for as repaired. const rows = rawRows ? normalizeFrozenDevelopments(rawRows, { countryCode, countryName }) : null; if (!rows || !developmentsHasDatedItem(rows)) return; if (!html.includes('data-country-developments')) { throw new Error(`${pagePath} is missing its recent-developments section`); } for (const headline of rows.headlines || []) { // Anchor-scoped: a bare URL substring passes when the URL merely appears // in prose or another link. The headline must render as a link. if (!html.includes(`href="${escapeHtml(headline.url)}"`)) { throw new Error(`${pagePath} dropped frozen headline ${headline.url}`); } } if (rows.brief && Array.isArray(rows.brief.evidence)) { // Structured briefs render their non-Situation claims, each as escaped // text in its own paragraph; every one must reach the page, and a brief // with none renders no block to check. for (const section of publishedBriefSections(rows.brief, { countryCode, countryName })) { for (const claim of section.claims) { if (!html.includes(escapeHtml(claim.text))) { throw new Error(`${pagePath} dropped its frozen intel brief`); } } } } else if (rows.brief && typeof rows.brief.text === 'string' && rows.brief.text.trim()) { // Anchor on first AND last content lines after stripping section titles, // bullets, and emphasis markers. Every generated brief opens with the same // boilerplate header, so the first line alone cannot catch a cross-country // swap; markdown conversion means raw `**` and ISO titles never appear. const contentLines = rows.brief.text.trim().split('\n') .map((line) => unwrapBriefEmphasisLine(line.trim())) .filter(Boolean) .filter((line) => !isBriefSectionHeader(line, { countryCode, countryName })) .map((line) => line.replace(/^(?:[•\-]\s*|\*\s+)/, '').replace(/\*\*/g, '')); const anchors = [contentLines[0], contentLines.at(-1)] .filter((line, index, all) => line && all.indexOf(line) === index) .map((line) => escapeHtml(line.slice(0, 120))); const pageText = html.replace(/<[^>]+>/g, ''); for (const anchor of anchors) { if (!pageText.includes(anchor)) { throw new Error(`${pagePath} dropped its frozen intel brief`); } } } const briefSources = Array.isArray(rows.brief?.sources) ? rows.brief.sources : []; for (const source of briefSources) { if (typeof source?.url === 'string' && !html.includes(`href="${escapeHtml(source.url)}"`)) { throw new Error(`${pagePath} dropped frozen brief source ${source.url}`); } } for (const event of rows.timeline || []) { if (!html.includes(escapeHtml(event.title))) { throw new Error(`${pagePath} dropped frozen timeline event ${event.title}`); } if (typeof event.occurredAt === 'string' && !html.includes(`datetime="${escapeHtml(event.occurredAt)}"`)) { throw new Error(`${pagePath} dropped the date of frozen timeline event ${event.title}`); } } } function corpusMainHtml(html) { const source = String(html || ''); const match = source.match(/]*>([\s\S]*?)<\/main>/i); const main = match ? match[1] : source; return main .replace(/]*>[\s\S]*?<\/script(?:[\t\n\f\r ][^>]*|\/[^>]*)?>/gi, '') .replace(/]*>[\s\S]*?<\/style(?:[\t\n\f\r ][^>]*|\/[^>]*)?>/gi, ''); } function corpusVisibleText(html) { return corpusMainHtml(html).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } function intelBriefHtml(html) { const match = corpusMainHtml(html).match(/]*\bdata-intel-brief\b[^>]*>([\s\S]*?)<\/div>/i); return match ? match[1] : null; } // #7738: prerendered country briefs were injected as escaped markdown, so // crawlers saw literal `**` and `WHAT THIS MEANS FOR NO`. Fail the build // when either artifact reaches
      , including section titles that are // still plain text rather than tags. const MEANS_FOR_ISO_RE = /^\s*what this means for [a-z]{2}(?=\s*(?::|$))/im; export function assertCountryBriefPresentation({ pagePath, html, sources, evidence = [] }) { const main = corpusMainHtml(html); if (main.includes('**')) { throw new Error(`${pagePath} renders literal markdown emphasis in
      `); } const brief = intelBriefHtml(html); if (brief && sources !== undefined) { // Check the rendered claim blocks as well as the input. A later formatter // must not add an entity or change a citation after publish-time validation. const claims = [...brief.matchAll(/<(p|li)\b([^>]*)>([\s\S]*?)<\/\1>/gi)] .filter((match) => !/\bclass="source"/.test(match[2])) .map((match) => corpusVisibleText(match[3]).replace(/&(amp|lt|gt|quot|#39);/g, (entity) => ({ '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" })[entity])); // Rendered claims may cite only World Monitor evidence: the Situation // section that carries the headline citations is not rendered. const gap = briefCitationGroundingGap({ text: claims.join('\n'), sources, evidence }, {}, { requireHeadlineCitation: evidence.length === 0 }); if (gap) throw new Error(`${pagePath} brief has unsupported citation: ${gap}`); } const headingSource = brief ?? main; const headingHits = [...headingSource.matchAll(/]*>([\s\S]*?)<\/h[1-6]>/gi)]; for (const hit of headingHits) { const text = hit[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); if (MEANS_FOR_ISO_RE.test(text)) { throw new Error(`${pagePath} heading leaks ISO code: ${text}`); } } const briefLines = corpusMainHtml(brief ?? html) .replace(/]*>|<\/(?:p|h[1-6]|li|div)>/gi, '\n') .replace(/<[^>]+>/g, ' '); if (MEANS_FOR_ISO_RE.test(briefLines)) { throw new Error(`${pagePath} brief heading leaks an ISO-3166 alpha-2 code`); } } // A floor, not completeness. #7615 shipped this as // `developmentsPageCount !== indexedCountryPageCount` -- every indexed country // owed a dated development -- which no real capture can satisfy: the news cycle // simply does not mention most countries. A fully keyed freeze on 2026-09-04 // covered 61 of 196 pages (54 headline-matched, 40 briefs, 18 timelines), so // the gate rejected every snapshot the freeze could produce. That left the // weekly refresh unable to publish and armed the // MAX_LIVE_PULSE_SNAPSHOT_AGE_DAYS fuse against the whole corpus build. // // Rendering is not this gate's job -- assertDevelopmentsRendered already fails // per page when a frozen item is dropped, and it compares against the same // snapshot rows, so demanding equality here proved nothing extra. What is worth // catching is a COLLAPSE: the digest matcher breaking, or a wrong-tiered key // leaving briefs and timelines empty. Gate on a floor that a quiet news week // clears and a broken pipeline does not. export const MIN_DEVELOPMENTS_COVERAGE_RATIO = 0.1; // The higher floor once the freeze runs with the per-country GDELT index // (#7748): the digest alone names roughly a third of indexed countries, the // index reaches most of the rest, so an index-era capture that covers under // 60% means the index served little (a materializer that just restarted // holds hours, not the week; an index that was down that morning) or the // top-up broke — either way a tail the size of the old one must not ship // green. The floor keys on the freeze having ATTEMPTED the index (the // snapshot carries coverage.developmentsCountryIndex at all), not on the // index having answered: a gate that relaxes exactly when the component it // protects fails is no gate (review of #7748). Not 100%: no article pool // names every country every week, and a floor the weekly refresh cannot // clear is the #7615 mistake again. export const MIN_DEVELOPMENTS_COVERAGE_RATIO_WITH_COUNTRY_INDEX = 0.6; // Operator override for the floor. A failed floor throws away the week's // capture (the workflow verifies before it opens the PR), so a measured-but- // lower week — the first freeze after the materializer redeploys, an index // outage that morning — needs a way to publish without a code change: // `workflow_dispatch` with `developments_coverage_ratio`, which the workflow // passes through this variable. A malformed value throws rather than // silently keeping the default (a typo must not look like "no override"). export const DEVELOPMENTS_COVERAGE_RATIO_ENV = 'CRAWLABLE_DEVELOPMENTS_COVERAGE_RATIO'; export function resolveDevelopmentsCoverageRatioOverride(env = process.env) { const raw = String(env?.[DEVELOPMENTS_COVERAGE_RATIO_ENV] ?? '').trim(); if (!raw) return null; const ratio = Number(raw); if (!Number.isFinite(ratio) || ratio <= 0 || ratio > 1) { throw new Error(`${DEVELOPMENTS_COVERAGE_RATIO_ENV} must be a ratio in (0, 1], got ${JSON.stringify(raw)}`); } return ratio; } // Pipeline tripwire decision (#7615, retuned in #7620 follow-up), exported for // tests: the per-page guard proves frozen items render; this proves the capture // did not collapse. `countryIndexAttempted` is the snapshot's own declaration // (it carries coverage.developmentsCountryIndex), so a snapshot frozen before // the index existed keeps the collapse floor. export function assertDevelopmentsCoverage({ carriesDevelopments, developmentsPageCount, indexedCountryPageCount, countryIndexAttempted = false, ratioOverride = null, }) { if (!carriesDevelopments) return; const ratio = ratioOverride ?? (countryIndexAttempted ? MIN_DEVELOPMENTS_COVERAGE_RATIO_WITH_COUNTRY_INDEX : MIN_DEVELOPMENTS_COVERAGE_RATIO); const floor = Math.max(1, Math.ceil(indexedCountryPageCount * ratio)); if (developmentsPageCount < floor) { throw new Error( `crawlable corpus captured dated country developments for ${developmentsPageCount} ` + `of ${indexedCountryPageCount} indexed country pages; expected at least ${floor}` + (ratioOverride != null ? ` (operator override ${ratioOverride})` : countryIndexAttempted ? ' for an index-era capture' : '') + '. A snapshot that carries developments should cover far more than this — check the ' + (countryIndexAttempted ? 'per-country index top-up (coverage.developmentsCountryIndex) and the digest match ' : 'digest match and the freeze service key ') + `before republishing, or publish a measured-but-lower week with ${DEVELOPMENTS_COVERAGE_RATIO_ENV}.`, ); } } /** Whether a frozen snapshot's freeze attempted the per-country article index top-up (#7748), whatever it answered. */ export function snapshotAttemptedCountryIndex(livePulse) { const record = livePulse?.coverage?.developmentsCountryIndex; return Boolean(record) && typeof record === 'object' && typeof record.state === 'string'; } export function renderCountryPage({ country, relatedChokepoints = [], relatedReading = [], relatedCountries = [], baseUrl, capturedAt, lastmod, methodologyFormula, rankedCount, snapshotNote, snapshotPath, bbox = null, livePulse = null, ciiEntry = null, now = Date.now(), }) { const path = `/countries/${country.slug}/`; const ageDays = ciiEntry?.ageDays ?? livePulseSnapshotAgeDays(livePulse?.capturedAt, now); const movementClaim = ciiEntry?.movementClaim ?? livePulseMovementClaim(ageDays); const description = countryMetaDescription({ name: country.name, rank: country.rank, rankedCount, lowConfidence: country.lowConfidence === true, ciiEntry, }); const mapUrl = absoluteUrl(baseUrl, `/dashboard?country=${encodeURIComponent(country.code)}&expanded=1`); const analysis = renderCountryAnalysis({ country, capturedAt, methodologyFormula, rankedCount, ciiEntry, }); const officialNameNote = country.identity.officialName !== country.identity.commonName ? `

      Official name: ${escapeHtml(country.identity.officialName)}. Wikidata identity record.

      \n` : ''; const scorePublished = country.headlineEligible !== false; const scoreDisclosure = scorePublished || analysis.suppressScoreDisclosure ? '' : `\n

      World Monitor does not publish a resilience score for ${escapeHtml(country.name)} because it does not meet the published ranking eligibility criteria.

      `; const datasetDescription = scorePublished ? `A dated World Monitor Country Resilience Index snapshot for ${country.name}, with the overall score, rank, dimension coverage, confidence classification, and scoring methodology used for this page.` : `A dated World Monitor Country Resilience Index snapshot for ${country.name}, with dimension coverage, confidence classification, and scoring methodology. No overall score or rank is published because the country does not meet the published ranking eligibility criteria. ${RANKING_ELIGIBILITY_CLAUSE}`; const pulse = livePulse?.countries?.[country.code] || null; const hasPulse = pulse != null && ( pulse.partial === true || hasObservedValue(pulse.score, { coverage: pulse.partial !== true }) ); const developments = pulse && typeof pulse.developments === 'object' ? pulse.developments : null; const liveState = hasPulse ? (pulse.partial ? 'partial' : 'ready') : 'loading'; const liveStatus = hasPulse ? (pulse.partial ? 'Published partial pulse' : 'Published pulse') : 'Waiting for live enhancement'; const liveGrid = hasPulse ? `
      Instability score${escapeHtml(formatScore(pulse.score, { coverage: pulse.partial !== true }))}${pulse.partial ? 'No current score' : escapeHtml(pulse.band)}
      ${escapeHtml(movementClaim.metricLabel)}${escapeHtml(pulse.partial ? 'Unavailable' : ciiMovementDisplayLabel(pulse.trend))}
      Travel advisory input${escapeHtml(pulse.advisory)}
      OFAC designations in feed${escapeHtml(pulse.sanctions)}
      ` : `

      Current instability metrics load after page enhancement. The structural resilience snapshot below remains the dated crawlable reference.

      `; const ciiAnswer = ciiEntry ? `${escapeHtml(country.name)}'s Country Instability Index is ${escapeHtml(formatScore(ciiEntry.score, OBSERVED_EVIDENCE))}/100 · ${escapeHtml(ciiEntry.band)}${escapeHtml(ciiMovementClause(ciiEntry))}, as of .` : null; const body = `

      Country · ${escapeHtml(country.code)}

      ${escapeHtml(country.name)} ${ciiEntry ? 'Country Instability Index' : 'country risk and resilience'}

      ${ciiAnswer || `${escapeHtml(description)} The structural snapshot is dated and source-labelled; the live monitor loads separately.`}

      ${ciiEntry ? `

      CII measures current stress. World Monitor's separate Country Resilience Index measures ${escapeHtml(country.name)}'s longer-term structural capacity.

      \n` : ''} ${officialNameNote}

      Current signal

      ${escapeHtml(country.name)} instability monitor

      ${escapeHtml(liveStatus)}

      Instability combines current information, unrest, conflict and security-mobility signals. It is separate from structural resilience; do not combine the scores.

      ${liveGrid}
      ${liveUpdatedMarkup({ asOf: pulse?.asOf || null, fallbackLabel: 'Live enhancement pending', // Always "Published pulse": the stamp is when the pulse was frozen, // not when a reader retrieved it. Matches the hydrate-side wording. prefix: 'Published pulse', })}
      Open ${escapeHtml(country.name)} on the live map → ${renderCountryDevelopments({ countryCode: country.code, countryName: country.name, developments, ciiEntry, pulse })}

      Structural resilience snapshot

      Rank${escapeHtml(country.rank == null ? 'Not ranked' : `#${country.rank}`)}
      Overall score${escapeHtml(formatScore(country.overallScore, { coverage: scorePublished }))}
      Dimension coverage${escapeHtml(formatPercent(country.dimensionCoverage))}
      Confidence${country.lowConfidence ? 'Low' : 'Standard'}
      ${scoreDisclosure} ${analysis.html} ${renderCountryRelatedLinks(country, relatedCountries, escapeHtml)} ${renderRelatedChokepoints(relatedChokepoints)} ${renderLiveDataLinks(countryLiveLinks(country.code), escapeHtml)} ${renderRelatedReading(relatedReading, escapeHtml)} ${analysis.readingGuide ? `

      How to use this evidence

      ${escapeHtml(analysis.readingGuide)} Full CRI method · revision log · forecast accuracy scorecard.

      ` : `

      How to read this page

      The 0-100 index records the ${escapeHtml(prettyDate(capturedAt))} snapshot. See the Country Resilience Index methodology for dimensions, sources and confidence rules. Published revisions that affect ${escapeHtml(country.name)} are in the corrections log.

      ${escapeHtml(snapshotNote)}

      Use this dated reference with the live map for active alerts, conflict, market and energy signals. Neither score on this page is a forecast; where World Monitor does forecast, the graded record is on the forecast accuracy scorecard.

      `}

      Download: ${COUNTRY_DATASET_DOWNLOAD}. Source: World Monitor Country Resilience Index snapshot, ${escapeHtml(prettyDate(capturedAt))}. Methodology: Country Resilience Index.

      `; const coreTitle = ciiEntry ? `${country.name} Instability Index & Country Risk` : `${country.name} Country Risk and Resilience`; const resilienceDatasetId = `${absoluteUrl(baseUrl, path)}#resilience-dataset`; const ciiDatasetId = `${absoluteUrl(baseUrl, path)}#cii-dataset`; const resilienceDownload = absoluteUrl(baseUrl, datasetDownloadHref(path, COUNTRY_DATASET_DOWNLOAD)); // WebPage dateModified tracks the newest frozen developments item (#7615: // per-country recency, machine-readable). The resilience Dataset node stays // pinned to the snapshot capturedAt per #7391 — a different claim (snapshot // freshness) from this one (newest rendered news item). const developmentsModified = newestDevelopmentsInstant(developments); const spatialCoverage = { ...countrySpatialCoverage(country, bbox), name: country.identity.commonName, alternateName: country.identity.alternateNames, sameAs: country.identity.sameAs, }; const variableMeasured = [ { '@type': 'PropertyValue', name: 'Dimension coverage', value: country.dimensionCoverage, minValue: 0, maxValue: 1 }, ...(scorePublished ? [ { '@type': 'PropertyValue', name: 'Overall resilience score', value: country.overallScore, minValue: 0, maxValue: 100 }, { '@type': 'PropertyValue', name: 'Rank', value: country.rank }, // Structured data is the surface answer engines cite, so it obeys the same // observed-value rule as the visible page: a withheld value is OMITTED here, // never emitted raw next to an em dash in the HTML. ...(hasObservedValue(country.change30d, { coverage: scorePublished }) ? [{ '@type': 'PropertyValue', name: '30-day score change', value: country.change30d, unitText: 'index points' }] : []), ...country.pillars .filter((pillar) => hasObservedValue(pillar.score, { coverage: pillar.coverage })) .map((pillar) => ({ '@type': 'PropertyValue', name: `${PILLAR_LABELS[pillar.id] || humanizeId(pillar.id)} score`, value: pillar.score, minValue: 0, maxValue: 100, })), ] : []), ]; const resilienceDataset = { '@type': 'Dataset', '@id': resilienceDatasetId, name: `World Monitor Country Resilience snapshot for ${country.name}`, description: datasetDescription, url: absoluteUrl(baseUrl, path), identifier: country.code, keywords: ['country resilience', country.name, 'resilience index', 'country risk'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: capturedAt, dateModified: capturedAt, temporalCoverage: datasetTemporalCoverage(capturedAt), spatialCoverage, isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), distribution: [dataDownload(resilienceDownload)], measurementTechnique: methodologyFormula, variableMeasured, }; const ciiDataset = ciiEntry ? { '@type': 'Dataset', '@id': ciiDatasetId, name: `World Monitor Country Instability Index: ${country.name}`, description: `The current World Monitor Country Instability Index score, ${movementClaim.availableApproximateMovement}, instability level, and methodology version for ${country.name}.`, url: absoluteUrl(baseUrl, path), identifier: `${country.code}-cii-${ciiEntry.methodologyVersion}`, keywords: ['country instability', country.name, 'instability index', 'country risk'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: pulseDateOnly(ciiEntry.asOf, capturedAt), dateModified: ciiEntry.asOf, temporalCoverage: datasetTemporalCoverage(pulseDateOnly(ciiEntry.asOf, capturedAt)), spatialCoverage, isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), distribution: [dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, COUNTRY_CII_DATASET_DOWNLOAD)))], measurementTechnique: `World Monitor CII ${ciiEntry.methodologyVersion}`, variableMeasured: [ { '@type': 'PropertyValue', name: 'Instability score', value: ciiEntry.score, minValue: 0, maxValue: 100 }, ...ciiMovementProperties(ciiEntry.change24h, movementClaim.propertyName), { '@type': 'PropertyValue', name: 'Instability level', value: ciiEntry.band }, ], } : null; const html = pageDocument({ baseUrl, path, // Keep SERP titles near the ~60-char display budget: drop the brand // suffix for long country names rather than letting Google truncate // mid-brand. title: coreTitle.length > 44 ? coreTitle : `${coreTitle} | World Monitor`, description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'WebPage', name: ciiEntry ? `${country.name} Country Instability Index and Country Risk` : `${country.name} country risk and resilience`, description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', ...(developmentsModified ? { dateModified: developmentsModified } : {}), about: { '@type': 'Country', name: country.identity.commonName, alternateName: country.identity.alternateNames, identifier: country.code, sameAs: country.identity.sameAs, }, mainEntity: ciiEntry ? { '@id': ciiDatasetId } : resilienceDataset, }, ...(ciiEntry ? [ciiDataset, resilienceDataset] : []), faqPageLd(analysis.faqs), dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Countries', path: '/countries/' }, { name: country.name, path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); assertCountryDevelopmentsRendered({ pagePath: path, html, developments, countryCode: country.code, countryName: country.name }); assertCountryBriefPresentation({ pagePath: path, html, sources: developments?.brief?.sources || [], evidence: developments?.brief?.evidence || [] }); assertLivePulseMovementClaim(html, { pagePath: path, ageDays }); return html; } const CHOKEPOINT_HUB_STATUS_LABELS = new Set(['Green', 'Yellow', 'Red']); const CHOKEPOINT_HUB_CONGESTION_LABELS = new Set([ 'Low', 'Normal', 'Elevated', 'High', 'Not reported', ]); function publishedPulseLabel(value, allowed) { if (typeof value !== 'string') return ''; const label = value.trim(); return allowed.has(label) ? label : ''; } function chokepointHubStatusForScore(score) { if (score < 20) return 'Green'; if (score < 50) return 'Yellow'; return 'Red'; } export function buildChokepointHubRows(chokepoints, livePulse) { const registryIds = chokepoints.map((chokepoint) => chokepoint.id); const registryIdSet = new Set(registryIds); const pulseIds = Object.keys(livePulse?.chokepoints || {}); const pulseIdSet = new Set(pulseIds); const missingIds = registryIds.filter((id) => !pulseIdSet.has(id)); const unexpectedIds = pulseIds.filter((id) => !registryIdSet.has(id)); if (missingIds.length > 0 || unexpectedIds.length > 0) { throw new Error( `Chokepoint hub pulse set is invalid: missing ${missingIds.join(', ') || 'none'}; ` + `unexpected ${unexpectedIds.join(', ') || 'none'}`, ); } return chokepoints.map((chokepoint) => { const pulse = livePulse?.chokepoints?.[chokepoint.id]; const rawScore = pulse?.disruptionScore; let score = Number.NaN; if (typeof rawScore === 'number') { score = rawScore; } else if (typeof rawScore === 'string' && /^\d+(?:\.\d+)?$/.test(rawScore)) { score = Number(rawScore); } const status = publishedPulseLabel(pulse?.status, CHOKEPOINT_HUB_STATUS_LABELS); const aisSnapshotAvailable = pulse?.aisSnapshotAvailable === true; const congestion = aisSnapshotAvailable ? publishedPulseLabel(pulse?.congestion, CHOKEPOINT_HUB_CONGESTION_LABELS) : 'Not reported'; const asOf = String(pulse?.asOf || '').trim(); const asOfMs = Date.parse(asOf); const capturedAtMs = livePulse?.capturedAtMs; if ( !pulse || !Number.isFinite(score) || score < 0 || score > 100 || !status || !congestion || status !== chokepointHubStatusForScore(score) || !isCanonicalIsoInstant(asOf) || !Number.isFinite(capturedAtMs) || asOfMs < capturedAtMs - MAX_LIVE_SNAPSHOT_AGE_MS || asOfMs > capturedAtMs + MAX_FUTURE_SKEW_MS ) { throw new Error(`Chokepoint hub pulse is invalid for ${chokepoint.id}`); } return { chokepoint, region: CHOKEPOINT_CONTENT[chokepoint.id]?.region || 'Strategic maritime waterway', score, status, congestion, aisSnapshotAvailable, asOf, }; }); } function renderChokepointsIndex({ chokepoints, chokepointHubRows, livePulse, baseUrl, lastmod, snapshotPath }) { const path = '/chokepoints/'; const description = `Live status of the ${chokepoints.length} maritime chokepoints, from Hormuz, Suez and Bab el-Mandeb to Malacca and Panama: disruption scores, AIS congestion and last-update times.`; const updatedAt = chokepointHubRows .map((row) => row.asOf) .sort((left, right) => Date.parse(left) - Date.parse(right)) .at(-1); const highestScore = Math.max(...chokepointHubRows.map((row) => row.score)); const mostDisruptedNames = chokepointHubRows .filter((row) => row.score === highestScore) .map((row) => row.chokepoint.displayName); // State the coverage this snapshot actually has. The answer used to assert // four scoring inputs flatly while the detail pages withheld three of them, // so the hub contradicted the pages it indexes (#7530). const congestionPublished = chokepointHubRows.filter((row) => row.aisSnapshotAvailable).length; const congestionCoverageClause = congestionPublished === chokepointHubRows.length ? `in this snapshot all ${chokepointHubRows.length} waterways publish an AIS congestion reading` : congestionPublished === 0 ? `in this snapshot the AIS snapshot is unavailable, so none of the ${chokepointHubRows.length} waterways publish an AIS congestion reading` : `in this snapshot ${congestionPublished} of ${chokepointHubRows.length} waterways publish an AIS congestion reading`; // The committed EIA series covers 7 of the 13 tracked waterways. Publish the // EIA row name alongside ours wherever the registry maps them differently // (Dover Strait draws on the Danish Straits row), so the substitution is // visible rather than implied. const oilTransitRows = chokepointHubRows .map((row) => ({ row, eia: EIA_OIL_TRANSIT_BASELINES.byRegistryId[row.chokepoint.id] })) .filter(({ eia }) => eia) .sort((left, right) => right.eia.mbd - left.eia.mbd); const hubFaqs = [ { question: 'Which maritime chokepoints are most disrupted?', answer: `The published ${formatStaticDateTime(updatedAt)} snapshot gives the highest disruption score to ${formatProseList(mostDisruptedNames)}, each at ${formatScore(highestScore, OBSERVED_EVIDENCE)}/100. The table covers all ${chokepointHubRows.length} tracked waterways and shows each source timestamp. A higher score means more current pressure, but the badge is a risk signal and does not declare unrestricted passage or operational closure.`, }, { question: 'How does World Monitor score chokepoint status?', answer: `World Monitor scores each waterway 0-100 from ${formatProseList(CHOKEPOINT_SCORE_INPUTS.map((input) => input.label))}. ${formatProseList(CHOKEPOINT_SCORE_CONTEXT_ONLY)} are published as context rather than score inputs. Each source controls only its own values, so unavailable evidence is withheld rather than published as a measured zero or a calm reading — ${congestionCoverageClause}. The methodology documents the inputs and score bands.`, }, { question: 'Why do some chokepoint pages show fewer metrics than others?', answer: 'A metric appears only when the source behind it reported for that snapshot. Navigational warnings, AIS disruptions and AIS congestion each depend on their own feed. The latest daily transit count, its 7-day average and the week-over-week movement are IMF PortWatch figures and are withheld together when PortWatch has no history for the waterway; the year-earlier comparison is omitted when no baseline was stored. Unavailable values can appear as an em dash or be hidden, depending on the metric, so a sparse page means missing evidence, not a calm waterway. Published revisions to these rules are in the corrections log.', }, ]; const datasetId = `${absoluteUrl(baseUrl, path)}#status-dataset`; const itemListId = `${absoluteUrl(baseUrl, path)}#status-list`; const itemListElement = chokepointHubRows.map((row, index) => { const url = absoluteUrl(baseUrl, `/chokepoints/${row.chokepoint.slug}/`); return { '@type': 'ListItem', position: index + 1, name: row.chokepoint.displayName, url, item: { '@type': 'Place', name: row.chokepoint.displayName, url, additionalProperty: [ { '@type': 'PropertyValue', name: 'Disruption score', value: row.score, minValue: 0, maxValue: 100 }, { '@type': 'PropertyValue', name: 'Status', value: row.status }, ...(row.aisSnapshotAvailable ? [{ '@type': 'PropertyValue', name: 'AIS congestion', value: row.congestion }] : []), ], }, }; }); const body = `

      For supply-chain risk teams

      Chokepoints and waterways

      ${escapeHtml(description)}

      For the step-by-step monitoring procedure, see Monitor supply chain disruptions.

      ${hubDashboardCta('chokepoints', baseUrl)}

      ${hubFaqs.map((faq) => `

      ${escapeHtml(faq.question)}

      ${escapeHtml(faq.answer)}

      `).join('\n')}
      ${chokepointHubRows.map((row) => ` `).join('\n')}
      Published chokepoint status snapshot updated ${escapeHtml(formatStaticDateTime(updatedAt))}
      ChokepointRegionDisruption scoreStatusAIS congestionUpdated
      ${escapeHtml(row.chokepoint.displayName)}${escapeHtml(row.region)}${escapeHtml(formatScore(row.score, OBSERVED_EVIDENCE))}${escapeHtml(row.status)}${escapeHtml(row.congestion)}

      How much oil moves through each waterway

      Disruption scores describe current pressure, not importance. The committed ${escapeHtml(String(EIA_OIL_TRANSIT_BASELINES.referenceYear))} ${escapeHtml(EIA_OIL_TRANSIT_BASELINES.source)} series gives a volume baseline for ${oilTransitRows.length} of the ${chokepointHubRows.length} tracked waterways, which is what separates a high score on a marginal strait from a high score on a corridor that moves a fifth of seaborne oil. Where our name and the EIA row differ, both are shown — the Dover Strait page draws on the EIA Danish Straits row.

      ${oilTransitRows.map(({ row, eia }) => ` `).join('\n')}
      Crude oil and petroleum liquids transiting each waterway, ${escapeHtml(EIA_OIL_TRANSIT_BASELINES.source)}, ${escapeHtml(String(EIA_OIL_TRANSIT_BASELINES.referenceYear))}
      ChokepointEIA series rowMillion barrels per day
      ${escapeHtml(row.chokepoint.displayName)}${escapeHtml(eia.eiaName)}${escapeHtml(String(eia.mbd))}

      Baseline source: U.S. ${escapeHtml(EIA_OIL_TRANSIT_SOURCE)} (${EIA_OIL_TRANSIT_REFERENCE_YEAR}). The remaining ${chokepointHubRows.length - oilTransitRows.length} tracked waterways have no row in that series; their pages say so rather than estimating one.

      These are transit volumes. For how much crude the US holds in storage this week, see US crude oil inventories this week.

      Why each waterway is tracked

      ${chokepointHubRows.map((row) => { const blurb = CHOKEPOINT_CONTENT[row.chokepoint.id]?.blurb || ''; const opening = blurb.match(/^[\s\S]*?\.(?=\s|$)/)?.[0]?.trim() || ''; return `
      ${escapeHtml(row.chokepoint.displayName)}
      ${escapeHtml(opening || `${row.chokepoint.displayName} is a tracked maritime chokepoint in the ${row.region} corridor.`)}
      `; }).join('\n')}

      Download the daily status archive

      The status of all ${chokepointHubRows.length} waterways is also published once a day as an open dataset. It is free to reuse under CC BY 4.0 with credit to World Monitor, and needs no key or signup. Each daily file keeps that day's disruption score, status, warning counts, AIS congestion, transit counts and flow ratio, and earlier days are never rewritten.

      How this list is scoped

      The ${chokepointHubRows.length} waterways come from a committed registry, not from whatever is in the news. Each has a detail page carrying the same four-source status block, the modelled corridors that route through it, and its alternatives when it is unavailable. A waterway enters the registry because traffic there has no cheap substitute — the test is substitutability, not incident count — so the list changes rarely and changes are recorded in the corrections log.

      What these pages are not

      They are not a navigation product and not an open/closed declaration. A score is a triage signal built from the sources named above; it does not authorise or discourage a transit, and it carries no view on the legality or safety of any particular voyage. Daily transit counts are IMF PortWatch figures from satellite AIS, published with a lag of several days, not a port authority figure, and the oil volumes above are a ${escapeHtml(String(EIA_OIL_TRANSIT_BASELINES.referenceYear))} annual-average baseline rather than current throughput.

      Sources: World Monitor weekly pulse snapshot and World Monitor chokepoint registry. Published ${escapeHtml(prettyDate(livePulse.capturedAt))}. Methodology: chokepoint disruption scoring. Published revisions: corrections log.

      `; return pageDocument({ baseUrl, path, title: 'Maritime Chokepoints: Live Shipping Lane Status | World Monitor', description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Maritime chokepoints and waterways', description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', mainEntity: { '@id': datasetId }, }, faqPageLd(hubFaqs), { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'Dataset', '@id': datasetId, name: `World Monitor maritime chokepoint status snapshot for ${livePulse.capturedAt}`, description, url: absoluteUrl(baseUrl, path), identifier: 'world-monitor-chokepoint-status', keywords: ['maritime chokepoints', 'shipping disruption', 'AIS congestion', 'maritime risk'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: livePulse.capturedAt, dateModified: updatedAt, temporalCoverage: datasetObservationCoverage(chokepointHubRows.map((row) => row.asOf)), spatialCoverage: 'Worldwide', isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), measurementTechnique: 'World Monitor chokepoint disruption scoring methodology', variableMeasured: [ { '@type': 'PropertyValue', name: 'Disruption score', minValue: 0, maxValue: 100, unitText: 'index points' }, { '@type': 'PropertyValue', name: 'Status' }, { '@type': 'PropertyValue', name: 'AIS congestion' }, ], distribution: dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, CHOKEPOINTS_INDEX_DATASET_DOWNLOAD))), mainEntity: { '@id': itemListId }, }, { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'ItemList', '@id': itemListId, name: 'Maritime chokepoint status snapshot', numberOfItems: chokepointHubRows.length, itemListOrder: 'https://schema.org/ItemListUnordered', itemListElement, }, chokepointOpenDataLd(baseUrl, chokepointHubRows.length), dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Chokepoints', path }, ]), body, }); } function chokepointFaqs(chokepoint, { content, routes, capturedAt, volumeObservedAt }) { const authored = Array.isArray(content.faqs) ? content.faqs.slice(0, 2) : []; const datedVolume = volumeObservedAt ? prettyDate(volumeObservedAt) : 'the committed trade-route table'; const datedCapture = capturedAt ? prettyDate(capturedAt) : 'the committed registry snapshot'; const generated = routes.length ? { question: `Which modelled trade routes use ${chokepoint.displayName}?`, answer: `${chokepoint.displayName} is a waypoint on ${routes.map((route) => `${route.name} (${route.volumeDesc})`).join('; ')}. Those corridor volumes are World Monitor modelled figures dated ${datedVolume} in the World Monitor trade-route reference.`, } : { question: `How should readers use the ${chokepoint.displayName} reference snapshot?`, answer: `Read the ${datedCapture} registry copy with the live disruption pulse. ${chokepoint.displayName} is tracked as a strategic waterway reference; modelled corridor rows are absent until TRADE_ROUTES gains a waypoint, which is not a claim that traffic is zero.`, }; return [...authored, generated].slice(0, 3); } function chokepointRouteTable({ chokepoint, routes, capturedAt, volumeObservedAt }) { const datedStamp = volumeObservedAt || capturedAt; const datedVolume = datedStamp ? timeMarkup(datedStamp) : 'the committed trade-route table'; if (!routes.length) { return `

      ${escapeHtml(chokepoint.displayName)} is tracked as a strategic waterway reference. It is not currently mapped to one of World Monitor's modelled trade-route corridors, but its vessel traffic and disruption signals are still monitored on the live map.

      No modelled corridor is currently mapped through ${escapeHtml(chokepoint.displayName)}. Waterway reference dated ${datedVolume}.
      RouteCategoryModelled volumeAs of
      Strategic waterway reference — corridor book unmapped, as of ${datedVolume}
      `; } return `
      ${routes.map((route) => { const category = route.category ? route.category.charAt(0).toUpperCase() + route.category.slice(1) : '—'; const asOf = volumeObservedAt ? timeMarkup(volumeObservedAt) : '—'; return ` `; }).join('\n')}
      Modelled trade-route corridors through ${escapeHtml(chokepoint.displayName)}. Volumes are World Monitor corridor figures as of ${datedVolume}.
      RouteCategoryModelled volumeAs of
      ${escapeHtml(route.name)}${escapeHtml(category)}${escapeHtml(route.volumeDesc)}${asOf}
      `; } function renderChokepointAnalysis({ chokepoint, content, routes, capturedAt, volumeObservedAt, }) { const faqs = chokepointFaqs(chokepoint, { content, routes, capturedAt, volumeObservedAt }); const whyHeading = content.whyHeading || `Why does ${chokepoint.displayName} matter for shipping?`; const analysisParagraphs = Array.isArray(content.analysis) ? content.analysis : []; const coords = formatCoordinates(chokepoint.lat, chokepoint.lon); const region = content.region || 'its connected waters'; const datedCapture = capturedAt ? timeMarkup(capturedAt) : 'the committed registry snapshot'; const shockClause = chokepoint.shockModelSupported ? 'World Monitor enables the energy shock model on this waterway.' : 'World Monitor does not enable the energy shock model on this waterway.'; const routeClause = routes.length ? `The ${datedCapture} reference maps ${routes.length} modelled trade-route corridor${routes.length === 1 ? '' : 's'} onto this waypoint.` : `The ${datedCapture} reference maps no modelled trade-route corridor onto this waypoint.`; const eia = EIA_OIL_TRANSIT_BASELINES.byRegistryId[chokepoint.id]; const eiaYear = String(EIA_OIL_TRANSIT_BASELINES.referenceYear); const eiaParagraph = eia ? `

      The ${timeMarkup(eiaYear, eiaYear)} ${escapeHtml(EIA_OIL_TRANSIT_BASELINES.source)} series records ${escapeHtml(String(eia.mbd))} million barrels a day on the ${escapeHtml(eia.eiaName)} row. ${escapeHtml(shockClause)}

      ` : `

      ${escapeHtml(shockClause)} This waterway has no row in the committed ${escapeHtml(String(EIA_OIL_TRANSIT_BASELINES.referenceYear))} ${escapeHtml(EIA_OIL_TRANSIT_BASELINES.source)} series.

      `; const html = `

      ${escapeHtml(whyHeading)}

      ${escapeHtml(chokepoint.displayName)} sits at ${escapeHtml(coords)} and connects ${escapeHtml(region)}. ${routeClause}

      ${analysisParagraphs.map((paragraph) => `

      ${escapeHtml(paragraph)}

      `).join('\n')} ${eiaParagraph} ${content.alternative ? `

      ${escapeHtml(content.alternative)}

      ` : ''}

      Major trade routes through ${escapeHtml(chokepoint.displayName)}

      ${chokepointRouteTable({ chokepoint, routes, capturedAt, volumeObservedAt })}

      How to read this page

      The crawlable Dataset is the ${datedCapture} registry-and-route snapshot under the chokepoint disruption methodology. Live pulse tiles are a separately frozen observation and are not copied into this Dataset. Modelled corridor volumes are dated ${volumeObservedAt ? timeMarkup(volumeObservedAt) : 'with the committed trade-route table'}.

      Template revision ${escapeHtml(CHOKEPOINT_PAGE_CONTENT_VERSION)}. Reference observation ${capturedAt ? timeMarkup(capturedAt) : 'unspecified'}. This note is a methodology-revision stamp, not a live AIS clock. Published revisions that affect this page are in the corrections log.

      Questions about ${escapeHtml(chokepoint.displayName)}

      ${faqs.map((faq) => `
      ${escapeHtml(faq.question)}

      ${escapeHtml(faq.answer)}

      `).join('\n')}
      `; return { html, faqs }; } // The upstream status note is optional. A snapshot without one must publish no // paragraph at all — until #7530 an absent note froze as "No additional status // note was supplied." and rendered as real body prose in
      on 7 of the 13 // chokepoint pages, where it was frequently the only sentence the live section // contributed. // Snapshots frozen before that change still carry the sentence verbatim, so // treat it as the absence it always described rather than waiting for a // refresh to age it out. function renderChokepointPage({ chokepoint, relatedCountries = [], relatedCrises = [], editorialLinks = [], baseUrl, lastmod, tradeRoutesById, researchReports = [], livePulse = null, capturedAt = null, volumeObservedAt = null, }) { const path = `/chokepoints/${chokepoint.slug}/`; const content = CHOKEPOINT_CONTENT[chokepoint.id] || {}; const blurb = content.blurb || `${chokepoint.displayName} is one of the 13 canonical maritime chokepoints tracked by World Monitor.`; const description = content.metaDescription ?? chokepointMetaDescription(chokepoint.displayName); const mapUrl = absoluteUrl(baseUrl, `/dashboard?chokepoint=${encodeURIComponent(chokepoint.id)}`); const routes = chokepoint.routeIds .map((id) => tradeRoutesById.get(id)) .filter(Boolean); const analysis = renderChokepointAnalysis({ chokepoint, content, routes, capturedAt, volumeObservedAt, }); const tiles = [ content.region ? metricTile('Connects', content.region) : null, metricTile('Position', formatCoordinates(chokepoint.lat, chokepoint.lon)), metricTile('Energy shock model', chokepoint.shockModelSupported ? 'Yes' : 'No'), ].filter(Boolean).join('\n'); const relatedItems = []; for (const { report } of researchReports) { const role = report.focusChokepointId === chokepoint.id ? 'Focus' : report.contextChokepointIds.includes(chokepoint.id) ? 'Comparison' : null; if (role) { relatedItems.push(`${role} in historical research published ${escapeHtml(report.datePublished)}: ${escapeHtml(report.title)}`); } } if (content.glossarySlug) { relatedItems.push(`${escapeHtml(chokepoint.displayName)} in the glossary`); } relatedItems.push('What is a maritime chokepoint?'); for (const link of chokepointLiveLinks({ carriesOil: Boolean(EIA_OIL_TRANSIT_BASELINES.byRegistryId[chokepoint.id]) })) { relatedItems.push(`${escapeHtml(link.label)}`); } for (const link of editorialLinks) { relatedItems.push(`${escapeHtml(link.label)}`); } const liveSection = renderChokepointLiveSection({ chokepoint, pulse: livePulse?.chokepoints?.[chokepoint.id] || null, }); const body = `

      Chokepoint

      ${escapeHtml(content.heading ?? chokepoint.displayName)}

      ${liveSection}

      ${escapeHtml(blurb)}

      Open ${escapeHtml(chokepoint.displayName)} on the live map →
      ${tiles}
      ${analysis.html} ${relatedCountries.length ? `

      Related country profiles

      ` : ''} ${relatedCrises.length ? `

      Crisis context

      ` : ''}

      Related

      Download: ${CHOKEPOINT_DATASET_DOWNLOAD}. Source: World Monitor chokepoint registry and trade-route reference. Captured ${capturedAt ? escapeHtml(prettyDate(capturedAt)) : 'unspecified'}. Methodology: how chokepoint disruption is scored.

      `; const hasCoordinates = Number.isFinite(chokepoint.lat) && Number.isFinite(chokepoint.lon); const geoCoordinates = hasCoordinates ? { '@type': 'GeoCoordinates', latitude: chokepoint.lat, longitude: chokepoint.lon, } : undefined; const geoShape = hasCoordinates ? chokepointGeoShape(chokepoint.lat, chokepoint.lon) : undefined; const additionalProperty = [ content.region ? propertyValue('Connects', content.region) : null, routes.length ? propertyValue( 'Modelled trade routes', routes.map((route) => route.name).join('; '), ) : null, ].filter(Boolean); const referenceDownload = absoluteUrl(baseUrl, datasetDownloadHref(path, CHOKEPOINT_DATASET_DOWNLOAD)); const datasetId = `${absoluteUrl(baseUrl, path)}#chokepoint-dataset`; const coordinatesValue = formatCoordinates(chokepoint.lat, chokepoint.lon); const variableMeasured = [ { '@type': 'PropertyValue', name: 'Geographic coordinates', value: coordinatesValue }, ...(content.region ? [{ '@type': 'PropertyValue', name: 'Connected waters', value: content.region }] : []), { '@type': 'PropertyValue', name: 'Energy shock model support', value: chokepoint.shockModelSupported ? 'Yes' : 'No' }, { '@type': 'PropertyValue', name: 'Modelled trade routes', value: routes.length }, ]; const dataset = { '@type': 'Dataset', '@id': datasetId, name: `World Monitor chokepoint reference for ${chokepoint.displayName}`, description: `A World Monitor maritime reference dataset for ${chokepoint.displayName}, with its position, connected waters, energy shock model support, and modelled trade-route corridors.`, url: absoluteUrl(baseUrl, path), identifier: chokepoint.id, keywords: ['maritime chokepoint', chokepoint.displayName, 'shipping route', 'trade corridor'], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, datePublished: capturedAt || undefined, dateModified: laterDate(lastmod, DATASET_SCHEMA_CONTENT_VERSION.chokepoint), temporalCoverage: datasetTemporalCoverage(capturedAt), isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), variableMeasured, spatialCoverage: { '@type': 'Place', name: chokepoint.displayName, identifier: chokepoint.id, geo: [geoCoordinates, geoShape].filter(Boolean), }, distribution: [ dataDownload(referenceDownload), ], }; return pageDocument({ baseUrl, path, title: content.metaTitle ?? `${chokepoint.displayName} Chokepoint Status | World Monitor`, description, lastmod, extraStyles: ` details[data-chokepoint-faq] { margin-top: 10px; border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; background: var(--panel); } details[data-chokepoint-faq] summary { cursor: pointer; color: var(--text); font-weight: 600; } details[data-chokepoint-faq] p { margin: 8px 0 0; } `, jsonLd: [ { '@context': 'https://schema.org', '@type': 'WebPage', name: `${chokepoint.displayName} chokepoint`, description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', about: { '@type': 'Place', name: chokepoint.displayName, identifier: chokepoint.id, geo: [geoCoordinates, geoShape].filter(Boolean), additionalProperty: additionalProperty.length ? additionalProperty : undefined, }, mainEntity: dataset, }, faqPageLd(analysis.faqs), dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Chokepoints', path: '/chokepoints/' }, { name: chokepoint.displayName, path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); } function countrySelectOptions(countryBounds, { includeWorldwide = false, defaultCode = '' } = {}) { const worldwide = includeWorldwide ? ' \n' : ''; return worldwide + countryBounds.map((country) => { const selected = country.code === defaultCode ? ' selected' : ''; return ` `; }).join('\n'); } const ACTIVE_WARS_MAX_AGE_DAYS = 75; const ACTIVE_WARS_MONTH_RE = /^(\d{4})-(\d{2})$/; /** * The committed pulse's UCDP war list, or null when the snapshot predates it, * the capture failed with nothing to carry, or the reference month ended more * than ACTIVE_WARS_MAX_AGE_DAYS before the capture. A malformed record throws. */ export function normalizeActiveWars(record, capturedAt) { if (record == null) return null; const month = String(record.referenceMonth ?? '').match(ACTIVE_WARS_MONTH_RE); const conflicts = Array.isArray(record.conflicts) ? record.conflicts : null; const threshold = Number(record.threshold); if (!month || !conflicts || !Number.isInteger(threshold) || threshold < 1 || !isCanonicalCalendarDate(record.latestEventDate) || !Number.isInteger(record.belowThresholdCount) || record.belowThresholdCount < 0 || record.countryCount !== conflicts.length) { throw new Error('live pulse activeWars is malformed'); } let previousDeaths = Infinity; for (const row of conflicts) { if (typeof row?.name !== 'string' || !row.name || !Number.isInteger(row.deaths) || row.deaths < threshold || row.deaths > previousDeaths || !Number.isInteger(row.events) || row.events < 1 || typeof row.sideA !== 'string' || typeof row.sideB !== 'string' || (row.code !== null && !/^[A-Z]{2}$/.test(String(row.code)))) { throw new Error(`live pulse activeWars row ${JSON.stringify(row?.name)} is malformed`); } previousDeaths = row.deaths; } const monthEndMs = Date.UTC(Number(month[1]), Number(month[2]), 1); const ageDays = (Date.parse(`${capturedAt}T00:00:00Z`) - monthEndMs) / 86_400_000; if (!(ageDays <= ACTIVE_WARS_MAX_AGE_DAYS) || conflicts.length === 0) return null; return { ...record, monthLabel: `${MONTHS[Number(month[2]) - 1]} ${month[1]}` }; } function listPhrase(items) { if (items.length <= 1) return items.join(''); if (items.length === 2) return `${items[0]} and ${items[1]}`; return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`; } function activeWarSides(row) { return row.sideA && row.sideB ? `${row.sideA} vs ${row.sideB}` : 'Sides not yet identified by UCDP'; } function activeWarsAnswer(activeWars) { const leaders = activeWars.conflicts.slice(0, 3).map((row) => row.name); const noun = activeWars.countryCount === 1 ? 'country' : 'countries'; return `In ${activeWars.monthLabel}, the latest month in UCDP's conflict data, ${activeWars.countryCount} ${noun} ` + `recorded at least ${activeWars.threshold} deaths in state-based armed conflict. ` + `The deadliest were ${listPhrase(leaders)}.`; } function activeWarsDescription(activeWars, crisisCount) { const names = activeWars.conflicts.map((row) => row.name); for (let leaders = Math.min(3, names.length); leaders >= 0; leaders -= 1) { const led = leaders > 0 ? `, led by ${listPhrase(names.slice(0, leaders))}` : ''; const description = `${activeWars.countryCount} ${activeWars.countryCount === 1 ? 'country' : 'countries'} had ${activeWars.threshold}+ deaths in state-based armed conflict in ${activeWars.monthLabel} (UCDP)${led}. Ranked list and ${crisisCount} crisis trackers.`; if (description.length <= META_DESCRIPTION_MAX) return description; } throw new Error('crises hub description exceeds the meta description limit'); } function renderActiveWars(activeWars, { countryByCode, crisisByCountryCode }) { const rows = activeWars.conflicts.map((row, index) => { const country = row.code ? countryByCode.get(row.code) : null; const name = country ? `${escapeHtml(row.name)}` : escapeHtml(row.name); const crisis = row.code ? crisisByCountryCode.get(row.code) : null; const tracker = crisis ? `${escapeHtml(crisis.shortTitle)}` : ''; return ` ${index + 1}${name}${row.deaths.toLocaleString('en-US')}${escapeHtml(activeWarSides(row))}${tracker}`; }); const below = activeWars.belowThresholdCount; const belowSentence = below > 0 ? `${below} more ${below === 1 ? 'country' : 'countries'} recorded state-based fighting with fewer than ${activeWars.threshold} deaths that month. ` : ''; return `

      ${escapeHtml(activeWarsAnswer(activeWars))}

      ${rows.join('\n')}
      Countries with at least ${activeWars.threshold} deaths in state-based armed conflict, ${escapeHtml(activeWars.monthLabel)}. Deaths are UCDP best estimates. Main sides are the pair with the most deaths in that country.
      RankCountryDeathsMain sidesTracker

      ${belowSentence}State-based means a government fought on at least one side. UCDP counts fighting only between armed groups, and attacks on civilians, separately; neither is in this list. UCDP publishes each month about four weeks after it ends, so the list trails the news: the newest event in this release is dated ${timeMarkup(activeWars.latestEventDate)}. For what is happening today, use the monitor the situation board.

      Source: Uppsala Conflict Data Program, Georeferenced Event Dataset candidate release, read through the World Monitor API (/api/conflict/v1/list-ucdp-events).

      `; } function renderCrisesIndex({ crises, baseUrl, lastmod, activeWars = null, countryByCode = new Map() }) { const path = '/crises/'; for (const needle of ['ukraine', 'sudan', 'myanmar', 'palestine', 'red-sea']) { if (!crises.some((crisis) => String(crisis.slug).includes(needle))) { throw new Error('crisis index requires a registry slug containing "' + needle + '"'); } } // A war row links a tracker only when one tracker covers its country. UCDP // files Gaza fighting under Israel, which two other trackers also cover, so // a first-match pick would send Israel vs Hamas to the Iran–Israel tracker. const trackersByCountryCode = new Map(); for (const crisis of crises) { for (const country of crisis.coverage) { trackersByCountryCode.set(country.code, [...(trackersByCountryCode.get(country.code) ?? []), crisis]); } } const crisisByCountryCode = new Map( [...trackersByCountryCode].filter(([, trackers]) => trackers.length === 1).map(([code, [crisis]]) => [code, crisis]), ); const lede = 'Curated, bounded crisis trackers that combine stable coverage definitions with World Monitor’s maintained country-level humanitarian summaries.'; const description = activeWars ? activeWarsDescription(activeWars, crises.length) : `Monthly conflict trackers for ${crises.length} crises, including Ukraine, Sudan, Myanmar, Palestine and the Red Sea, built from UN OCHA HDX HAPI event and fatality data.`; const trackers = ` ${escapeHtml(lede)}

      Each tracker has a fixed, reviewable geographic boundary. The static page explains that boundary and ships a dated maintained-month pulse; JavaScript can refresh newer values and fails closed when a country summary is unavailable.

      For the step-by-step verification procedure, see Verify breaking news.

      ${hubDashboardCta('crises', baseUrl)}

      `; const head = activeWars ? `

      Active armed conflicts, ${escapeHtml(activeWars.monthLabel)}

      Wars happening right now

      ${renderActiveWars(activeWars, { countryByCode, crisisByCountryCode })}

      Current crisis trackers

      ${trackers}` : `

      Bounded trackers

      Current crisis trackers

      ${trackers}`; const body = `${head}

      How these trackers are scoped

      Every tracker names its covered countries up front and never silently widens. Metrics are monthly country-level conflict summaries — recorded events, political-violence events, fatalities, and demonstrations — from the UN OCHA Humanitarian API (HDX HAPI). A combined total is shown only when every covered country reports the same reference month; otherwise per-country figures stand alone.

      What they are not

      These are bounded pulses, not battlefield maps, casualty ledgers, or forecasts. Missing countries are reported as unavailable rather than zero, and event-level context lives in the live dashboard with its map layers and independent signals. All trackers appear by region, with current headlines, chokepoint status and internet outages, on the monitor the situation board.

      Scope source: World Monitor crisis registry. Live metrics: HAPI/HDX humanitarian conflict summaries through the World Monitor API.

      `; const name = activeWars ? 'Wars happening right now' : 'Current crisis trackers'; const warsLd = activeWars ? [ faqPageLd([{ question: 'What wars are happening right now?', answer: activeWarsAnswer(activeWars) }]), { '@context': SCHEMA_ORG_CONTEXT_URL, '@type': 'ItemList', name: `Countries with active state-based armed conflict, ${activeWars.monthLabel}`, numberOfItems: activeWars.countryCount, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: activeWars.conflicts.map((row, index) => { const country = row.code ? countryByCode.get(row.code) : null; return { '@type': 'ListItem', position: index + 1, name: `${row.name}: ${activeWarSides(row)}, ${row.deaths.toLocaleString('en-US')} deaths`, ...(country ? { url: absoluteUrl(baseUrl, `/countries/${country.slug}/`) } : {}), }; }), }, ] : []; return pageDocument({ baseUrl, path, title: activeWars ? `Wars Happening Right Now: ${activeWars.countryCount} Active Conflicts | World Monitor` : 'Crisis Trackers: Ukraine, Sudan, Red Sea | World Monitor', description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'CollectionPage', name, description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', }, ...warsLd, dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Crises', path }, ]), body, }); } function crisisDatasetMetadata(crisis, baseUrl, pulse) { const hasPulse = pulse != null && OBSERVATION_PERIOD_RE.test(String(pulse.referencePeriod ?? '')); const description = hasPulse ? `A bounded World Monitor crisis tracker for ${crisis.title}, with the maintained ${pulse.referencePeriod} HAPI/HDX country summaries across ${crisis.coverage.map((country) => country.name).join(', ')}.` : `A bounded World Monitor crisis tracker reference for ${crisis.title}, defining the maintained geographic scope across ${crisis.coverage.map((country) => country.name).join(', ')}.`; const path = `/crises/${crisis.slug}/`; const url = absoluteUrl(baseUrl, path); return { '@type': 'Dataset', '@id': `${url}#crisis-dataset`, name: `World Monitor crisis tracker reference: ${crisis.shortTitle || crisis.title}`, description, url, keywords: ['crisis tracker', crisis.shortTitle || crisis.title, 'humanitarian conflict', ...crisis.coverage.map((country) => country.name)], distribution: [dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, CRISIS_DATASET_DOWNLOAD)))], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, }; } // Reader-facing names for cited hosts, keyed without a leading "www.". An // unlisted host falls back to its bare hostname. const EDITORIAL_SOURCE_LABELS = new Map([ ['news.un.org', 'UN News'], ['press.un.org', 'UN press'], ['unocha.org', 'OCHA'], ['ochaopt.org', 'OCHA oPt'], ['reliefweb.int', 'ReliefWeb'], ['unhcr.org', 'UNHCR'], ['data.unhcr.org', 'UNHCR'], ['ohchr.org', 'OHCHR'], ['unicef.org', 'UNICEF'], ['unric.org', 'UNRIC'], ['dtm.iom.int', 'IOM DTM'], ['crisisresponse.iom.int', 'IOM'], ['osesgy.unmissions.org', 'UN Special Envoy for Yemen'], ['unmiss.unmissions.org', 'UNMISS'], ['unama.unmissions.org', 'UNAMA'], ['ukraine.un.org', 'UN Ukraine'], ['yemen.un.org', 'UN Yemen'], ['afghanistan.un.org', 'UN Afghanistan'], ['au.int', 'African Union'], ['imo.org', 'IMO'], ['eia.gov', 'EIA'], ['state.gov', 'US State Department'], ['aljazeera.com', 'Al Jazeera'], ['france24.com', 'France 24'], ['africanews.com', 'Africanews'], ['voanews.com', 'VOA'], ['pbs.org', 'PBS NewsHour'], ['nbcnews.com', 'NBC News'], ['arabnews.com', 'Arab News'], ['yahoo.com', 'Yahoo News'], ['ny1.com', 'NY1'], ['wtop.com', 'WTOP'], ['gcaptain.com', 'gCaptain'], ['newcastleherald.com.au', 'Newcastle Herald'], ['lanacion.com.ar', 'La Nación'], ['internazionale.it', 'Internazionale'], ['timesofisrael.com', 'The Times of Israel'], ]); function editorialSourceLabel(url) { const host = new URL(url).hostname.replace(/^www\./, ''); return EDITORIAL_SOURCE_LABELS.get(host) || host; } function editorialDateLabel(date) { if (date.length === 10) return prettyDate(date); const [year, month] = date.split('-'); return `${MONTHS[Number(month) - 1]} ${year}`; } function editorialSourceLink(url) { return `${escapeHtml(editorialSourceLabel(url))}`; } // One label per outlet: several articles from the same outlet read as // "UN News (1, 2, 3)" rather than repeating the name. function editorialSourceLinks(urls) { const groups = new Map(); for (const url of urls) { const label = editorialSourceLabel(url); groups.set(label, [...(groups.get(label) || []), url]); } return [...groups].map(([label, group]) => (group.length === 1 ? editorialSourceLink(group[0]) : `${escapeHtml(label)} (${group.map((url, index) => `${index + 1}`).join(', ')})`)).join(', '); } export function crisisEditorialSourceUrls(editorial) { return [...new Set([ ...editorial.background.flatMap((paragraph) => paragraph.sourceUrls), ...editorial.keyActors.map((actor) => actor.sourceUrl), ...editorial.timeline.map((item) => item.sourceUrl), ])]; } function renderCrisisEditorial(crisis) { const { editorial } = crisis; const background = editorial.background.map((paragraph) => `

      ${escapeHtml(paragraph.text)} Sources: ${editorialSourceLinks(paragraph.sourceUrls)}.

      `).join('\n'); const actors = editorial.keyActors.map((actor) => `
    • ${escapeHtml(actor.name)} — ${escapeHtml(actor.role)} (${editorialSourceLink(actor.sourceUrl)})
    • `).join('\n'); const timeline = editorial.timeline.map((item) => `
    • — ${escapeHtml(item.event)} (${editorialSourceLink(item.sourceUrl)})
    • `).join('\n'); const watch = editorial.whatToWatch.map((item) => `
    • ${escapeHtml(item.indicator)}. ${escapeHtml(item.why)} Tracked in the ${escapeHtml(item.trackedBy)} ${escapeHtml(item.trackedByKind)} on the live dashboard.
    • `).join('\n'); return `

      Background

      ${background}

      Key actors

      Timeline

      What to watch

      Background, actors, timeline and watch list last reviewed against the linked sources. They are context for the monthly figures above, not part of the HAPI/HDX dataset.

      `; } // Every answer is copy already on the page: the latest sourced background // paragraph, the published HAPI totals, and the registry coverage. Nothing here // is written for the FAQ alone, so it cannot drift from the sourced text. export function crisisFaqs({ crisis, pulse = null, publishedTotals = {}, publishedDate = null }) { const { editorial } = crisis; const current = editorial.background.at(-1); const faqs = [{ question: editorial.question, answer: `${current.text} Last reviewed ${prettyDate(editorial.lastReviewed)} against the linked sources.`, }]; // The page's metric tiles refresh in the browser; this answer is frozen at // build time, so it names the published snapshot rather than claiming to be // the latest reading. Totals sum only the countries that reported, so the // scope comes from pulse.rows and any missing country is named as excluded. if (pulse && publishedDate && publishedTotals.eventsTotal != null && publishedTotals.fatalities != null) { const count = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); const summed = listPhrase(pulse.rows.map((row) => row.name)); const missing = pulse.missingCountries || []; const exclusion = missing.length ? ` ${listPhrase(missing)} ${missing.length === 1 ? 'has' : 'have'} no summary for that period and ${missing.length === 1 ? 'is' : 'are'} not included.` : ''; faqs.push({ question: `What did the last published monthly figures show for the ${crisis.shortTitle}?`, answer: `In World Monitor's weekly snapshot published ${prettyDate(publishedDate)}, HAPI/HDX humanitarian conflict summaries record ${count.format(publishedTotals.eventsTotal)} conflict events and ${count.format(publishedTotals.fatalities)} fatalities across ${summed} for the reference period starting ${prettyDate(pulse.referencePeriod)}.${exclusion} These are monthly country-level summaries, not a complete casualty or incident ledger; the figures above may refresh to a newer month.`, }); } faqs.push({ question: `Which countries does the ${crisis.shortTitle} tracker cover?`, answer: `${listPhrase(crisis.coverage.map((country) => country.name))}. Events outside ${crisis.coverage.length === 1 ? 'this country' : 'these countries'} are not included in the totals on this page.`, }); return faqs; } export function renderCrisisCountryReadings({ crisis, countryByCode, ciiByCode, rankedCount }) { const rows = crisis.coverage.map((covered) => { const country = countryByCode.get(covered.code); const cii = ciiByCode.get(covered.code); const name = country ? `${escapeHtml(covered.name)}` : escapeHtml(covered.name); const ciiCells = cii ? `${escapeHtml(formatScore(cii.score, OBSERVED_EVIDENCE))} (${escapeHtml(cii.band)})${escapeHtml(ciiMovementDisplayLabel(cii.trend))}` : 'Not monitored—'; const ranked = country && country.headlineEligible !== false && country.rank != null; const resilienceCells = ranked ? `${escapeHtml(formatScore(country.overallScore, { coverage: true }))} / 100#${escapeHtml(country.rank)} of ${escapeHtml(rankedCount)}` : 'Not ranked—'; return ` ${name}${ciiCells}${resilienceCells}`; }).join('\n'); const monitored = crisis.coverage .map((covered) => ({ covered, cii: ciiByCode.get(covered.code) })) .filter((entry) => entry.cii) .sort((left, right) => right.cii.score - left.cii.score); const scopeNoun = crisis.coverage.length === 1 ? 'covered country' : 'covered countries'; const summary = monitored.length === 0 ? (crisis.coverage.length === 1 ? `${crisis.coverage[0].name} is not in the Country Instability Index's monitored set, so only its structural resilience reading is shown.` : `None of the ${crisis.coverage.length} ${scopeNoun} in this tracker is in the Country Instability Index's monitored set, so only structural resilience readings are shown.`) : monitored.length === 1 ? (crisis.coverage.length === 1 ? `${monitored[0].covered.name} has a Country Instability Index reading of ${formatScore(monitored[0].cii.score, OBSERVED_EVIDENCE)} (${monitored[0].cii.band}).` : `Only ${monitored[0].covered.name}, of the ${crisis.coverage.length} ${scopeNoun}, has a Country Instability Index reading: ${formatScore(monitored[0].cii.score, OBSERVED_EVIDENCE)} (${monitored[0].cii.band}).`) : `${monitored.length} of the ${crisis.coverage.length} ${scopeNoun} have a Country Instability Index reading. The highest is ${monitored[0].covered.name} at ${formatScore(monitored[0].cii.score, OBSERVED_EVIDENCE)} (${monitored[0].cii.band}), and the lowest is ${monitored.at(-1).covered.name} at ${formatScore(monitored.at(-1).cii.score, OBSERVED_EVIDENCE)} (${monitored.at(-1).cii.band}).`; return `

      Country readings

      ${escapeHtml(summary)} Instability is a live 0-100 stress score; resilience is a structural 0-100 index ranked across countries. Both describe each country as a whole, not this crisis alone, and a country without a reading reflects index coverage, not a statement about risk.

      ${rows}
      CountryInstability (CII)CII movementResilience scoreResilience rank

      Sources: Country Instability Index published pulse and the country resilience index snapshot used on each country page.

      `; } function renderCrisisPage({ crisis, countrySlugByCode, countryByCode = new Map(), ciiByCode = new Map(), rankedCount = 0, relatedChokepoints = [], relatedReading = [], baseUrl, lastmod, livePulse = null, livePulseSnapshotPath = null, publishedTotals, }) { const path = `/crises/${crisis.slug}/`; const dashboardUrl = absoluteUrl(baseUrl, crisis.dashboardPath); const pulse = livePulse?.crises?.[crisis.slug] || null; // Require a real observation window. crisisTrackerViewModel substitutes the // sentinel 'Mixed reference periods' when covered countries report different // HAPI months; publishing that as a period would put it in the page prose and // the Dataset description while temporalCoverage silently drops to undefined. const hasPulse = pulse != null && OBSERVATION_PERIOD_RE.test(String(pulse.referencePeriod ?? '')); const publishedPeriod = hasPulse ? datasetTemporalCoverage(pulse.referencePeriod) : undefined; const publishedDate = hasPulse ? pulseDateOnly(livePulse.capturedAt, undefined) : undefined; const liveState = hasPulse ? pulse.state : 'loading'; const liveStatus = hasPulse ? (pulse.state === 'partial' ? 'Published partial pulse' : 'Published pulse') : 'Waiting for live enhancement'; const rowByCode = new Map((pulse?.rows || []).map((row) => [row.code, row])); const countFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); const formatCount = (value, evidence, fallback = '—') => formatObservedNumber( value, evidence, (numeric) => countFormatter.format(numeric), fallback, ); const countryRows = crisis.coverage.map((country) => { const slug = countrySlugByCode.get(country.code); const name = slug ? `${escapeHtml(country.name)}` : escapeHtml(country.name); const row = rowByCode.get(country.code); const value = row ? `${formatCount(row.events, OBSERVED_EVIDENCE)} events · ${formatCount(row.fatalities, OBSERVED_EVIDENCE)} fatalities · ${row.referencePeriod}` : (hasPulse ? 'Unavailable' : 'Waiting for published pulse'); return `
    • ${name}
      ${escapeHtml(value)}
    • `; }).join('\n'); const liveGrid = hasPulse ? `
      Recorded events${escapeHtml(formatCount(pulse.eventsTotal, { coverage: hasPulse }, 'See countries'))}
      Recorded fatalities${escapeHtml(formatCount(pulse.fatalities, { coverage: hasPulse }, 'See countries'))}
      Political violence events${escapeHtml(formatCount(pulse.politicalViolenceEvents, { coverage: hasPulse }, 'See countries'))}
      Reference period${escapeHtml(pulse.referencePeriod)}
      ` : `

      Current monthly conflict metrics load after page enhancement. The tracker scope below remains the dated crawlable reference.

      `; const coverageLabel = hasPulse ? (pulse.missingCountries?.length ? `Unavailable: ${pulse.missingCountries.join(', ')}` : `${pulse.rows.length} ${pulse.rows.length === 1 ? 'country' : 'countries'} available`) : `${crisis.coverage.length} ${crisis.coverage.length === 1 ? 'country' : 'countries'} requested`; const snapshotSection = hasPulse ? `

      Maintained month snapshot

      This page records the committed ${escapeHtml(pulse.referencePeriod)} HAPI/HDX country summaries published ${escapeHtml(prettyDate(livePulse.capturedAt))}. JavaScript can refresh newer values; the numbers above remain available without it.

      Source: World Monitor weekly pulse snapshot, ${escapeHtml(prettyDate(livePulse.capturedAt))}. Combined totals are withheld when covered countries report different reference months.

      ` : ''; const faqs = crisisFaqs({ crisis, pulse: hasPulse ? pulse : null, publishedTotals, publishedDate }); const body = `

      ${escapeHtml(crisis.title)}

      ${escapeHtml(crisis.editorial.question)}

      ${escapeHtml(crisis.description)}

      ${escapeHtml(crisis.overview)}

      ${renderChokepointStatusLinks(relatedChokepoints)}

      Latest maintained month

      Country conflict pulse

      ${escapeHtml(liveStatus)}

      Metrics are country-level records for the latest available HAPI/HDX reference month. Missing countries are unavailable, not zero. A combined total is withheld when reference periods differ.

      ${liveGrid}
        ${countryRows}
      ${escapeHtml(coverageLabel)} ${liveUpdatedMarkup({ asOf: pulse?.asOf || null, fallbackLabel: 'Live enhancement pending', prefix: 'Published pulse', })}
      Investigate this crisis in World Monitor → ${snapshotSection} ${renderCrisisEditorial(crisis)} ${renderCrisisCountryReadings({ crisis, countryByCode, ciiByCode, rankedCount })}

      Coverage boundary

      ${escapeHtml(crisis.coverage.map((country) => `${country.name} (${country.code})`).join(', '))}. Events outside this list are not included in the live totals on this page.

      ${renderLiveDataLinks(crisisLiveLinks({ touchesOilChokepoint: relatedChokepoints.some((chokepoint) => EIA_OIL_TRANSIT_BASELINES.byRegistryId[chokepoint.id]) }), escapeHtml)} ${renderRelatedReading(relatedReading, escapeHtml)}

      How to read this tracker

      Use these monthly country summaries as a bounded pulse, then inspect the dashboard for event-level context, map layers, and other independent signals. The figures are not forecasts and should not be interpreted as a complete casualty or incident ledger. World Monitor's forecasts are graded separately, and that record is published on the forecast accuracy scorecard.

      Questions about the ${escapeHtml(crisis.shortTitle)}

      ${faqs.map((faq) => `
      ${escapeHtml(faq.question)}

      ${escapeHtml(faq.answer)}

      `).join('\n')}

      Download: ${CRISIS_DATASET_DOWNLOAD}. Scope source: World Monitor crisis registry. Maintained metrics: HAPI/HDX humanitarian conflict summaries from the UN OCHA Humanitarian API.

      `; const coveragePlaces = crisis.coverage.map((country) => ({ '@type': 'Country', name: country.name, identifier: country.code, })); // Dataset.spatialCoverage must stay a literal Place for Google; WebPage.about keeps Country. const coverageSpatial = coveragePlaces.map((place) => ({ ...place, '@type': 'Place' })); const variableMeasured = [ { '@type': 'PropertyValue', name: 'Tracker scope', value: crisis.shortTitle || crisis.title, }, { '@type': 'PropertyValue', name: 'Covered countries', value: crisis.coverage.length, unitText: 'countries', }, ...(hasPulse ? [ { '@type': 'PropertyValue', name: 'Recorded conflict events', value: publishedTotals.eventsTotal ?? 'Unavailable', unitText: 'events', }, { '@type': 'PropertyValue', name: 'Recorded fatalities', value: publishedTotals.fatalities ?? 'Unavailable', unitText: 'fatalities', }, { '@type': 'PropertyValue', name: 'Political violence events', value: publishedTotals.politicalViolenceEvents ?? 'Unavailable', unitText: 'events', }, { '@type': 'PropertyValue', name: 'Humanitarian reference period', value: pulse.referencePeriod, }, ] : []), ]; return pageDocument({ baseUrl, path, title: crisis.editorial.pageTitle, description: crisis.description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'WebPage', name: crisis.title, description: crisis.description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', lastReviewed: crisis.editorial.lastReviewed, citation: crisisEditorialSourceUrls(crisis.editorial), about: coveragePlaces, mainEntity: { ...crisisDatasetMetadata(crisis, baseUrl, pulse), identifier: `crisis-tracker-${crisis.slug}`, datePublished: publishedDate, dateModified: laterDate( hasPulse ? pulseDateOnly(pulse.asOf, lastmod) : lastmod, DATASET_SCHEMA_CONTENT_VERSION.crisis, ), temporalCoverage: publishedPeriod, isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), variableMeasured, measurementTechnique: 'Monthly country-level HAPI/HDX humanitarian conflict summaries; combined totals are published only when covered countries share a reference period.', spatialCoverage: coverageSpatial.length === 1 ? coverageSpatial[0] : coverageSpatial, }, }, dataCatalogLd(baseUrl), faqPageLd(faqs), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Crises', path: '/crises/' }, { name: crisis.shortTitle, path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); } // Counts come from the registries this same build renders, never a frozen // literal: the hub card is the one place a stale number reads as a factual // claim about the corpus rather than as prose. function renderToolsIndex({ baseUrl, lastmod, crisisCount, chokepointCount }) { const path = '/tools/'; const description = 'Check hazards, airspace, shipping, outages, markets and economic indicators with live World Monitor tools, named sources, update times and coverage limits.'; const body = `

      Live intelligence tools

      Check a current operational signal

      ${escapeHtml(description)}

      How these tools work

      Each tool asks one narrow operational question — what natural hazards are open right now, is a country's monitored airspace disrupted, where independent streams converge — and answers it from a maintained World Monitor contract. Results are labelled with their source and retrieval time, unavailable data is reported as unavailable rather than zero, and independent signals are never combined into a single opaque threat score unless the tool names that combination explicitly.

      When to use them

      Use these pages for a fast, shareable check before a trip, a shipment, or a market open; use the live dashboard when you need the full picture — map layers, alerts, news, and country briefs side by side. Hazard coverage is documented in natural disaster tracking; chokepoint scoring in the chokepoint methodology; convergence scoring in geographic convergence.

      Live results load from maintained World Monitor API contracts. Static route descriptions remain available if a current source cannot be reached.

      `; return pageDocument({ baseUrl, path, title: 'Live Tools: Outages, Fear & Greed, Oil, Debt | World Monitor', description, lastmod, jsonLd: { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Live intelligence tools', description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', }, breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Live tools', path }, ]), body, }); } function signalConvergenceDatasetMetadata(signalConvergence, baseUrl) { const metricName = signalConvergence.metricName || 'Geographic Convergence Score'; const path = '/tools/signal-convergence/'; const url = absoluteUrl(baseUrl, path); return { '@type': 'Dataset', '@id': `${url}#signal-convergence-dataset`, name: `World Monitor ${metricName} reference`, description: `World Monitor's ${metricName} (0-100) names when protests, military flights, naval vessels, and earthquakes co-occur in the same 1° cell.`, url, keywords: ['signal convergence', 'geographic convergence', 'event correlation', 'geopolitical signals'], distribution: [dataDownload(absoluteUrl(baseUrl, datasetDownloadHref(path, CONVERGENCE_DATASET_DOWNLOAD)))], creator: { ...WORLD_MONITOR_ORG }, license: DATASET_LICENSE, }; } function renderSignalConvergencePage({ signalConvergence, baseUrl, lastmod, reviewedOn, snapshotPath }) { const path = '/tools/signal-convergence/'; const metricName = signalConvergence.metricName || 'Geographic Convergence Score'; const datasetMetadata = signalConvergenceDatasetMetadata(signalConvergence, baseUrl); const { description } = datasetMetadata; const downloadHref = datasetDownloadHref(path, CONVERGENCE_DATASET_DOWNLOAD); const examples = (signalConvergence.referenceExamples || []).map((example) => ( `

      ${escapeHtml(example.kind === 'methodology-example' ? 'Methodology example' : 'Reference')}

      ${escapeHtml(example.label)}

      Cell ${escapeHtml(example.cell)}. Domains: ${escapeHtml(example.types.join(', '))}. Events: ${escapeHtml(String(example.totalEvents))}.

      ${escapeHtml(metricName)}${escapeHtml(formatScore(example.score, OBSERVED_EVIDENCE))}
      Priority${escapeHtml(example.priority)}

      Cited from the Geographic Convergence Detection methodology. Score = min(100, ${escapeHtml(String(example.typeCount))}×25 + min(25, ${escapeHtml(String(example.totalEvents))}×2)).

      ` )).join('\n'); const thresholds = (signalConvergence.thresholds || []).map((row) => ( ` ${escapeHtml(String(row.types))}${escapeHtml(row.scoreRange)}${escapeHtml(row.priority)}` )).join('\n'); const body = `

      Correlation metric

      ${escapeHtml(metricName)}

      ${escapeHtml(description)}

      The named metric is crawlable here. Live cell alerts remain available to agents through the MCP tool get_signal_convergence; this page publishes the score definition, thresholds, and cited reference examples so current-state answers can attribute a World Monitor number.

      Scale0–100
      Default min domains${escapeHtml(String(signalConvergence.defaultMinDomains ?? 3))}
      Grid1° × 1°
      Window24 hours

      How the score is computed

      type_score = event_types × 25. count_boost = min(25, total_events × 2). convergence_score = min(100, type_score + count_boost).

      ${thresholds}
      Alert priority thresholds
      TypesScore rangePriority

      Cited reference examples

      ${examples}

      Where to go next

      Open the live dashboard for map layers, or read the full methodology in geographic convergence. Country pages expose related instability scores; this page is the citable correlation definition.

      Download: ${CONVERGENCE_DATASET_DOWNLOAD}. Snapshot: World Monitor weekly pulse snapshot. Methodology reference, last reviewed ${escapeHtml(prettyDate(reviewedOn))}. Methodology: Geographic Convergence Detection.

      `; return pageDocument({ baseUrl, path, title: `${metricName} | World Monitor`, description, lastmod, jsonLd: [ { '@context': 'https://schema.org', '@type': 'WebPage', name: metricName, description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', mainEntity: { ...datasetMetadata, identifier: 'signal-convergence-reference', // This reference is a formula plus documentation-derived examples. It // has no observation window, so it carries no temporalCoverage; when // available, datePublished identifies the source snapshot. The family // schema stamp rides alongside so a Dataset-shape change signals // recrawl without dragging the page lastmod with it (#7382). datePublished: datasetTemporalCoverage(signalConvergence.capturedAt), dateModified: laterDate(lastmod, DATASET_SCHEMA_CONTENT_VERSION.tools), spatialCoverage: 'Worldwide', isAccessibleForFree: true, includedInDataCatalog: includedInDataCatalog(baseUrl), variableMeasured: [ { '@type': 'PropertyValue', name: metricName, minValue: 0, maxValue: 100, unitText: 'score points' }, { '@type': 'PropertyValue', name: 'Default minimum domains', value: signalConvergence.defaultMinDomains ?? 3, minValue: 1 }, { '@type': 'PropertyValue', name: 'Alert priority thresholds', value: signalConvergence.thresholds?.length ?? 0, unitText: 'thresholds' }, ], measurementTechnique: 'type_score = event_types × 25; count_boost = min(25, total_events × 2); convergence_score = min(100, type_score + count_boost)', citation: absoluteUrl(baseUrl, '/docs/geographic-convergence'), }, }, dataCatalogLd(baseUrl), ], breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Live tools', path: '/tools/' }, { name: metricName, path }, ]), body, }); } function signalConvergenceDatasetDownload(signalConvergence, snapshotPath) { return stableJson({ dataset: 'signal-convergence-reference', metricName: signalConvergence.metricName, methodologyPath: signalConvergence.methodologyPath, scale: signalConvergence.scale, formula: signalConvergence.formula, defaultMinDomains: signalConvergence.defaultMinDomains, thresholds: signalConvergence.thresholds, referenceExamples: signalConvergence.referenceExamples, capturedAt: signalConvergence.capturedAt, source: snapshotPath, license: DATASET_LICENSE.url, }); } function renderHazardPage({ countryBounds, baseUrl, lastmod }) { const path = '/tools/natural-hazard-pulse/'; const description = 'See which natural hazards are active right now — earthquakes, storms, wildfires and floods from EONET, GDACS, NHC and HKO feeds — worldwide or filtered to one country.'; const body = `

      Natural-hazard pulse

      What natural hazards are active now?

      ${escapeHtml(description)}

      The country selector is an approximate geographic filter, not a territorial polygon. Coastal and border events can overlap neighbouring countries. Countries with oversized or discontinuous envelopes are omitted so every country query remains bounded. Agency observations and wind averaging periods remain distinct.

      Current source snapshot

      Open event pulse

      Connecting…
      Open matches—
      CategoriesLoading
      Strongest reported magnitude—
      Most recent event—
      • Loading current events…
      Open the selected area in World Monitor →

      Sources and limits

      World Monitor reads its seeded natural-event snapshot from maintained NASA EONET, GDACS, NHC, and HKO ingestion paths. Source names are retained on individual events. A zero is shown only when a source snapshot is explicitly available; unavailable snapshots fail closed. Coverage and update cadence are documented in natural disaster tracking.

      Geographic filters: ${COUNTRY_BBOXES_PATH}. Live metrics: /api/natural/v1/list-natural-events.

      `; return pageDocument({ baseUrl, path, title: 'Live Natural Hazard Tracker | World Monitor', description, lastmod, jsonLd: { '@context': 'https://schema.org', '@type': 'WebApplication', name: 'World Monitor natural-hazard pulse', description, url: absoluteUrl(baseUrl, path), applicationCategory: 'DataApplication', operatingSystem: 'Any', }, breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Live tools', path: '/tools/' }, { name: 'Natural-hazard pulse', path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); } function renderAirspacePage({ countryBounds, baseUrl, lastmod }) { const path = '/tools/airspace-disruption-checker/'; const description = 'Check monitored commercial-airport disruption and bounded observed military-flight activity for one country without combining the signals into a threat score.'; const body = `

      Airspace-disruption checker

      Check a country’s airspace signals

      ${escapeHtml(description)}

      Commercial disruption and observed military aircraft are independent evidence domains. Aircraft presence does not establish mission, intent, or danger. Airport coverage is limited to monitored hubs.

      Country bounding-box check

      Commercial and observed activity

      Connecting…

      Commercial operations

      Monitored airports

      Loading source coverage…

      Monitored in box—
      Disrupted—
      Normal coverage—
      Unknown coverage—
      • Loading airport coverage…

      Observed activity

      Military-flight returns

      Loading bounded observations…

      Returned aircraft—
      Flagged interesting—
      • Loading returned observations…

      Military results are capped at 100 returned observations for the selected box. Countries with oversized or discontinuous envelopes are omitted so the tool never issues a continent-scale observation query. An empty or failed military response is unavailable, not confirmed zero activity.

      Investigate the selected country in World Monitor →

      How to read the result

      “Normal” applies only to monitored airports with current source coverage. “Unknown” means telemetry was not available and is not counted as normal. Military-flight results are bounded observations from OpenSky/Wingbits-compatible ingestion and are not exhaustive.

      Geographic filters: ${COUNTRY_BBOXES_PATH}. Live metrics: /api/aviation/v1/list-airport-delays and /api/military/v1/list-military-flights.

      `; return pageDocument({ baseUrl, path, title: 'Airspace-Disruption Checker | World Monitor', description, lastmod, jsonLd: { '@context': 'https://schema.org', '@type': 'WebApplication', name: 'World Monitor airspace-disruption checker', description, url: absoluteUrl(baseUrl, path), applicationCategory: 'DataApplication', operatingSystem: 'Any', }, breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Live tools', path: '/tools/' }, { name: 'Airspace-disruption checker', path }, ]), body, scriptSrcs: ['/tools/live-tools.js'], }); } function chunk(values, size) { const chunks = []; for (let i = 0; i < values.length; i += size) chunks.push(values.slice(i, i + size)); return chunks; } function changelogPagePath(index) { return index === 0 ? '/reference/changelog/' : `/reference/changelog/page/${index + 1}/`; } function renderChangelogPage({ releases, pageIndex, totalPages, baseUrl, lastmod }) { const path = changelogPagePath(pageIndex); const paginationLinks = [ pageIndex > 0 ? { rel: 'prev', path: changelogPagePath(pageIndex - 1) } : null, pageIndex + 1 < totalPages ? { rel: 'next', path: changelogPagePath(pageIndex + 1) } : null, ].filter(Boolean); const title = pageIndex === 0 ? 'World Monitor Changelog | World Monitor' : `World Monitor Changelog Page ${pageIndex + 1} | World Monitor`; const description = 'Paginated release notes for World Monitor — new panels, data sources, API changes and fixes.'; const body = `

      Release notes

      World Monitor changelog

      ${escapeHtml(description)}

      ${releases.map((release) => `

      ${escapeHtml(release.label)}${release.date ? ` ${escapeHtml(release.date)}` : ''}

      ${release.headings.length ? `

      ${escapeHtml(release.headings.join(' / '))}

      ` : ''}
        ${release.bullets.map((bullet) => `
      • ${escapeHtml(bullet)}
      • `).join('\n')}
      `).join('\n')}

      Source: World Monitor release notes. Page ${pageIndex + 1} of ${totalPages}.

      `; return pageDocument({ baseUrl, path, title, description, lastmod, paginationLinks, // Pagination beyond the changelog index is crawlable for humans but // intentionally noindex + omitted from the root sitemap (#7380). robots: pageIndex === 0 ? INDEXABLE_ROBOTS_CONTENT : CHANGELOG_PAGINATION_ROBOTS_CONTENT, jsonLd: { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'World Monitor changelog', description, url: absoluteUrl(baseUrl, path), inLanguage: 'en-US', isPartOf: { '@type': 'CreativeWorkSeries', name: 'World Monitor release notes', }, }, breadcrumbs: breadcrumbLd(baseUrl, [ { name: 'Home', path: '/' }, { name: 'Changelog', path: '/reference/changelog/' }, ]), body, }); } function writeGeneratedFile(outDir, relativePath, content) { const target = join(outDir, relativePath); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); } function routeFile(pathname) { const withoutLeading = pathname.replace(/^\/+/, ''); return join(withoutLeading, 'index.html'); } function buildManifest({ data, baseUrl, changelogPageCount }) { const countryRoutes = data.countries.map((country) => `/countries/${country.slug}/`); const chokepointRoutes = data.chokepoints.map((chokepoint) => `/chokepoints/${chokepoint.slug}/`); const crisisRoutes = data.crises.map((crisis) => `/crises/${crisis.slug}/`); const toolRoutes = [ '/tools/natural-hazard-pulse/', '/tools/airspace-disruption-checker/', '/tools/signal-convergence/', ...LIVE_TOPIC_PAGES.map((page) => page.path), ]; const changelogRoutes = Array.from({ length: changelogPageCount }, (_, index) => changelogPagePath(index)); const glossaryRoutes = data.glossaryTerms.map((term) => `/blog/glossary/${term.slug}/`); const researchRoutes = data.researchReports.map(({ report }) => `/research/${report.slug}/`); return { schemaVersion: 1, baseUrl: normalizeBaseUrl(baseUrl), generatorContentVersion: data.generatorContentVersion, sources: data.sources, sections: { countryInstabilityIndex: { count: 1, index: '/country-instability-index/', routes: [], sourceCapturedAt: data.livePulse.capturedAt, }, countries: { count: countryRoutes.length, index: '/countries/', routes: countryRoutes, sourceCapturedAt: data.resilience.capturedAt, }, chokepoints: { count: chokepointRoutes.length, index: '/chokepoints/', routes: chokepointRoutes, }, crises: { count: crisisRoutes.length, index: '/crises/', routes: crisisRoutes, sourceCapturedAt: data.livePulse.capturedAt, }, tools: { count: toolRoutes.length, index: '/tools/', routes: toolRoutes, }, changelog: { // count remains total generated pages (index + pagination). // routes is the sitemap inventory only — page/2+ stay noindex and // are omitted from the root sitemap (#7380). count: changelogRoutes.length, index: '/reference/changelog/', routes: [changelogRoutes[0]], paginationRoutes: changelogRoutes.slice(1), sourceLastmod: data.lastmod.changelog, }, research: { count: researchRoutes.length, index: '/research/', routes: researchRoutes, }, useCases: { count: USE_CASE_PAGES.length, index: '/use-cases/', routes: USE_CASE_PAGES.map((page) => page.path), }, coverage: { count: 1, index: COVERAGE_PAGE_PATH, routes: [], }, accuracy: { count: 1, index: ACCURACY_PAGE_PATH, routes: [], sourceCapturedAt: data.livePulse.capturedAt, }, situation: { count: 1, index: SITUATION_PAGE_PATH, routes: [], sourceCapturedAt: data.livePulse.capturedAt, }, comparisons: { count: COMPARISON_PAGES.length + 1, index: '/compare/', routes: COMPARISON_PAGES.map((page) => page.path), }, sources: { count: buildSourcePages(data.sourceCatalog).length + 1, index: '/sources/', routes: buildSourcePages(data.sourceCatalog).map((page) => page.path), }, glossary: { count: glossaryRoutes.length, index: '/blog/glossary/', routes: glossaryRoutes, generatedBy: 'blog-site Astro build', }, }, }; } export async function buildCorpus({ rootDir = DEFAULT_ROOT, outDir = DEFAULT_OUT_DIR, baseUrl = DEFAULT_BASE_URL, clean = true, livePulseSnapshotPath, now = Date.now(), // Where scripts/data/search-links/ is read from, so a test can build the // real corpus against its own ledgers. searchLinksRootDir = rootDir, } = {}) { const data = await loadCorpusData({ rootDir, livePulseSnapshotPath, now }); const blogPostPaths = blogPostPathsIn(rootDir); const changelogPages = chunk(data.changelog, CHANGELOG_PAGE_SIZE); const manifest = buildManifest({ data, baseUrl, changelogPageCount: changelogPages.length, }); searchLinksByPath = loadSearchLinks({ rootDir: searchLinksRootDir, resolves: createTargetResolver({ rootDir, manifest, blogPostPaths }), }); try { writeCorpus({ data, rootDir, outDir, baseUrl, clean, now, blogPostPaths, changelogPages }); } finally { searchLinksByPath = new Map(); } writeGeneratedFile(outDir, 'crawlable-corpus.json', `${JSON.stringify(manifest, null, 2)}\n`); return manifest; } function writeCorpus({ data, rootDir, outDir, baseUrl, clean, now, blogPostPaths, changelogPages }) { const countrySlugByCode = new Map(data.countries.map((country) => [country.code, country.slug])); const chokepointPageLinks = buildChokepointPageLinks({ ...data, blogPostPaths }); const relatedReading = loadRelatedReading({ rootDir, blogPostPaths }); if (clean) { for (const dir of GENERATED_DIRS) { rmSync(join(outDir, dir), { recursive: true, force: true }); } } // Google validates catalog entries in this document, including bare @id // references. Share self-describing metadata with the canonical detail pages. const sourcesCatalogDatasets = [ ...data.crises.map((crisis) => crisisDatasetMetadata(crisis, baseUrl, data.livePulse.crises?.[crisis.slug])), signalConvergenceDatasetMetadata(data.livePulse.signalConvergence, baseUrl), ]; const sourcePages = buildSourcePages(data.sourceCatalog); const catalogAnchors = sourceCardAnchors(data.sourceCatalog); const sourceHelpers = { absoluteUrl, breadcrumbLd, dataCatalogLd, escapeHtml, pageDocument }; writeGeneratedFile( outDir, 'sources/index.html', renderSourcesIndex({ sourceStats: data.sourceStats, sourceCatalog: data.sourceCatalog, catalogDatasets: sourcesCatalogDatasets, baseUrl, lastmod: data.lastmod.sources, helpers: sourceHelpers, directoryPages: sourcePages, catalogAnchors, }), ); for (const sourcePage of sourcePages) { writeGeneratedFile(outDir, routeFile(sourcePage.path), renderSourcesIndex({ sourceStats: data.sourceStats, sourceCatalog: sourcePage.providers, baseUrl, lastmod: data.lastmod.sources, helpers: sourceHelpers, sourcePage, catalogAnchors, siblingPages: sourcePages.filter((page) => page.domainId === sourcePage.domainId), })); } writeGeneratedFile(outDir, 'sources/search-index.json', JSON.stringify(sourcePages.flatMap((page) => ( page.providers.map((provider) => ({ name: provider.displayName, hosts: provider.hosts, search: [provider.displayName, provider.provider, ...provider.hosts].join(' ').toLowerCase(), domain: provider.domainId, kinds: provider.kinds, country: sourceOriginFilterValue(provider.originCountry), coverage: (provider.coveredCountries || []).map(sourceOriginFilterValue), url: `${page.path}#${catalogAnchors.get(provider.provider)}`, })) )))); writeGeneratedFile( outDir, 'country-instability-index/index.html', renderCountryInstabilityIndexPage({ ciiRanking: data.ciiRanking, resilienceCoverage: data.resilienceCoverage, baseUrl, capturedAt: data.livePulse.capturedAt, lastmod: data.lastmod.countryInstabilityIndex, snapshotPath: data.sources.livePulseSnapshot, }), ); writeGeneratedFile( outDir, datasetDownloadFile('/country-instability-index/', CII_INDEX_DATASET_DOWNLOAD), ciiIndexDatasetDownload(data.ciiRanking, { capturedAt: data.livePulse.capturedAt, snapshotPath: data.sources.livePulseSnapshot, }), ); writeGeneratedFile( outDir, 'countries/index.html', renderCountriesIndex({ countries: data.countries, ciiRanking: data.ciiRanking, resilienceCoverage: data.resilienceCoverage, baseUrl, capturedAt: data.resilience.capturedAt, lastmod: data.lastmod.countriesIndex, snapshotPath: data.sources.resilienceSnapshot, }), ); writeGeneratedFile( outDir, datasetDownloadFile('/countries/', COUNTRIES_INDEX_DATASET_DOWNLOAD), countriesIndexDatasetDownload(data.countries, { capturedAt: data.resilience.capturedAt, snapshotPath: data.sources.resilienceSnapshot, developmentsByCode: new Set( data.countries .filter((country) => developmentsHasDatedItem(data.livePulse?.countries?.[country.code]?.developments)) .map((country) => country.code), ), }), ); const rankedCount = data.resilienceCoverage.ranked; let developmentsPageCount = 0; for (const country of data.countries) { const pagePath = `/countries/${country.slug}/`; const ciiEntry = data.ciiRanking.byCode.get(country.code) || null; const pulseDevelopments = data.livePulse?.countries?.[country.code]?.developments || null; if (developmentsHasDatedItem(pulseDevelopments)) developmentsPageCount += 1; writeGeneratedFile( outDir, routeFile(pagePath), renderCountryPage({ country, relatedChokepoints: chokepointPageLinks.byCountryCode.get(country.code), relatedReading: relatedReading.get(pagePath), relatedCountries: data.relatedCountriesByCode?.get(country.code) || [], baseUrl, capturedAt: data.resilience.capturedAt, lastmod: ciiEntry ? data.lastmod.ciiCountries : data.lastmod.countries, methodologyFormula: data.resilience.methodologyFormula || 'unknown', rankedCount, snapshotNote: data.resilience.snapshotNote, snapshotPath: data.sources.resilienceSnapshot, bbox: data.countryBboxByCode.get(country.code) || null, livePulse: data.livePulse, ciiEntry, now, }), ); if (ciiEntry) { writeGeneratedFile( outDir, datasetDownloadFile(pagePath, COUNTRY_CII_DATASET_DOWNLOAD), countryCiiDatasetDownload(country, ciiEntry, { capturedAt: data.livePulse.capturedAt, snapshotPath: data.sources.livePulseSnapshot, }), ); } writeGeneratedFile( outDir, datasetDownloadFile(pagePath, COUNTRY_DATASET_DOWNLOAD), countryDatasetDownload(country, { capturedAt: data.resilience.capturedAt, methodologyFormula: data.resilience.methodologyFormula || 'unknown', rankedCount, snapshotPath: data.sources.resilienceSnapshot, developments: pulseDevelopments, }), ); } // Pipeline tripwire (#7615): the per-page guard proves frozen items render; // this proves the freeze captured at least one dated, sourced item for every // indexed country page. // Snapshots frozen before developments existed carry no `developments` key // at all; the tripwire applies only once the snapshot has the shape, so // older committed snapshots (and the tests pinned to them) keep building. const snapshotCarriesDevelopments = Object.values(data.livePulse?.countries || {}) .some((row) => row && typeof row === 'object' && 'developments' in row); const coverageRatioOverride = resolveDevelopmentsCoverageRatioOverride(); if (coverageRatioOverride != null) { console.warn(`[build-crawlable-corpus] developments coverage floor overridden to ${coverageRatioOverride} via ${DEVELOPMENTS_COVERAGE_RATIO_ENV}`); } assertDevelopmentsCoverage({ carriesDevelopments: snapshotCarriesDevelopments, developmentsPageCount, indexedCountryPageCount: data.countries.length, countryIndexAttempted: snapshotAttemptedCountryIndex(data.livePulse), ratioOverride: coverageRatioOverride, }); const chokepointHubRows = buildChokepointHubRows(data.chokepoints, data.livePulse); writeGeneratedFile( outDir, 'chokepoints/index.html', renderChokepointsIndex({ chokepoints: data.chokepoints, chokepointHubRows, livePulse: data.livePulse, baseUrl, lastmod: data.lastmod.chokepoints, snapshotPath: data.sources.livePulseSnapshot, }), ); writeGeneratedFile( outDir, datasetDownloadFile('/chokepoints/', CHOKEPOINTS_INDEX_DATASET_DOWNLOAD), chokepointsIndexDatasetDownload( chokepointHubRows, { capturedAt: data.livePulse.capturedAt, snapshotPath: data.sources.livePulseSnapshot, }, ), ); for (const chokepoint of data.chokepoints) { const pagePath = `/chokepoints/${chokepoint.slug}/`; writeGeneratedFile( outDir, routeFile(pagePath), renderChokepointPage({ chokepoint, relatedCountries: chokepointPageLinks.byChokepointId.get(chokepoint.id).countries, relatedCrises: chokepointPageLinks.byChokepointId.get(chokepoint.id).crises, editorialLinks: chokepointPageLinks.byChokepointId.get(chokepoint.id).editorial, baseUrl, lastmod: data.lastmod.chokepoints, tradeRoutesById: data.tradeRoutesById, researchReports: data.researchReports, livePulse: data.livePulse, capturedAt: data.chokepointObservation?.capturedAt || null, volumeObservedAt: data.chokepointObservation?.volumeObservedAt || null, }), ); writeGeneratedFile( outDir, datasetDownloadFile(pagePath, CHOKEPOINT_DATASET_DOWNLOAD), chokepointDatasetDownload(chokepoint, { tradeRoutesById: data.tradeRoutesById, capturedAt: data.chokepointObservation?.capturedAt || null, volumeObservedAt: data.chokepointObservation?.volumeObservedAt || null, }), ); } writeResearchSection({ data, outDir, baseUrl, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, dataCatalog: dataCatalogLd(baseUrl), includedInDataCatalog: includedInDataCatalog(baseUrl), }); writeUseCasesSection({ outDir, baseUrl, lastmod: data.lastmod.useCases, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, }); writeCoverageSection({ outDir, baseUrl, lastmod: data.lastmod.coverage, catalog: data.coverageCatalog, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, }); writeAccuracySection({ outDir, baseUrl, lastmod: data.lastmod.accuracy, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, section: data.livePulse.forecastScorecard ?? null, snapshotPath: data.sources.livePulseSnapshot, dataCatalog: dataCatalogLd(baseUrl), dataset: { filename: ACCURACY_DATASET_DOWNLOAD, href: datasetDownloadHref(ACCURACY_PAGE_PATH, ACCURACY_DATASET_DOWNLOAD), file: datasetDownloadFile(ACCURACY_PAGE_PATH, ACCURACY_DATASET_DOWNLOAD), download: dataDownload( absoluteUrl(baseUrl, datasetDownloadHref(ACCURACY_PAGE_PATH, ACCURACY_DATASET_DOWNLOAD)), ), catalog: includedInDataCatalog(baseUrl), }, }); writeSituationPage({ outDir, baseUrl, lastmod: data.lastmod.situation, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, crises: data.crises, livePulse: data.livePulse, hubRows: chokepointHubRows, snapshotPath: data.sources.livePulseSnapshot, }); writeComparisonPages({ outDir, baseUrl, lastmod: data.lastmod.comparisons, snapshotDate: data.livePulse.capturedAt, relatedReading, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, }); writeGeneratedFile( outDir, 'tools/live-tools.js', readText(rootDir, LIVE_TOOLS_SCRIPT_PATH), ); writeGeneratedFile( outDir, 'tools/index.html', renderToolsIndex({ baseUrl, lastmod: data.lastmod.toolsIndex, crisisCount: data.crises.length, chokepointCount: data.chokepoints.length, }), ); writeGeneratedFile( outDir, 'tools/natural-hazard-pulse/index.html', renderHazardPage({ countryBounds: data.countryBounds, baseUrl, lastmod: data.lastmod.tools, }), ); writeGeneratedFile( outDir, 'tools/airspace-disruption-checker/index.html', renderAirspacePage({ countryBounds: data.countryBounds, baseUrl, lastmod: data.lastmod.tools, }), ); writeGeneratedFile( outDir, 'tools/signal-convergence/index.html', renderSignalConvergencePage({ signalConvergence: data.livePulse.signalConvergence, baseUrl, lastmod: data.lastmod.tools, reviewedOn: data.toolsReviewedOn, snapshotPath: data.sources.livePulseSnapshot, }), ); writeLiveTopicPages({ outDir, baseUrl, lastmod: data.lastmod.liveTopics, liveTopics: data.livePulse.liveTopics ?? {}, tpl: { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument }, }); writeGeneratedFile( outDir, datasetDownloadFile('/tools/signal-convergence/', CONVERGENCE_DATASET_DOWNLOAD), signalConvergenceDatasetDownload( data.livePulse.signalConvergence, data.sources.livePulseSnapshot, ), ); writeGeneratedFile( outDir, 'crises/index.html', renderCrisesIndex({ crises: data.crises, baseUrl, lastmod: data.lastmod.crises, activeWars: data.activeWars, countryByCode: new Map(data.countries.map((country) => [country.code, country])), }), ); const crisisCountryByCode = new Map(data.countries.map((country) => [country.code, country])); for (const crisis of data.crises) { const pagePath = `/crises/${crisis.slug}/`; const crisisPulse = data.livePulse.crises?.[crisis.slug] || null; const crisisPulseTotals = crisisPulse ? pulseTotals(crisisPulse) : null; writeGeneratedFile( outDir, routeFile(pagePath), renderCrisisPage({ crisis, countrySlugByCode, countryByCode: crisisCountryByCode, ciiByCode: data.ciiRanking.byCode, rankedCount: data.resilienceCoverage.ranked, relatedChokepoints: chokepointPageLinks.byCrisisSlug.get(crisis.slug), relatedReading: relatedReading.get(pagePath), baseUrl, lastmod: data.lastmod.crises, livePulse: data.livePulse, livePulseSnapshotPath: data.sources.livePulseSnapshot, publishedTotals: crisisPulseTotals, }), ); writeGeneratedFile( outDir, datasetDownloadFile(pagePath, CRISIS_DATASET_DOWNLOAD), crisisDatasetDownload(crisis, crisisPulse, crisisPulseTotals), ); } changelogPages.forEach((releases, pageIndex) => { writeGeneratedFile( outDir, routeFile(changelogPagePath(pageIndex)), renderChangelogPage({ releases, pageIndex, totalPages: changelogPages.length, baseUrl, lastmod: data.lastmod.changelog, }), ); }); } function parseArgs(argv) { const options = { rootDir: DEFAULT_ROOT, outDir: DEFAULT_OUT_DIR, baseUrl: DEFAULT_BASE_URL, clean: true, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--no-clean') { options.clean = false; } else if (arg === '--out-dir') { options.outDir = resolve(argv[++i]); } else if (arg.startsWith('--out-dir=')) { options.outDir = resolve(arg.slice('--out-dir='.length)); } else if (arg === '--root-dir') { options.rootDir = resolve(argv[++i]); } else if (arg.startsWith('--root-dir=')) { options.rootDir = resolve(arg.slice('--root-dir='.length)); } else if (arg === '--base-url') { options.baseUrl = argv[++i]; } else if (arg.startsWith('--base-url=')) { options.baseUrl = arg.slice('--base-url='.length); } else { throw new Error(`Unknown argument: ${arg}`); } } return options; } async function main() { const options = parseArgs(process.argv.slice(2)); const manifest = await buildCorpus(options); process.stdout.write( `Wrote crawlable corpus: ${manifest.sections.countries.count} countries, ` + `${manifest.sections.countryInstabilityIndex.count} CII ranking page, ` + `${manifest.sections.chokepoints.count} chokepoints, ` + `${manifest.sections.crises.count} crisis trackers, ` + `${manifest.sections.tools.count} live tools, ` + `${manifest.sections.research.count} research reports, ` + `${manifest.sections.changelog.count} changelog pages, ` + `${manifest.sections.comparisons.count} comparison pages, ` + `${manifest.sections.sources.count} source catalog page. ` + `Glossary manifest references ${manifest.sections.glossary.count} existing blog pages.\n`, ); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((err) => { process.stderr.write(`${err.stack || err.message}\n`); process.exit(1); }); }