diff --git a/internal/check-popup-href-escaped.mjs b/internal/check-popup-href-escaped.mjs
new file mode 100644
index 000000000..26b5889f4
--- /dev/null
+++ b/internal/check-popup-href-escaped.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+/**
+ * check-popup-href-escaped — a URL built into an HTML string is escaped before it lands in href=.
+ *
+ * ★ THE BUG THIS WAS WRITTEN FOR (regions, 2026-09-29). RegionCountryMap builds its map popup by
+ * string concatenation, and every value in it went through esc() — the name, the religion, the
+ * description, the image src, the button labels — except the two URLs:
+ *
+ *
+ * i.e. out of the attribute and into arbitrary new ones. esc() closes it; it was already imported
+ * in that file and used on every other value.
+ *
+ * ★ WHY A GATE AND NOT JUST THE FIX. The popup is built in a template literal, so no template
+ * analysis and no linter sees the attribute. It also only renders once a map DRAWS, and that popup
+ * had never been exercised — the Regions map was grey — so review was the only thing standing
+ * between this and production, and review had already passed it.
+ *
+ * WHAT COUNTS. Only HTML being BUILT: href=" or src=" immediately followed by an interpolation, in
+ * a .vue or .js under library/ or plugin-designs/. A querySelector(`link[href="${CONST}"]`) is not
+ * HTML generation and is skipped by requiring the match to sit in a string that also opens a tag.
+ * An interpolation is accepted when its FIRST call is an escaper (esc/escapeHtml/encodeURI*), or
+ * when it is a bare constant-cased identifier (HREF, MAPBOX_CSS) which cannot carry user data.
+ *
+ * EXIT 0 pass · 1 an unescaped URL interpolation
+ */
+import { readFileSync, readdirSync, statSync } from 'node:fs'
+import { join, resolve, dirname, relative } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+let bad = 0
+let exempt = 0
+let quiet = false // self-tests probe the same function without printing
+const fail = (m) => { bad++; if (!quiet) console.log(' FAIL ' + m) }
+
+const walk = (d, out = []) => {
+ for (const n of readdirSync(d)) {
+ if (n === 'node_modules' || n.startsWith('.')) continue
+ const p = join(d, n)
+ if (statSync(p).isDirectory()) walk(p, out)
+ else if (/\.(vue|js|mjs)$/.test(n)) out.push(p)
+ }
+ return out
+}
+
+// href=" or src=" followed straight by ${...}. The capture is the interpolation body.
+const ATTR_INTERP = /\b(href|src)="\$\{([^}]*)\}/g
+// Safe, and every entry here is a value a quote CANNOT reach — verified at each call site, not
+// assumed from the name:
+// esc/escapeHtml/escapeAttr escape it.
+// safeUrl(...) validates the protocol AND calls escapeHtml (library/utils/html.js:17).
+// encodeURI/encodeURIComponent percent-encode the quote. Accepted anywhere in the expression,
+// because export-public.mjs builds `${enc(a)}/${enc(b)}` and the first
+// call is not the only one that matters.
+// a SCREAMING_CASE constant is a module constant, not user data.
+// objectUrl / dataUrl URL.createObjectURL gives blob:, and FileReader.readAsDataURL
+// gives base64 — neither can contain a quote. Matched by NAME, which is
+// weaker than the others, so the name must be exactly one of these.
+const SAFE = new RegExp(
+ '^\\s*(?:esc|escapeHtml|escapeAttr|safeUrl)\\s*\\(' // escaped at the call
+ + '|(?:encodeURI|encodeURIComponent)\\s*\\(' // percent-encoded anywhere
+ + '|^\\s*[A-Z][A-Z0-9_]*\\s*$' // a module constant
+ + '|^\\s*(?:url|objectUrl|dataUrl|blobUrl)\\s*$') // blob: / data: — quote-free by construction
+// HTML generation, not a selector: the same line (or the two before it) opens a tag.
+const OPENS_TAG = /<\s*[a-zA-Z][^>]*$|<\s*[a-zA-Z][a-zA-Z0-9]*\s/
+
+const check = (src, rel) => {
+ const lines = src.split('\n')
+ lines.forEach((l, i) => {
+ for (const m of l.matchAll(ATTR_INTERP)) {
+ const body = m[2]
+ if (SAFE.test(body)) continue
+ const ctx = lines.slice(Math.max(0, i - 2), i + 1).join('\n')
+ if (!OPENS_TAG.test(ctx)) continue // a querySelector, not built HTML
+ // An escape hatch for a value this check cannot see the safety of — a URL built from
+ // encodeURIComponent on an earlier line, or a module constant reached through a property.
+ // The reason must be written down, in the 4 lines above, so the next reader can re-judge it.
+ const why = lines.slice(Math.max(0, i - 4), i + 1).join('\n')
+ if (/href-escaped-ok:\s*\S/.test(why)) { exempt++; continue }
+ fail(`${rel}:${i + 1} builds ${m[1]}="\${${body.trim().slice(0, 44)}}" without escaping — a quote in the value escapes the attribute (use esc())`)
+ }
+ })
+}
+
+for (const f of [...walk(join(ROOT, 'library')), ...walk(join(ROOT, 'plugin-designs'))]) {
+ check(readFileSync(f, 'utf8'), relative(ROOT, f))
+}
+
+// ── self-tests: the arm must fire on the founding bug and stay quiet on the real safe shapes ──
+const t = (name, src, shouldFail) => {
+ const before = bad
+ quiet = true
+ check(src, '')
+ quiet = false
+ const fired = bad > before
+ bad = before
+ if (fired !== shouldFail) { bad++; console.log(` FAIL self-test "${name}": expected ${shouldFail ? 'FAIL' : 'pass'}, got ${fired ? 'FAIL' : 'pass'}`) }
+}
+t('the founding bug', '`x`', true)
+t('escaped with esc()', '`x`', false)
+t('encodeURI', '`
`', false)
+t('a SCREAMING_CASE constant', '``', false)
+t('querySelector, not built HTML', 'root.querySelector(`link[href="${cssUrl}"]`)', false)
+t('src on an img', '`
`', true)
+
+if (bad) { console.log(`POPUP-HREF-ESCAPED: FAIL — ${bad} problem(s).`); process.exit(1) }
+console.log(`POPUP-HREF-ESCAPED: PASS — every href/src interpolation in built HTML is escaped or explained; ${exempt} documented exception(s); 6 self-tests.`)
diff --git a/library/components/maps/RegionCountryMap.vue b/library/components/maps/RegionCountryMap.vue
index bf57659f5..acaf95eef 100644
--- a/library/components/maps/RegionCountryMap.vue
+++ b/library/components/maps/RegionCountryMap.vue
@@ -443,8 +443,8 @@ function popupHtml(p) {
${rows.length ? `` : ''}
${p.description ? `` : ''}
`
diff --git a/library/composables/useMapAttribution.js b/library/composables/useMapAttribution.js
index 2da0f201e..3452f6f68 100644
--- a/library/composables/useMapAttribution.js
+++ b/library/composables/useMapAttribution.js
@@ -80,6 +80,9 @@ export function addMapAttribution(map, opts = {}) {
if (boundaries) parts.push(LINKS.boundaries);
if (basemap) parts.push(...basemapCredit(map));
+ // href-escaped-ok: every p comes from the LINKS table above — module constants chosen by engine
+ // type in basemapCredit(). No API value and no host attribute reaches this href, so there is
+ // nothing user-supplied to escape. (check:popup-href-escaped)
el.innerHTML = parts.map((p) =>
` `
${list.map((p) => {
const rel = `${encodeURIComponent(safe(name))}/${encodeURIComponent(safe(p.title))}.png`;
const f = join(DEST, safe(name), `${safe(p.title)}.png`);
+ // href-escaped-ok: rel is built two lines up entirely from encodeURIComponent(), which
+ // percent-encodes a quote, so it cannot break out of the attribute. (check:popup-href-escaped)
return `${esc(p.title)}
${p.count} UPG · ${existsSync(f) ? kb(f) : 0} KB`;
}).join('')}
diff --git a/package.json b/package.json
index 799816349..b77fe9709 100644
--- a/package.json
+++ b/package.json
@@ -230,6 +230,7 @@
"check:one-scroll-container": "node internal/check-one-scroll-container.mjs",
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
+ "check:popup-href-escaped": "node internal/check-popup-href-escaped.mjs",
"check:poster-attribution": "node internal/check-poster-attribution.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
@@ -322,7 +323,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@@ -512,6 +513,7 @@
"check:popup-escaping": "the library's pin and region popups render hostile data (an
, a