#!/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 `
`;
}
// 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 `
${mainHtml}${searchLinks ? `\n${searchLinks}` : ''}
${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
Published ${escapeHtml(formatStaticDateTime(ciiRanking.updatedAt))} from the committed crawlable pulse. The table refreshes from the current API after load.
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.
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
`).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)}.
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 = `
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(`
`);
}
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 `
`;
}
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) => `
`);
}
// 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(`
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(/