<dxm-poster-section>: one poster section, any poster type, as a reusable element
The section the Country Posters tab draws (a collapsible region head + its poster cards) is now usable on its
own, for any poster set, by parameters:
- library/components/poster/posterTypes.js: a TYPE REGISTRY. A type is an adapter (items for a scope, the
section's title, the print actions). The library registers 'country' (library data + the shared sheet path,
so a withheld whole-country poster stays withheld); plugins register their own types from their own code
(UPG 100 lists, regions, continents later). No plugin code in the library.
- 'country' scopes: a region key in any spelling. DX Life's /regions slug ('latin-america-and-caribbean'),
the API's value, the wall's slug and the English name all reduce to one canonical key.
- PosterSectionView.vue: one section, drawn by a PosterWall in a new SECTION-ONLY mode (no wall head; off by
default, so every existing wall renders as before), embedded so the page scrolls it.
- <dxm-poster-section type scope collapsible collapsed locale assets-base api-base-url is-dark>: the
attributes map onto the standard element's profile-config (posterSectionConfig.js, pure), so it keeps the
per-instance i18n and store. registerPosterSectionElement() from any bundle; Country Posters ships it first.
- country-posters/poster-section: a page showing two sections (Africa open, Oceania collapsed).
- poster.sectionNone (11 locales): an unknown type or an empty scope says so, never a blank.
check:poster-section (gates:pre) + self-test: the registry, DX Life's 8 scopes covering every country once,
withheld India, the attribute mapping, and PosterSection.vue injecting every name its template uses; 3 poisons.
The known collapse-while-scrolled landing is left exactly as it is.
This commit is contained in:
parent
194006412f
commit
a95b88ed65
22 changed files with 361 additions and 12 deletions
65
library/components/poster/PosterSectionView.vue
Normal file
65
library/components/poster/PosterSectionView.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup>
|
||||
/**
|
||||
* PosterSectionView.vue — ONE poster section on its own: the profile behind <dxm-poster-section>.
|
||||
* It reads its settings from profile-config (type, scope, collapsible, collapsed, locale, assetsBase), finds the
|
||||
* poster TYPE in posterTypes.js, and draws a PosterWall in section-only mode: the same section a wall draws (the
|
||||
* same PosterSection, the same print actions and overlay), with no wall head around it.
|
||||
*/
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import PosterWall from './PosterWall.vue'
|
||||
import { posterType } from './posterTypes.js'
|
||||
import { useOptionalT } from '@map/composables/useOptionalT.js'
|
||||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
const cfg = computed(() => profileConfig.value || {})
|
||||
const t = useOptionalT('')
|
||||
const typeName = computed(() => cfg.value.type || 'country')
|
||||
const adapter = ref(null)
|
||||
const unknownType = ref(false)
|
||||
watch(typeName, async (name) => {
|
||||
const a = await posterType(name)
|
||||
adapter.value = a; unknownType.value = !a
|
||||
}, { immediate: true })
|
||||
|
||||
const lang = () => cfg.value.locale || getRuntimeLocale() || detectLocale() || 'en'
|
||||
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
|
||||
const items = computed(() => (adapter.value ? adapter.value.items(cfg.value.scope, lang()) : []))
|
||||
const title = computed(() => (adapter.value ? adapter.value.title(cfg.value.scope, t) : ''))
|
||||
const actions = computed(() => (adapter.value
|
||||
? adapter.value.actions({ assetsBase: () => assetsBase.value, apiBase: () => cfg.value.apiBaseUrl || getApiBaseUrl(), lang, profileConfig, t })
|
||||
: null))
|
||||
const collapsible = computed(() => cfg.value.collapsible !== false)
|
||||
const startCollapsed = computed(() => cfg.value.collapsed === true)
|
||||
const groupBy = () => title.value
|
||||
const collapsedBy = () => startCollapsed.value
|
||||
const instanceId = computed(() => `poster-section-${typeName.value}-${String(cfg.value.scope || 'all')}`)
|
||||
// Nothing to show still SAYS so (an unknown type or scope never renders as a blank).
|
||||
const noneLine = computed(() => t('poster.sectionNone', { scope: String(cfg.value.scope || '') }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="unknownType || (adapter && !items.length)" class="dxm-ps-none" role="status">{{ noneLine }}</p>
|
||||
<!-- embedded: an in-flow block the PAGE scrolls (a wall alone is a full-pane scroller), so sections stack on a
|
||||
page and sit inline under a host's own heading. -->
|
||||
<PosterWall v-else-if="adapter && actions"
|
||||
section-only
|
||||
embedded
|
||||
:items="items"
|
||||
:group-by="groupBy"
|
||||
:collapsible="collapsible"
|
||||
:collapsed-by="collapsedBy"
|
||||
:instance-id="instanceId"
|
||||
:is-dark="cfg.isDark === true"
|
||||
:assets-base="assetsBase"
|
||||
:event-name="actions.eventName"
|
||||
:unit="actions.unit"
|
||||
:item-count-key="actions.itemCountKey"
|
||||
:records="actions.records"
|
||||
:sheet="actions.sheet"
|
||||
:unsupported="actions.unsupported"
|
||||
:card-stocks="actions.cardStocks"
|
||||
:download-prefix="actions.downloadPrefix"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -208,6 +208,9 @@ const props = defineProps({
|
|||
// to count n rather than the section's card count, or null for that section to keep the default. The Country
|
||||
// Posters India section holds India's own card and one per state and union territory, which are not countries.
|
||||
sectionItemCountKey: { type: Function, default: null },
|
||||
// SECTION-ONLY: the wall's own head (heading, tag, intro) is not drawn, only its section(s): a standalone poster
|
||||
// section (<dxm-poster-section>). Off by default, so every existing wall renders exactly as before.
|
||||
sectionOnly: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['header-action'])
|
||||
|
||||
|
|
@ -1342,9 +1345,11 @@ provide(POSTER_WALL_CTX, {
|
|||
|
||||
<template>
|
||||
<div ref="wallEl" class="wall" :class="{ 'is-dark': isDark, 'wall--embedded': embedded }">
|
||||
<template v-if="!sectionOnly">
|
||||
<h2 class="wall-h">{{ heading }}</h2>
|
||||
<div v-if="tag" class="wall-tag">{{ tag }}</div>
|
||||
<p v-if="intro" class="wall-sub" v-html="intro"></p>
|
||||
</template>
|
||||
|
||||
<!-- Each section stacks ABOVE the ones after it (z-index decreasing down the wall; see .sec), so the LAST
|
||||
poster's open menu, which hangs past its section's end, sits over the next section's header. -->
|
||||
|
|
|
|||
32
library/components/poster/posterTypes.js
Normal file
32
library/components/poster/posterTypes.js
Normal file
|
|
@ -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()]
|
||||
49
library/components/poster/types/countryPosterType.js
Normal file
49
library/components/poster/types/countryPosterType.js
Normal file
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
14
library/elements/posterSectionConfig.js
Normal file
14
library/elements/posterSectionConfig.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// <dxm-poster-section>'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
|
||||
}
|
||||
|
||||
40
library/elements/posterSectionElement.js
Normal file
40
library/elements/posterSectionElement.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* posterSectionElement.js — registers <dxm-poster-section>: ONE poster section, any poster type, as an element.
|
||||
*
|
||||
* <dxm-poster-section type="country" scope="africa" locale="ar"></dxm-poster-section>
|
||||
*
|
||||
* 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
|
||||
|
|
@ -41,5 +41,6 @@
|
|||
"optional": "(اختياري)",
|
||||
"phoneLimit": "على الهاتف، أكبر المقاسات غير متاحة: لا يستطيع الهاتف رسم ملصق بهذا الحجم. استخدم حاسوبًا لها.",
|
||||
"phoneLimitAll": "يتطلّب هذا حاسوبًا مكتبيًا أو محمولًا.",
|
||||
"glue_here": "الصق هنا"
|
||||
"glue_here": "الصق هنا",
|
||||
"sectionNone": "لا توجد ملصقات لـ {scope}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,6 @@
|
|||
"optional": "(वैकल्पिक)",
|
||||
"phoneLimit": "फ़ोन पर सबसे बड़े आकार बंद हैं: फ़ोन इतना बड़ा पोस्टर नहीं बना सकता। उनके लिए कंप्यूटर का उपयोग करें।",
|
||||
"phoneLimitAll": "इसके लिए डेस्कटॉप या लैपटॉप कंप्यूटर चाहिए।",
|
||||
"glue_here": "यहाँ चिपकाएँ"
|
||||
"glue_here": "यहाँ चिपकाएँ",
|
||||
"sectionNone": "{scope} के लिए कोई पोस्टर नहीं।"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,6 @@
|
|||
"optional": "(необязательно)",
|
||||
"phoneLimit": "На телефоне самые большие размеры отключены: телефон не может нарисовать такой большой плакат. Используйте для них компьютер.",
|
||||
"phoneLimitAll": "Для этого нужен настольный компьютер или ноутбук.",
|
||||
"glue_here": "клеить здесь"
|
||||
"glue_here": "клеить здесь",
|
||||
"sectionNone": "Нет плакатов для {scope}."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,5 +41,6 @@
|
|||
"optional": "(可选)",
|
||||
"phoneLimit": "在手机上,最大的尺寸已关闭:手机无法绘制这么大的海报。请使用电脑制作这些尺寸。",
|
||||
"phoneLimitAll": "这需要台式电脑或笔记本电脑。",
|
||||
"glue_here": "在此粘贴"
|
||||
"glue_here": "在此粘贴",
|
||||
"sectionNone": "{scope} 没有海报。"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (<dxm-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 <image> 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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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') {
|
|||
}
|
||||
}
|
||||
|
||||
// <dxm-poster-section>: one poster section (any poster type), the reusable element this bundle ships first.
|
||||
registerPosterSectionElement()
|
||||
|
||||
export default CountryPostersElement
|
||||
|
|
|
|||
5
plugin-designs/country-posters/poster-section.meta.json
Normal file
5
plugin-designs/country-posters/poster-section.meta.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Country Posters — one poster section (<dxm-poster-section>)",
|
||||
"note": "Shows the reusable <dxm-poster-section> 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."
|
||||
}
|
||||
21
plugin-designs/country-posters/poster-section.vue
Normal file
21
plugin-designs/country-posters/poster-section.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup>
|
||||
/**
|
||||
* poster-section.vue — the reusable <dxm-poster-section> element, shown on its own page: two regions, each ONE
|
||||
* section (the same cards and print options as the Country Posters wall), one collapsible and open, one starting
|
||||
* shut. The element is the library's (registered by this bundle); this page only places it.
|
||||
*/
|
||||
import { computed, h, inject, ref } from 'vue'
|
||||
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
const cfg = computed(() => profileConfig.value || {})
|
||||
const locale = computed(() => cfg.value.locale || getRuntimeLocale() || detectLocale() || 'en')
|
||||
const section = (scope, extra = {}) => h('dxm-poster-section', {
|
||||
type: 'country', scope, locale: locale.value, 'assets-base': cfg.value.assetsBase || './assets', ...extra,
|
||||
})
|
||||
const Page = () => h('div', { class: 'ps-demo' }, [section(cfg.value.scope || 'africa'), section('oceania', { collapsed: '' })])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page />
|
||||
</template>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env node
|
||||
// check:poster-section — the reusable poster section (<dxm-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(/<template>([\s\S]*)<\/template>\s*$/) || ['', ''])[1].replace(/<!--[\s\S]*?-->/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(/(?<![\w.$])([A-Za-z_$][\w$]*)/g)) used.add(id[1]) }
|
||||
}
|
||||
const local = new Set(['sec', 'z', 'item', 'n', 's', 'v', 'el', 'true', 'false', 'null', 'String', 'Number', '$event', 'in', 'label', 'zIndex'])
|
||||
const missing = [...used].filter((x) => !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('</template>', '{{ notProvided }}</template>') }, /^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)
|
||||
Loading…
Reference in a new issue