+
{{ heading }}
{{ tag }}
+
diff --git a/library/components/poster/posterTypes.js b/library/components/poster/posterTypes.js
new file mode 100644
index 000000000..7636b2327
--- /dev/null
+++ b/library/components/poster/posterTypes.js
@@ -0,0 +1,32 @@
+/**
+ * posterTypes.js — the POSTER TYPES a poster section can show, by name.
+ *
+ * A type is an ADAPTER: what its posters are and how they print. The library registers 'country' (its data and
+ * its sheet path are library code); a plugin registers its own type from its own code (registerPosterType), so no
+ * plugin-specific code lives here. Loaded lazily: a page that shows only country posters never pulls another
+ * type's data.
+ *
+ * adapter = {
+ * items(scope, locale) → the cards for a scope (a region / continent / list key), [] when none
+ * title(scope, t) → the section's heading, in the reader's language
+ * actions({ assetsBase, apiBase, lang, profileConfig, t }) → the PosterWall props that print a card:
+ * { records, sheet, unsupported, unit, itemCountKey, eventName, downloadPrefix, cardStocks }
+ * }
+ */
+const LOADERS = new Map([
+ ['country', () => import('./types/countryPosterType.js')],
+])
+const CACHE = new Map()
+
+/** Register (or replace) a type: an adapter object, or a function that loads one (a dynamic import). */
+export function registerPosterType(name, adapterOrLoader) {
+ LOADERS.set(name, typeof adapterOrLoader === 'function' ? adapterOrLoader : () => Promise.resolve({ default: adapterOrLoader }))
+ CACHE.delete(name)
+}
+/** The adapter for a type, or null for a type nobody registered. */
+export function posterType(name) {
+ if (!LOADERS.has(name)) return Promise.resolve(null)
+ if (!CACHE.has(name)) CACHE.set(name, LOADERS.get(name)().then((m) => m.default || m))
+ return CACHE.get(name)
+}
+export const posterTypeNames = () => [...LOADERS.keys()]
diff --git a/library/components/poster/types/countryPosterType.js b/library/components/poster/types/countryPosterType.js
new file mode 100644
index 000000000..5c613bff8
--- /dev/null
+++ b/library/components/poster/types/countryPosterType.js
@@ -0,0 +1,49 @@
+/**
+ * The 'country' poster type: one poster per country, grouped by its WAGF region. Exactly what the Country Posters
+ * wall shows for a region: the same data (country-posters.json), the same records provider, the same sheet path
+ * (countrySheet.js, so a withheld country stays withheld), the same region headings.
+ * scope = a region key in ANY of its spellings: DX Life's /regions slug ('latin-america-and-caribbean'), the API's
+ * value ('latin_america_&_caribbean'), the wall's slug ('latin_america_&_caribbean') or the English name. They all
+ * reduce to one canonical key (DX Life's slugify: no diacritics, lowercase, "&" → "and", non-alphanumerics → "-");
+ * a country with no region is 'other'.
+ */
+import data from '@map/data/country-posters.json'
+import { makeCountrySheet, sheetWithheld } from '@map/packet/countrySheet.js'
+import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
+import { keyedError } from '@map/i18n/keyedError.js'
+import { countryName } from '@map/utils/countryName.js'
+
+const slug = (s) => String(s || '').toLowerCase().replace(/\s+/g, '_') // the wall's slug: its i18n heading key
+export const regionKey = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
+ .replace(/['’.]/g, '').replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'other'
+const TOO_MANY = 'Too many people groups to fit on one poster legibly — a solution is in progress.'
+const inScope = (scope) => (c) => regionKey(c.region) === regionKey(scope)
+
+export default {
+ scopes: () => [...new Set(data.countries.map((c) => regionKey(c.region)))],
+ items(scope, locale) {
+ const rows = data.countries.filter(inScope(scope))
+ if (/^en\b/i.test(locale || 'en')) return rows
+ return rows.map((c) => ({ ...c, label: countryName(c.iso3, locale) || c.label }))
+ },
+ title(scope, t) {
+ const region = (data.countries.find(inScope(scope)) || {}).region || String(scope || '')
+ if (!region || region === 'Other') return t('dashboard.other')
+ const key = `dashboard.wagfRegion.${slug(region)}`
+ const v = t(key)
+ return v && v !== key ? v : region
+ },
+ actions({ assetsBase, apiBase, lang, profileConfig, t }) {
+ const recordsFor = createPeopleGroupRecords({ apiBase, lang, profileConfig })
+ const sheet = makeCountrySheet({ records: recordsFor, assetsBase, countries: data.countries })
+ const byId = new Map(data.countries.map((c) => [c.id, c]))
+ return {
+ // a withheld whole-country poster withholds its list, packet and cards too (countrySheet.js WITHHELD)
+ records: (c) => (sheetWithheld(c) ? Promise.reject(keyedError('messages.countryPosterTooMany', TOO_MANY)) : recordsFor({ type: 'country', values: [c.iso3] })),
+ // the sheet gets the data's own record (English, the series index by id), as on the wall
+ sheet: (card, format) => sheet(byId.get(card.id) || card, format),
+ unsupported: (c) => (sheetWithheld(c) || c.unsupportedReason ? t('messages.countryPosterTooMany') : null),
+ unit: 'UPG', itemCountKey: 'regions.countryCount', eventName: 'doxa-country-posters:show-on-map', downloadPrefix: 'doxa-country', cardStocks: true,
+ }
+ },
+}
diff --git a/library/elements/posterSectionConfig.js b/library/elements/posterSectionConfig.js
new file mode 100644
index 000000000..fa84f784f
--- /dev/null
+++ b/library/elements/posterSectionConfig.js
@@ -0,0 +1,14 @@
+//
's attributes → its profile-config. Pure (no Vue), so it is testable on its own.
+export const ATTRS = ['type', 'scope', 'collapsible', 'collapsed', 'locale', 'assets-base', 'api-base-url', 'is-dark']
+export const TAG = 'dxm-poster-section'
+
+export function posterSectionConfig(el) {
+ const a = (n) => el.getAttribute(n)
+ const flag = (n, dflt) => (el.hasAttribute(n) ? a(n) !== 'false' : dflt)
+ const cfg = { type: a('type') || 'country', scope: a('scope') || '', collapsible: flag('collapsible', true), collapsed: flag('collapsed', false), isDark: flag('is-dark', false) }
+ if (a('locale')) cfg.locale = a('locale')
+ if (a('assets-base')) cfg.assetsBase = a('assets-base')
+ if (a('api-base-url')) cfg.apiBaseUrl = a('api-base-url')
+ return cfg
+}
+
diff --git a/library/elements/posterSectionElement.js b/library/elements/posterSectionElement.js
new file mode 100644
index 000000000..fcdb52b37
--- /dev/null
+++ b/library/elements/posterSectionElement.js
@@ -0,0 +1,40 @@
+/**
+ * posterSectionElement.js — registers : ONE poster section, any poster type, as an element.
+ *
+ *
+ *
+ * Attributes: type (default "country"), scope (a region / continent / list key), collapsible (default on;
+ * collapsible="false" turns it off), collapsed (start shut), locale, assets-base, api-base-url, is-dark.
+ * They are mapped into the standard element's profile-config, so the element keeps defineMapElement's per-instance
+ * i18n and store, and the profile (PosterSectionView.vue) reads them like every other profile reads its config.
+ * Any bundle can call registerPosterSectionElement(); a second call is a no-op.
+ */
+import { defineMapElement } from './defineMapElement.js'
+
+import { ATTRS, TAG, posterSectionConfig } from './posterSectionConfig.js'
+export { posterSectionConfig }
+
+export function registerPosterSectionElement(tag = TAG) {
+ if (typeof customElements === 'undefined' || customElements.get(tag)) return
+ const Base = defineMapElement({ profileModules: { './poster-section.vue': () => import('../components/poster/PosterSectionView.vue') } })
+ class PosterSectionElement extends Base {
+ connectedCallback() {
+ this.syncConfig()
+ if (!this.attrWatch) {
+ this.attrWatch = new MutationObserver(() => this.syncConfig())
+ this.attrWatch.observe(this, { attributes: true, attributeFilter: ATTRS })
+ }
+ super.connectedCallback()
+ }
+ disconnectedCallback() {
+ if (this.attrWatch) { this.attrWatch.disconnect(); this.attrWatch = null }
+ super.disconnectedCallback()
+ }
+ syncConfig() {
+ const next = JSON.stringify(posterSectionConfig(this))
+ if (this.getAttribute('profile-config') !== next) this.setAttribute('profile-config', next)
+ }
+ }
+ customElements.define(tag, PosterSectionElement)
+}
+export default registerPosterSectionElement
diff --git a/library/i18n/locales/ar/poster.json b/library/i18n/locales/ar/poster.json
index 1a5dfa683..6e1dde73f 100644
--- a/library/i18n/locales/ar/poster.json
+++ b/library/i18n/locales/ar/poster.json
@@ -41,5 +41,6 @@
"optional": "(اختياري)",
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها.",
"phoneLimitAll": "يتطلّب هذا حاسوبًا مكتبيًا أو محمولًا.",
- "glue_here": "الصق هنا"
+ "glue_here": "الصق هنا",
+ "sectionNone": "لا توجد ملصقات لـ {scope}."
}
diff --git a/library/i18n/locales/de/poster.json b/library/i18n/locales/de/poster.json
index 5817ab676..edbae9b02 100644
--- a/library/i18n/locales/de/poster.json
+++ b/library/i18n/locales/de/poster.json
@@ -41,5 +41,6 @@
"optional": "(optional)",
"phoneLimit": "Auf dem Handy sind die größten Formate ausgeschaltet: Ein Handy kann kein so großes Poster zeichnen. Nutzen Sie dafür einen Computer.",
"phoneLimitAll": "Dafür ist ein Desktop- oder Laptop-Computer nötig.",
- "glue_here": "hier kleben"
+ "glue_here": "hier kleben",
+ "sectionNone": "Keine Poster für {scope}."
}
diff --git a/library/i18n/locales/en/poster.json b/library/i18n/locales/en/poster.json
index a21f86145..3615d5d9e 100644
--- a/library/i18n/locales/en/poster.json
+++ b/library/i18n/locales/en/poster.json
@@ -41,5 +41,6 @@
"optional": "(optional)",
"phoneLimit": "On a phone, the largest sizes are off: a phone can't draw a poster that big. Use a computer for them.",
"phoneLimitAll": "This needs a desktop or laptop computer.",
- "glue_here": "glue here"
+ "glue_here": "glue here",
+ "sectionNone": "No posters for {scope}."
}
diff --git a/library/i18n/locales/es/poster.json b/library/i18n/locales/es/poster.json
index 3d59dd59b..b3481cca4 100644
--- a/library/i18n/locales/es/poster.json
+++ b/library/i18n/locales/es/poster.json
@@ -41,5 +41,6 @@
"optional": "(opcional)",
"phoneLimit": "En un teléfono, los tamaños más grandes están desactivados: un teléfono no puede dibujar un póster tan grande. Usa un ordenador para ellos.",
"phoneLimitAll": "Esto requiere una computadora de escritorio o portátil.",
- "glue_here": "pegar aquí"
+ "glue_here": "pegar aquí",
+ "sectionNone": "No hay pósteres para {scope}."
}
diff --git a/library/i18n/locales/fr/poster.json b/library/i18n/locales/fr/poster.json
index 3e84b2e7d..604529405 100644
--- a/library/i18n/locales/fr/poster.json
+++ b/library/i18n/locales/fr/poster.json
@@ -41,5 +41,6 @@
"optional": "(facultatif)",
"phoneLimit": "Sur un téléphone, les plus grands formats sont désactivés : un téléphone ne peut pas dessiner une affiche aussi grande. Utilisez un ordinateur pour ceux-là.",
"phoneLimitAll": "Cela nécessite un ordinateur de bureau ou un portable.",
- "glue_here": "coller ici"
+ "glue_here": "coller ici",
+ "sectionNone": "Aucune affiche pour {scope}."
}
diff --git a/library/i18n/locales/hi/poster.json b/library/i18n/locales/hi/poster.json
index b7be9c54b..04499ee5b 100644
--- a/library/i18n/locales/hi/poster.json
+++ b/library/i18n/locales/hi/poster.json
@@ -41,5 +41,6 @@
"optional": "(वैकल्पिक)",
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।",
"phoneLimitAll": "इसके लिए डेस्कटॉप या लैपटॉप कंप्यूटर चाहिए।",
- "glue_here": "यहाँ चिपकाएँ"
+ "glue_here": "यहाँ चिपकाएँ",
+ "sectionNone": "{scope} के लिए कोई पोस्टर नहीं।"
}
diff --git a/library/i18n/locales/it/poster.json b/library/i18n/locales/it/poster.json
index d148b888f..177d0643d 100644
--- a/library/i18n/locales/it/poster.json
+++ b/library/i18n/locales/it/poster.json
@@ -41,5 +41,6 @@
"optional": "(facoltativo)",
"phoneLimit": "Sul telefono i formati più grandi sono disattivati: un telefono non può disegnare un poster così grande. Per quelli usa un computer.",
"phoneLimitAll": "Serve un computer desktop o portatile.",
- "glue_here": "incollare qui"
+ "glue_here": "incollare qui",
+ "sectionNone": "Nessun poster per {scope}."
}
diff --git a/library/i18n/locales/pt/poster.json b/library/i18n/locales/pt/poster.json
index 87ec99373..d34e21339 100644
--- a/library/i18n/locales/pt/poster.json
+++ b/library/i18n/locales/pt/poster.json
@@ -41,5 +41,6 @@
"optional": "(opcional)",
"phoneLimit": "No telefone, os tamanhos maiores ficam desativados: um telefone não consegue desenhar um cartaz tão grande. Use um computador para eles.",
"phoneLimitAll": "Isto requer um computador de mesa ou portátil.",
- "glue_here": "colar aqui"
+ "glue_here": "colar aqui",
+ "sectionNone": "Nenhum pôster para {scope}."
}
diff --git a/library/i18n/locales/ro/poster.json b/library/i18n/locales/ro/poster.json
index dea1318ad..ea7ae8603 100644
--- a/library/i18n/locales/ro/poster.json
+++ b/library/i18n/locales/ro/poster.json
@@ -41,5 +41,6 @@
"optional": "(opțional)",
"phoneLimit": "Pe telefon, cele mai mari dimensiuni sunt dezactivate: un telefon nu poate desena un poster atât de mare. Folosește un calculator pentru ele.",
"phoneLimitAll": "Pentru aceasta este nevoie de un computer desktop sau laptop.",
- "glue_here": "lipiți aici"
+ "glue_here": "lipiți aici",
+ "sectionNone": "Niciun poster pentru {scope}."
}
diff --git a/library/i18n/locales/ru/poster.json b/library/i18n/locales/ru/poster.json
index 6b5b50ea7..17c7fb624 100644
--- a/library/i18n/locales/ru/poster.json
+++ b/library/i18n/locales/ru/poster.json
@@ -41,5 +41,6 @@
"optional": "(необязательно)",
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер.",
"phoneLimitAll": "Для этого нужен настольный компьютер или ноутбук.",
- "glue_here": "клеить здесь"
+ "glue_here": "клеить здесь",
+ "sectionNone": "Нет плакатов для {scope}."
}
diff --git a/library/i18n/locales/zh/poster.json b/library/i18n/locales/zh/poster.json
index 655e53bf3..fa3de2384 100644
--- a/library/i18n/locales/zh/poster.json
+++ b/library/i18n/locales/zh/poster.json
@@ -41,5 +41,6 @@
"optional": "(可选)",
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。",
"phoneLimitAll": "这需要台式电脑或笔记本电脑。",
- "glue_here": "在此粘贴"
+ "glue_here": "在此粘贴",
+ "sectionNone": "{scope} 没有海报。"
}
diff --git a/package.json b/package.json
index 05e4c24ce..35005a00e 100644
--- a/package.json
+++ b/package.json
@@ -351,7 +351,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:script-flags 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: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:auto-engine check:engine-contract 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:no-silent-engine:self-test 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:region-paint-single-source 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 check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test",
- "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:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export 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-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate",
+ "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:i18n-never-translated 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:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export 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-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate",
"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",
@@ -418,6 +418,8 @@
"check:shell-header": "node internal/check-shell-header.mjs",
"check:side-lanes": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs",
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
+ "check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs",
+ "check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test",
"check:side-lane-photo-pixels": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs",
"check:side-lane-photo-pixels:self-test": "node plugin-designs/country-posters/scripts/check-side-lane-photo-pixels.mjs --self-test"
},
@@ -687,6 +689,8 @@
"check:shell-header": "a header-off shell has ONE way to its tabs and ONE language menu, in the right place for the width: VerticalTabsLayout renders the phone bar (☰ + #phone-end) only when narrow AND header-off, #rail-end only when wide AND header-off, and no corner circle; ShellLanguageMenu's pick dispatches exactly one cancelable, composed, bubbling doxa-language-request (detail.language), does not switch when a host cancels, and writes ?lang= only on a tree-owned page; every switcher language has a catalogue and the current language is always listed. Headless (Vite SSR). Run the `check:shell-header:self-test` script for the NEGATIVE test (11 planted defects)",
"check:side-lanes": "the large side-lane sheets (60x96, 66x66, 36x60) through the real poster-render.js, on a SEEDED worst case (855 groups, stacked duplicates, tight clusters, real and placeholder photos): every name drawn exactly once, leader crossings 0 (re-measured from the SVG, and equal to the renderer's own count), no overflow rows, photos only where a real one exists and the format allows them.",
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
+ "check:poster-section": "the reusable poster section (): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
+ "check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
"check:side-lane-photo-pixels": "BROWSER gate (browser seat; elsewhere 75 NOT MEASURED): the 60x96 side-lane sheet rasterised by poster-raster.js, measured in pixels at every photo slot. A data: photo draws; every photo on its own host stays flat, never the broken-image glyph an external rasterises as.",
"check:side-lane-photo-pixels:self-test": "the same raster WITHOUT stripExternalImages must leave the external photo slots NOT flat (the glyph), else the gate proves nothing."
},
diff --git a/plugin-designs/country-posters/index.js b/plugin-designs/country-posters/index.js
index db0f31ef2..5249c9dde 100644
--- a/plugin-designs/country-posters/index.js
+++ b/plugin-designs/country-posters/index.js
@@ -1,4 +1,5 @@
import { defineMapElement } from '@map/elements/defineMapElement.js'
+import { registerPosterSectionElement } from '@map/elements/posterSectionElement.js'
/**
* plugin-designs/country-posters/index.js
*
@@ -60,4 +61,7 @@ if (typeof customElements !== 'undefined') {
}
}
+// : one poster section (any poster type), the reusable element this bundle ships first.
+registerPosterSectionElement()
+
export default CountryPostersElement
diff --git a/plugin-designs/country-posters/poster-section.meta.json b/plugin-designs/country-posters/poster-section.meta.json
new file mode 100644
index 000000000..70ed9fcbd
--- /dev/null
+++ b/plugin-designs/country-posters/poster-section.meta.json
@@ -0,0 +1,5 @@
+{
+ "type": "single",
+ "label": "Country Posters — one poster section ()",
+ "note": "Shows the reusable element: one region's posters, collapsible, with the same cards and print options as the wall. Attributes: type (country), scope (a region key, e.g. africa), collapsible, collapsed, locale."
+}
diff --git a/plugin-designs/country-posters/poster-section.vue b/plugin-designs/country-posters/poster-section.vue
new file mode 100644
index 000000000..f532c3ea1
--- /dev/null
+++ b/plugin-designs/country-posters/poster-section.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/plugin-designs/country-posters/scripts/check-poster-section.mjs b/plugin-designs/country-posters/scripts/check-poster-section.mjs
new file mode 100644
index 000000000..5139f03d5
--- /dev/null
+++ b/plugin-designs/country-posters/scripts/check-poster-section.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+// check:poster-section — the reusable poster section () and the lift behind it.
+// A. the TYPE REGISTRY: 'country' is registered, an unknown type is null, a plugin can register its own.
+// B. SCOPES: DX Life's /regions slugs (africa … oceania, other) each resolve; every spelling of a region (its
+// slug, its API value, its English name) resolves to the same countries; the scopes cover all countries once.
+// C. a WITHHELD whole-country poster stays withheld through the type (the card's sentence; its rows rejected,
+// no records fetched).
+// D. the ELEMENT's attributes map to its config (collapsible on by default, collapsible="false", collapsed).
+// E. THE LIFT: every name PosterSection.vue's template uses is injected from the wall (a section never reads
+// something its wall does not provide).
+// --self-test: a registry without 'country', a region key without "&" → "and", and a section template that uses a
+// name it does not inject must each FAIL. Loaded through Vite (the @map alias), like check:poster-wall-host-seams.
+import { readFileSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
+const DXL = ['africa', 'asia', 'europe', 'latin-america-and-caribbean', 'middle-east', 'north-america-and-non-spanish-caribbean', 'oceania', 'other']
+
+async function arms({ types, country, element, sectionSrc }) {
+ const fails = []
+ // A
+ if (!types.posterTypeNames().includes('country')) fails.push("A: the registry has no 'country' type")
+ if (await types.posterType('no-such-type') !== null) fails.push('A: an unknown type is not null')
+ types.registerPosterType('gate-fixture', { items: () => [{ id: 'x' }], title: () => 'X', actions: () => ({}) })
+ if (!(await types.posterType('gate-fixture'))) fails.push('A: a registered type cannot be found')
+ // B
+ const data = JSON.parse(readFileSync(join(ROOT, 'library/data/country-posters.json'), 'utf8')).countries
+ const seen = new Map()
+ for (const k of DXL) {
+ const ids = country.items(k, 'en').map((c) => c.id)
+ if (!ids.length) fails.push(`B: scope "${k}" (a DX Life /regions slug) shows no countries`)
+ for (const id of ids) seen.set(id, (seen.get(id) || 0) + 1)
+ }
+ const once = data.filter((c) => seen.get(c.id) === 1).length
+ if (once !== data.length) fails.push(`B: the scopes cover ${once} of ${data.length} countries exactly once`)
+ for (const [a, b, c] of [['latin-america-and-caribbean', 'latin_america_&_caribbean', 'Latin America & Caribbean'], ['middle-east', 'middle_east', 'Middle East']]) {
+ const s = (x) => country.items(x, 'en').map((r) => r.id).join()
+ if (!(s(a) && s(a) === s(b) && s(b) === s(c))) fails.push(`B: "${a}" / "${b}" / "${c}" do not resolve to the same countries`)
+ }
+ // C
+ const ind = country.items('asia', 'en').find((c) => c.iso3 === 'IND')
+ if (!ind) fails.push('C: India is not in the asia scope')
+ else {
+ let fetched = 0
+ const act = country.actions({ assetsBase: () => '', apiBase: () => { fetched++; return '' }, lang: () => 'en', profileConfig: null, t: (k) => k })
+ if (act.unsupported(ind) !== 'messages.countryPosterTooMany') fails.push("C: withheld India's card does not show messages.countryPosterTooMany")
+ let why = ''
+ try { await act.records(ind); why = 'resolved' } catch (e) { why = e && (e.i18nKey || e.message) }
+ if (!/countryPosterTooMany/.test(String(why))) fails.push(`C: withheld India's rows are not withheld (${why})`)
+ if (fetched) fails.push('C: withheld India fetched records')
+ }
+ // D
+ const fake = (attrs) => ({ getAttribute: (n) => (n in attrs ? attrs[n] : null), hasAttribute: (n) => n in attrs })
+ const d0 = element.posterSectionConfig(fake({ scope: 'africa' }))
+ if (!(d0.type === 'country' && d0.scope === 'africa' && d0.collapsible === true && d0.collapsed === false)) fails.push(`D: defaults wrong ${JSON.stringify(d0)}`)
+ if (element.posterSectionConfig(fake({ collapsible: 'false' })).collapsible !== false) fails.push('D: collapsible="false" does not turn collapsing off')
+ if (element.posterSectionConfig(fake({ collapsed: '' })).collapsed !== true) fails.push('D: collapsed does not start the section shut')
+ // E
+ const tpl = (sectionSrc.match(/([\s\S]*)<\/template>\s*$/) || ['', ''])[1].replace(//g, '')
+ const injected = new Set(((sectionSrc.match(/const \{([\s\S]*?)\} = inject\(POSTER_WALL_CTX\)/) || ['', ''])[1]).split(/[\s,]+/).filter(Boolean))
+ const used = new Set()
+ for (const m of tpl.matchAll(/(?:[:@]|v-[a-z.-]+)[\w.-]*="([^"]*)"|\{\{([^}]*)\}\}/g)) {
+ for (const e of [m[1], m[2]]) { if (!e) continue
+ for (const id of e.replace(/'[^']*'|`[^`]*`/g, '').matchAll(/(? !local.has(x) && !injected.has(x))
+ if (missing.length) fails.push(`E: PosterSection.vue uses names it does not inject: ${missing.join(', ')}`)
+ return fails
+}
+
+const { createServer } = await import('vite')
+const server = await createServer({ root: ROOT, configFile: false, logLevel: 'error', appType: 'custom', server: { middlewareMode: true, hmr: false, ws: false }, resolve: { alias: { '@map': join(ROOT, 'library') } } })
+let code = 0
+try {
+ const load = (p) => server.ssrLoadModule(join(ROOT, p))
+ const types = await load('library/components/poster/posterTypes.js')
+ const country = (await load('library/components/poster/types/countryPosterType.js')).default
+ const element = await load('library/elements/posterSectionConfig.js')
+ const sectionSrc = readFileSync(join(ROOT, 'library/components/poster/PosterSection.vue'), 'utf8')
+ const real = { types, country, element, sectionSrc }
+ if (process.argv.includes('--self-test')) {
+ const poisons = [
+ ["a registry without 'country'", { ...real, types: { ...types, posterTypeNames: () => [] } }, /^A:/],
+ ['a region key without "&" → "and"', { ...real, country: { ...country, items: (s, l) => (/-and-/.test(String(s)) ? [] : country.items(s, l)) } }, /^B:/],
+ ['a section that uses a name it does not inject', { ...real, sectionSrc: sectionSrc.replace('', '{{ notProvided }}') }, /^E:/],
+ ]
+ for (const [name, inp, rx] of poisons) {
+ const f = await arms(inp); const ok = f.some((x) => rx.test(x))
+ console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}`)
+ if (!ok) code = 1
+ }
+ } else {
+ const f = await arms(real)
+ if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
+ else console.log(`PASS check:poster-section — the 'country' type; ${DXL.length} DX Life scopes cover every country once (and their other spellings); withheld India stays withheld; the element's attributes; PosterSection injects every name it uses`)
+ }
+} finally { await server.close() }
+process.exit(code)