From 5a6b226a19c14232d77ccedc484b3fb88e3e39c0 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 21:09:27 +0000 Subject: [PATCH] Posterverse: a capability-5 app shell for every poster plugin is a vertical-tab rail of whole poster plugins, each mounted as its real element from the manifest: UPG 100 List Posters (upg-100-mfe), Country Posters (country-posters-mfe), Poster Design, and an Experimental folder of Wall of People Groups, All People Groups, UPG Neighbor and Bible Posters. It reuses VerticalTabsLayout, loadScriptOnce/whenTagDefined and the Research Gallery's profile-config contract (urlSync:false, mapTitle, token ladder, treeBase, assetsBase). The whole rail is an allow-list (src/tabs.js), so a gated plugin such as key-cities can never appear. Rows whose bundle is not in the tree are pruned; Poster Design and Bible Posters carry provisional ids until their bundles land. meta.json declares nested-mfe-spa and composes the five rows that exist, so the Plugin Studio lists it under capability 5. check:posterverse-shelf (in gates:pre) asserts the allow-list, the key-cities exclusion, composite-not-leaf resolution, composes/rail agreement and 11-locale string parity; --self-test plants one defect per arm. --- package.json | 4 +- plugin-designs/posterverse/index.html | 21 + plugin-designs/posterverse/index.js | 37 ++ .../posterverse/posterverse.instances.json | 16 + .../posterverse/posterverse.meta.json | 12 + plugin-designs/posterverse/posterverse.vue | 397 ++++++++++++++++++ .../scripts/check-posterverse-shelf.mjs | 155 +++++++ plugin-designs/posterverse/src/strings.js | 169 ++++++++ plugin-designs/posterverse/src/tabs.js | 87 ++++ 9 files changed, 897 insertions(+), 1 deletion(-) create mode 100644 plugin-designs/posterverse/index.html create mode 100644 plugin-designs/posterverse/index.js create mode 100644 plugin-designs/posterverse/posterverse.instances.json create mode 100644 plugin-designs/posterverse/posterverse.meta.json create mode 100644 plugin-designs/posterverse/posterverse.vue create mode 100644 plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs create mode 100644 plugin-designs/posterverse/src/strings.js create mode 100644 plugin-designs/posterverse/src/tabs.js diff --git a/package.json b/package.json index 6920d87b9..268a9baba 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs", "check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs", "check:posters-exportable": "node internal/check-posters-exportable.mjs", + "check:posterverse-shelf": "node plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs", "check:poster-prints": "node internal/check-poster-prints.mjs", "check:poster-raster": "node internal/check-poster-raster.mjs", "check:poster-wall-localised": "node internal/check-poster-wall-localised.mjs", @@ -342,7 +343,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: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", - "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: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 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch", + "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: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 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: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:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch", "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", @@ -570,6 +571,7 @@ "check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.", "check:poster-print-fallback": "clicks the REAL poster print control with the served poster generator and every posters/*.webp aborted, and asserts the preview RASTERISES from posters/.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.", "check:posters-exportable": "every poster the tree offers can be printed. With posterSources on, the tree once shipped only the 28 zone sources, so 350 of 378 posters said \"poster not found (404)\". Each id, from data/posters and the wall's previews, must ship a real WebP (RIFF..WEBP) or a real poster source (its class=\"stage\" element), so a placeholder or a host's fallback page cannot pass. SKIPs when posters are off by config, which check:poster-degrades covers. Reads the built tree; no server.", + "check:posterverse-shelf": "Posterverse (the capability-5 poster shell) shows exactly its allow-list and never a gated plugin. Asserts the rail rows per section (Posters: upg-100, country-posters, poster-design; Experimental: wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters), that no row names key-cities or a shell (a key-cities bundle is planted into the built manifest, because the real one withholds gated profiles), that every present row opens the profile it names (no silent fallback to a leaf), that posterverse.meta.json composes equals the rows whose bundle exists, and 11-locale parity of the shell strings. --self-test plants one defect per arm. Headless. Exit 0 pass, 1 fail, 75 no built manifest.", "check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT= so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.", "check:preset-config-merge": "a preset page (///index.html) keeps every default in its profile's .meta.json config, with the instance's props merged over them (go-int's upg-neighbor presets lost pinHint:false when props replaced the config). Reads the built tree; SKIP without one.", "check:profile-table": "the prayer packet's profile TABLE (20 full-width rows: picture, generated description, three boxes; PER_PAGE stays 20): a row stays tall enough for a name plus a two-line description at the smallest fit size, and truncation happens only at a whole word. Character-count, not DOM, so Node and the browser agree. Cannot see the RENDERED row height: that is check:packet-render, a browser gate. By the UPG-100 owner; mutation-tested. Headless; in gates:pre.", diff --git a/plugin-designs/posterverse/index.html b/plugin-designs/posterverse/index.html new file mode 100644 index 000000000..5c76ce69f --- /dev/null +++ b/plugin-designs/posterverse/index.html @@ -0,0 +1,21 @@ + + + + + + doxa-maps · posterverse staging + + + + + + + + + diff --git a/plugin-designs/posterverse/index.js b/plugin-designs/posterverse/index.js new file mode 100644 index 000000000..10a75536b --- /dev/null +++ b/plugin-designs/posterverse/index.js @@ -0,0 +1,37 @@ +/** + * posterverse / index.js — bundle entry (capability 5, nested-mfe-spa / app shell). + * + * ONE folder -> ONE bundle -> ONE custom element: . (A custom-element name needs a + * hyphen, so the tag cannot be the bare folder name.) + * + * It registers only its own tag. It never imports another plugin's components and never re-registers a + * tag another bundle owns: each nested poster plugin registers its own tag when its script loads. + * + * TREE BASE: to find each nested plugin's script, the shell needs the built tree's root URL. It is + * derived HERE, at IIFE load, from this script's own URL (...//posterverse/posterverse.js, two + * levels up), so the element self-locates wherever the tree is served. A host can override it with + * the tree-base="" attribute or profile-config.treeBase. + * + * Usage from a host page: + * + * + */ + +import { defineCustomElement } from 'vue' +import Posterverse from './posterverse.vue' +import { deriveTreeBase } from '@map/utils/embedScript.js' + +// Captured synchronously at IIFE load, while document.currentScript is still this bundle's tag. +const TREE_BASE = deriveTreeBase() + +const PosterverseElement = defineCustomElement(Posterverse, { + configureApp(app) { + app.provide('pvTreeBase', TREE_BASE) + } +}) + +if (typeof customElements !== 'undefined' && !customElements.get('posterverse-app')) { + customElements.define('posterverse-app', PosterverseElement) +} + +export default PosterverseElement diff --git a/plugin-designs/posterverse/posterverse.instances.json b/plugin-designs/posterverse/posterverse.instances.json new file mode 100644 index 000000000..55a9aada5 --- /dev/null +++ b/plugin-designs/posterverse/posterverse.instances.json @@ -0,0 +1,16 @@ +{ + "_comment": "One preset of the capability-5 shell. provider:'maplibre' marks the generated standalone page TOKEN-FREE (generate-tree TOKEN_FREE), so the shell mounts at once: the shell paints no map itself, its tabs do, each with its own token ladder. The meta.json type still wins over the preset signal, so this stays capability 5.", + "profile": "posterverse", + "instances": [ + { + "id": "app", + "title": "Posterverse — every poster plugin (app shell)", + "props": { + "profile": "posterverse", + "provider": "maplibre", + "instanceId": "posterverse-app", + "heading": "Posterverse" + } + } + ] +} diff --git a/plugin-designs/posterverse/posterverse.meta.json b/plugin-designs/posterverse/posterverse.meta.json new file mode 100644 index 000000000..06dbafd5c --- /dev/null +++ b/plugin-designs/posterverse/posterverse.meta.json @@ -0,0 +1,12 @@ +{ + "_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element renders a vertical-tab rail of whole poster plugins, each mounted as its own real element from the manifest (no iframes). The tier is DERIVED from `composes` at tree generation (composing a cap-4 makes this cap-5) and this `type` is checked against it. `composes` lists every row in src/tabs.js whose bundle exists today; a provisional row (poster-design, bible-posters) joins the list when its bundle lands, since an unresolved ref fails check:capability-derived.", + "type": "nested-mfe-spa", + "composes": [ + "my-upg-100-list/upg-100-mfe", + "country-posters/country-posters-mfe", + "wall-of-people-groups/wall-of-people-groups", + "all-people-groups/all-people-groups", + "upg-neighbor/upg-neighbor" + ], + "label": "Posterverse (vertical-tab app shell: every poster plugin)" +} diff --git a/plugin-designs/posterverse/posterverse.vue b/plugin-designs/posterverse/posterverse.vue new file mode 100644 index 000000000..d315a82a5 --- /dev/null +++ b/plugin-designs/posterverse/posterverse.vue @@ -0,0 +1,397 @@ + + + + + + diff --git a/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs b/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs new file mode 100644 index 000000000..3b1b9cad9 --- /dev/null +++ b/plugin-designs/posterverse/scripts/check-posterverse-shelf.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * check-posterverse-shelf.mjs — Posterverse shows exactly its allow-list, never a gated plugin, and its + * declared `composes` stays in step with its rail. + * + * WHY. The Research Gallery's Experimental folder is a deny-list catch-all ("every bundle nobody + * placed"). Posterverse deliberately is not: copied here, that rule would expose key-cities, which is + * GATED. The allow-list lives in src/tabs.js; this gate is what keeps it one. + * + * WHAT IT ASSERTS (each arm is poisoned by --self-test before a pass is trusted): + * A. the rail's rows are exactly the named sets: Posters = upg-100, country-posters, poster-design; + * Experimental = wall-of-people-groups, all-people-groups, upg-neighbor, bible-posters. Ids unique, + * every row in a declared section. + * B. no row names a NEVER bundle (key-cities, the shells themselves), and resolving the built + * manifest WITH a key-cities bundle planted in it yields no key-cities row (the real manifest + * withholds gated profiles, so without the plant the exclusion would never be exercised). + * C. every non-provisional row resolves on the built manifest to the profile it names (a silent + * fallback to a leaf is the "composite shown as a flat list" defect), and a row whose bundle is + * absent is pruned. + * D. posterverse.meta.json `composes` == the bundle/profile of every row whose bundle exists in + * plugin-designs/ (two-sided: nothing declared that the rail cannot show, nothing shown undeclared). + * E. strings: every locale carries every English key, and every key the shell reads exists in English. + * + * Headless, no browser. Exit 0 pass · 1 fail · 75 not measured (no built manifest). + */ +import { readFileSync, existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const TAG = 'POSTERVERSE-SHELF' +const HERE = dirname(fileURLToPath(import.meta.url)) +const PLUGIN = resolve(HERE, '..') +const ROOT = resolve(PLUGIN, '../..') + +const EXPECT = { + posters: ['upg-100', 'country-posters', 'poster-design'], + experimental: ['wall-of-people-groups', 'all-people-groups', 'upg-neighbor', 'bible-posters'], +} +const GATED = 'key-cities' + +const sameList = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]) + +export function checkShelf({ SECTIONS, TABS, NEVER, resolveTabs, manifest, meta, bundleExists, STRINGS }) { + const errs = [] + const notes = [] + // A. exact membership, per section, in order + const ids = TABS.map((t) => t.id) + if (new Set(ids).size !== ids.length) errs.push(`A: duplicate row id in TABS: ${ids.join(', ')}`) + const sectionKeys = new Set(SECTIONS.map((s) => s.key)) + for (const t of TABS) if (!sectionKeys.has(t.section)) errs.push(`A: row ${t.id} is in undeclared section "${t.section}"`) + for (const [sec, want] of Object.entries(EXPECT)) { + const got = TABS.filter((t) => t.section === sec).map((t) => t.id) + if (!sameList(got, want)) errs.push(`A: section ${sec} rows are [${got.join(', ')}], expected [${want.join(', ')}]`) + } + const extra = TABS.filter((t) => !EXPECT[t.section]).map((t) => t.id) + if (extra.length) errs.push(`A: rows outside the allow-listed sections: ${extra.join(', ')}`) + // B. never a gated bundle — by declaration and by resolution over the real manifest + if (!NEVER.has(GATED)) errs.push(`B: NEVER no longer lists ${GATED}`) + for (const t of TABS) if (NEVER.has(t.bundle) || t.bundle === GATED) errs.push(`B: row ${t.id} names never-shown bundle ${t.bundle}`) + // The built manifest WITHHOLDS gated profiles (generate-tree), so a real tree never offers key-cities + // to the resolver. Plant it (shaped like a real row) so the exclusion is exercised on every run: the + // question is whether Posterverse would show it if the tree ever did carry it. + const planted = { ...manifest, bundles: [...(manifest.bundles || []).filter((b) => b.bundle !== GATED), + { bundle: GATED, scriptUrl: `./${GATED}/${GATED}.js`, elements: [`${GATED}-map`], profiles: [{ profile: GATED, element: `${GATED}-map`, isMap: true }] }] } + for (const r of resolveTabs(planted, () => {})) if (r.bundle === GATED || NEVER.has(r.bundle)) errs.push(`B: resolution produced a ${r.bundle} row`) + const resolved = resolveTabs(manifest, () => {}) + const inManifest = new Set((manifest.bundles || []).map((b) => b.bundle)) + // C. rows resolve to the profile they name; absent bundles are pruned + const resolvedIds = new Set(resolved.map((r) => r.id)) + for (const t of TABS) { + const present = inManifest.has(t.bundle) + if (!present && resolvedIds.has(t.id)) errs.push(`C: row ${t.id} resolved although ${t.bundle} is not in the manifest`) + if (present && !t.provisional) { + const r = resolved.find((x) => x.id === t.id) + if (!r) errs.push(`C: row ${t.id} did not resolve although ${t.bundle} is in the manifest`) + else if (r.profile !== t.profile) errs.push(`C: row ${t.id} opens "${r.profile}", not its named profile "${t.profile}"`) + else if (!r.tag || !r.scriptUrl) errs.push(`C: row ${t.id} resolved without a tag or scriptUrl`) + } + } + // D. composes == the rows whose bundle exists in source + const composes = Array.isArray(meta.composes) ? meta.composes : [] + const want = TABS.filter((t) => bundleExists(t.bundle, t.profile)).map((t) => `${t.bundle}/${t.profile}`) + for (const c of composes) if (!want.includes(c)) errs.push(`D: meta composes "${c}", which no existing rail row shows`) + for (const w of want) if (!composes.includes(w)) errs.push(`D: the rail shows "${w}" but meta.composes does not declare it`) + if (meta.type !== 'nested-mfe-spa') errs.push(`D: meta type is ${JSON.stringify(meta.type)}, expected "nested-mfe-spa"`) + // E. strings + const en = STRINGS.en || {} + const used = ['lab', 'experimentalSub', 'loading', 'loadFailed', 'manifestHint', 'backToTop', + 'toggleList', 'openViews', 'expandList', 'collapseList', 'collapse', + ...SECTIONS.map((s) => s.i18n), ...TABS.map((t) => 'tab.' + t.id), + ...TABS.filter((t) => t.section !== 'experimental').map((t) => 'sub.' + t.id)] + for (const k of used) if (!(k in en)) errs.push(`E: the shell reads "${k}", which English does not define`) + const locales = Object.keys(STRINGS) + if (locales.length !== 11) errs.push(`E: ${locales.length} locales, expected 11`) + for (const l of locales) for (const k of Object.keys(en)) { + const v = STRINGS[l][k] + if (typeof v !== 'string' || !v.trim()) errs.push(`E: ${l} is missing "${k}"`) + } + return { errs, notes, counts: { rows: TABS.length, resolved: resolved.length, composes: composes.length, locales: locales.length, keys: Object.keys(en).length } } +} + +async function load() { + const tabs = await import(pathToFileURL(join(PLUGIN, 'src/tabs.js')).href) + const { STRINGS } = await import(pathToFileURL(join(PLUGIN, 'src/strings.js')).href) + const meta = JSON.parse(readFileSync(join(PLUGIN, 'posterverse.meta.json'), 'utf8')) + const { TREE_NAME } = await import(pathToFileURL(join(ROOT, 'internal/_tree-name.mjs')).href) + const mf = join(ROOT, 'public', TREE_NAME, 'manifest.json') + const manifest = existsSync(mf) ? JSON.parse(readFileSync(mf, 'utf8')) : null + const bundleExists = (b, p) => existsSync(join(ROOT, 'plugin-designs', b, `${p}.vue`)) + return { ...tabs, STRINGS, meta, manifest, bundleExists } +} + +function selfTest(base) { + const clone = (o) => JSON.parse(JSON.stringify(o)) + const cases = [ + ['A: key-cities added to Experimental', (c) => { c.TABS = [...c.TABS, { id: 'key-cities', section: 'experimental', bundle: 'key-cities', profile: 'key-cities' }] }, /^A: section experimental|^B: row key-cities/], + ['A: a row dropped', (c) => { c.TABS = c.TABS.filter((t) => t.id !== 'upg-neighbor') }, /^A: section experimental/], + ['B: resolver that shows whatever the manifest carries', (c) => { c.resolveTabs = (m) => (m.bundles || []).map((b) => ({ id: b.bundle, bundle: b.bundle, profile: b.bundle, tag: b.bundle, scriptUrl: b.scriptUrl })) }, /^B: resolution produced a key-cities row/], + ['C: a composite row falls back to a leaf', (c) => { c.manifest = clone(c.manifest); const b = c.manifest.bundles.find((x) => x.bundle === 'my-upg-100-list'); b.profiles = b.profiles.filter((p) => p.profile !== 'upg-100-mfe') }, /^C: row upg-100 opens/], + ['D: composes drift', (c) => { c.meta = { ...c.meta, composes: c.meta.composes.slice(1) } }, /^D: the rail shows/], + ['D: composes a ref the rail cannot show', (c) => { c.meta = { ...c.meta, composes: [...c.meta.composes, 'key-cities/key-cities'] } }, /^D: meta composes/], + ['E: a locale loses a key', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.ar['tab.upg-100'] }, /^E: ar is missing "tab.upg-100"/], + ['E: a row without an English label', (c) => { c.STRINGS = clone(c.STRINGS); delete c.STRINGS.en['tab.bible-posters'] }, /^E: the shell reads "tab.bible-posters"/], + ] + let bad = 0 + for (const [name, mutate, expect] of cases) { + const c = { ...base, TABS: [...base.TABS] } + mutate(c) + const { errs } = checkShelf(c) + const hit = errs.some((e) => expect.test(e)) + console.log(`${hit ? 'ok ' : 'MISS'} self-test: ${name}${hit ? '' : ' — errors were: ' + (errs.join(' | ') || 'none')}`) + if (!hit) bad++ + } + const clean = checkShelf(base).errs + if (clean.length) { console.log(`MISS self-test: the unmutated inputs are not clean: ${clean.join(' | ')}`); bad++ } + return bad +} + +const base = await load() +if (!base.manifest) { + console.log(`${TAG}: NOT MEASURED — no built manifest (run bun run tree first)`) + process.exitCode = 75 +} else if (process.argv.includes('--self-test')) { + const bad = selfTest(base) + console.log(bad ? `${TAG} self-test: FAIL (${bad} arm(s) could not fail)` : `${TAG} self-test: PASS (every arm fails on its planted defect)`) + process.exitCode = bad ? 1 : 0 +} else { + const { errs, notes, counts } = checkShelf(base) + for (const n of notes) console.log(n) + for (const e of errs) console.log('FAIL ' + e) + const summary = `${counts.rows} rows declared, ${counts.resolved} resolved on the built manifest, ${counts.composes} composes refs, ${counts.keys} keys x ${counts.locales} locales` + if (errs.length) { console.log(`${TAG}: FAIL (${errs.length}) — ${summary}`); process.exitCode = 1 } + else if (notes.length) { console.log(`${TAG}: NOT MEASURED — ${summary}`); process.exitCode = 75 } + else console.log(`${TAG}: PASS — ${summary}`) +} diff --git a/plugin-designs/posterverse/src/strings.js b/plugin-designs/posterverse/src/strings.js new file mode 100644 index 000000000..e426eb547 --- /dev/null +++ b/plugin-designs/posterverse/src/strings.js @@ -0,0 +1,169 @@ +/** + * strings.js — Posterverse's own words, in the 11 shipped locales. + * + * PRIVATE TO THIS PLUGIN, like the Research Gallery's table: these strings belong to the shell alone + * (its rail, sections and status hints). Each nested poster plugin translates ITSELF. + * + * VOCABULARY FOLLOWS THE RESEARCH GALLERY. A row that also appears in the gallery + * (plugin-designs/doxa-maps-spa/src/strings.js) uses the gallery's words verbatim, so the same plugin + * has one name in both shells: tab.upg-100 follows the "UPG 100 List Posters" rename, and the + * Experimental names, status hints and the layout's own controls are copied as the gallery ships them. + * + * `en` is the source and the fallback: a key missing in a locale renders in English rather than as a + * key name. {label} is the only placeholder. + */ +export const STRINGS = { + en: { + 'sec.posters': 'Posters', 'sec.experimental': 'Experimental', + 'tab.upg-100': 'UPG 100 List Posters', 'sub.upg-100': 'my unreached list + posters', + 'tab.country-posters': 'Country Posters', 'sub.country-posters': 'a poster for every country', + 'tab.poster-design': 'Poster Design', 'sub.poster-design': 'every poster style and size', + 'tab.wall-of-people-groups': 'Wall of People Groups', 'tab.all-people-groups': 'All People Groups', + 'tab.upg-neighbor': 'UPG Neighbor', 'tab.bible-posters': 'Bible Posters', + experimentalSub: 'experimental bundle', lab: 'lab', + loading: 'Loading {label}…', loadFailed: 'Could not load {label}.', + manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.', + backToTop: 'Back to top', + toggleList: 'Toggle list', openViews: 'Open the list of views', expandList: 'Expand list', collapseList: 'Collapse list', collapse: 'Collapse', + }, + es: { + 'sec.posters': 'Carteles', 'sec.experimental': 'Experimental', + 'tab.upg-100': 'Carteles de la lista UPG 100', 'sub.upg-100': 'mi lista de no alcanzados + carteles', + 'tab.country-posters': 'Carteles por país', 'sub.country-posters': 'un cartel para cada país', + 'tab.poster-design': 'Diseño de carteles', 'sub.poster-design': 'todos los estilos y tamaños de cartel', + 'tab.wall-of-people-groups': 'Muro de los pueblos', 'tab.all-people-groups': 'Todos los pueblos', + 'tab.upg-neighbor': 'Vecino UPG', 'tab.bible-posters': 'Carteles bíblicos', + experimentalSub: 'mapa experimental', lab: 'beta', + loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.', + manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.', + backToTop: 'Volver arriba', + toggleList: 'Mostrar u ocultar la lista', openViews: 'Abrir la lista de vistas', expandList: 'Expandir la lista', collapseList: 'Contraer la lista', collapse: 'Contraer', + }, + fr: { + 'sec.posters': 'Affiches', 'sec.experimental': 'Expérimental', + 'tab.upg-100': 'Affiches de la liste UPG 100', 'sub.upg-100': 'ma liste des non atteints + affiches', + 'tab.country-posters': 'Affiches par pays', 'sub.country-posters': 'une affiche pour chaque pays', + 'tab.poster-design': 'Conception d’affiches', 'sub.poster-design': 'tous les styles et formats d’affiche', + 'tab.wall-of-people-groups': 'Mur des peuples', 'tab.all-people-groups': 'Tous les peuples', + 'tab.upg-neighbor': 'Voisin UPG', 'tab.bible-posters': 'Affiches bibliques', + experimentalSub: 'carte expérimentale', lab: 'bêta', + loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.', + manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.", + backToTop: 'Retour en haut', + toggleList: 'Afficher ou masquer la liste', openViews: 'Ouvrir la liste des vues', expandList: 'Déplier la liste', collapseList: 'Replier la liste', collapse: 'Replier', + }, + de: { + 'sec.posters': 'Poster', 'sec.experimental': 'Experimentell', + 'tab.upg-100': 'Poster der UPG 100 Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster', + 'tab.country-posters': 'Länderposter', 'sub.country-posters': 'ein Poster für jedes Land', + 'tab.poster-design': 'Postergestaltung', 'sub.poster-design': 'alle Posterstile und -größen', + 'tab.wall-of-people-groups': 'Wand der Volksgruppen', 'tab.all-people-groups': 'Alle Volksgruppen', + 'tab.upg-neighbor': 'UPG-Nachbar', 'tab.bible-posters': 'Bibelposter', + experimentalSub: 'experimentelle Karte', lab: 'Beta', + loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.', + manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.', + backToTop: 'Nach oben', + toggleList: 'Liste ein-/ausblenden', openViews: 'Ansichtsliste öffnen', expandList: 'Liste ausklappen', collapseList: 'Liste einklappen', collapse: 'Einklappen', + }, + pt: { + 'sec.posters': 'Cartazes', 'sec.experimental': 'Experimental', + 'tab.upg-100': 'Cartazes da lista UPG 100', 'sub.upg-100': 'minha lista de não alcançados + cartazes', + 'tab.country-posters': 'Cartazes por país', 'sub.country-posters': 'um cartaz para cada país', + 'tab.poster-design': 'Design de cartazes', 'sub.poster-design': 'todos os estilos e tamanhos de cartaz', + 'tab.wall-of-people-groups': 'Mural dos povos', 'tab.all-people-groups': 'Todos os povos', + 'tab.upg-neighbor': 'Vizinho UPG', 'tab.bible-posters': 'Cartazes bíblicos', + experimentalSub: 'mapa experimental', lab: 'beta', + loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.', + manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.', + backToTop: 'Voltar ao topo', + toggleList: 'Mostrar ou ocultar a lista', openViews: 'Abrir a lista de visualizações', expandList: 'Expandir a lista', collapseList: 'Recolher a lista', collapse: 'Recolher', + }, + it: { + 'sec.posters': 'Poster', 'sec.experimental': 'Sperimentale', + 'tab.upg-100': 'Poster della lista UPG 100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster', + 'tab.country-posters': 'Poster per paese', 'sub.country-posters': 'un poster per ogni paese', + 'tab.poster-design': 'Design dei poster', 'sub.poster-design': 'tutti gli stili e i formati dei poster', + 'tab.wall-of-people-groups': 'Muro dei popoli', 'tab.all-people-groups': 'Tutti i popoli', + 'tab.upg-neighbor': 'Vicino UPG', 'tab.bible-posters': 'Poster biblici', + experimentalSub: 'mappa sperimentale', lab: 'beta', + loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.', + manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.", + backToTop: 'Torna su', + toggleList: 'Mostra o nascondi l’elenco', openViews: 'Apri l’elenco delle viste', expandList: 'Espandi l’elenco', collapseList: 'Comprimi l’elenco', collapse: 'Comprimi', + }, + ro: { + 'sec.posters': 'Afișe', 'sec.experimental': 'Experimental', + 'tab.upg-100': 'Afișe din lista UPG 100', 'sub.upg-100': 'lista mea de neatinși + afișe', + 'tab.country-posters': 'Afișe pe țări', 'sub.country-posters': 'un afiș pentru fiecare țară', + 'tab.poster-design': 'Design de afișe', 'sub.poster-design': 'toate stilurile și dimensiunile de afiș', + 'tab.wall-of-people-groups': 'Zidul popoarelor', 'tab.all-people-groups': 'Toate popoarele', + 'tab.upg-neighbor': 'Vecin UPG', 'tab.bible-posters': 'Afișe biblice', + experimentalSub: 'hartă experimentală', lab: 'beta', + loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.', + manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.', + backToTop: 'Sus', + toggleList: 'Afișează sau ascunde lista', openViews: 'Deschide lista de vizualizări', expandList: 'Extinde lista', collapseList: 'Restrânge lista', collapse: 'Restrânge', + }, + ru: { + 'sec.posters': 'Плакаты', 'sec.experimental': 'Экспериментальное', + 'tab.upg-100': 'Плакаты списка UPG 100', 'sub.upg-100': 'мой список недостигнутых + плакаты', + 'tab.country-posters': 'Плакаты по странам', 'sub.country-posters': 'плакат для каждой страны', + 'tab.poster-design': 'Дизайн плакатов', 'sub.poster-design': 'все стили и размеры плакатов', + 'tab.wall-of-people-groups': 'Стена народов', 'tab.all-people-groups': 'Все народы', + 'tab.upg-neighbor': 'Сосед UPG', 'tab.bible-posters': 'Библейские плакаты', + experimentalSub: 'экспериментальная карта', lab: 'бета', + loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.', + manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.', + backToTop: 'Наверх', + toggleList: 'Показать или скрыть список', openViews: 'Открыть список представлений', expandList: 'Развернуть список', collapseList: 'Свернуть список', collapse: 'Свернуть', + }, + ar: { + 'sec.posters': 'ملصقات', 'sec.experimental': 'تجريبي', + 'tab.upg-100': 'ملصقات قائمة UPG 100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات', + 'tab.country-posters': 'ملصقات الدول', 'sub.country-posters': 'ملصق لكل دولة', + 'tab.poster-design': 'تصميم الملصقات', 'sub.poster-design': 'كل أنماط الملصقات وأحجامها', + 'tab.wall-of-people-groups': 'جدار الشعوب', 'tab.all-people-groups': 'جميع الشعوب', + 'tab.upg-neighbor': 'جار UPG', 'tab.bible-posters': 'ملصقات الكتاب المقدس', + experimentalSub: 'خريطة تجريبية', lab: 'تجريبي', + loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.', + manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.', + backToTop: 'إلى الأعلى', + toggleList: 'إظهار القائمة أو إخفاؤها', openViews: 'فتح قائمة العروض', expandList: 'توسيع القائمة', collapseList: 'طي القائمة', collapse: 'طي', + }, + hi: { + 'sec.posters': 'पोस्टर', 'sec.experimental': 'प्रायोगिक', + 'tab.upg-100': 'UPG 100 सूची पोस्टर', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर', + 'tab.country-posters': 'देश पोस्टर', 'sub.country-posters': 'हर देश के लिए एक पोस्टर', + 'tab.poster-design': 'पोस्टर डिज़ाइन', 'sub.poster-design': 'हर पोस्टर शैली और आकार', + 'tab.wall-of-people-groups': 'जनसमूहों की दीवार', 'tab.all-people-groups': 'सभी जनसमूह', + 'tab.upg-neighbor': 'UPG पड़ोसी', 'tab.bible-posters': 'बाइबल पोस्टर', + experimentalSub: 'प्रायोगिक मानचित्र', lab: 'बीटा', + loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।', + manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।', + backToTop: 'ऊपर जाएँ', + toggleList: 'सूची दिखाएँ या छिपाएँ', openViews: 'दृश्यों की सूची खोलें', expandList: 'सूची फैलाएँ', collapseList: 'सूची संक्षिप्त करें', collapse: 'संक्षिप्त करें', + }, + zh: { + 'sec.posters': '海报', 'sec.experimental': '实验性', + 'tab.upg-100': 'UPG 100 名单海报', 'sub.upg-100': '我的未得之民名单 + 海报', + 'tab.country-posters': '国家海报', 'sub.country-posters': '每个国家一张海报', + 'tab.poster-design': '海报设计', 'sub.poster-design': '所有海报样式与尺寸', + 'tab.wall-of-people-groups': '民族之墙', 'tab.all-people-groups': '所有民族', + 'tab.upg-neighbor': 'UPG 邻居', 'tab.bible-posters': '圣经海报', + experimentalSub: '实验性地图', lab: '测试版', + loading: '正在加载{label}…', loadFailed: '无法加载{label}。', + manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。', + backToTop: '回到顶部', + toggleList: '显示或隐藏列表', openViews: '打开视图列表', expandList: '展开列表', collapseList: '折叠列表', collapse: '折叠', + }, +} + +export const LOCALES = Object.keys(STRINGS) + +/** Translate `key` into `locale`, falling back to English, then to the key itself. */ +export function translate(locale, key, params) { + const table = STRINGS[locale] || STRINGS.en + let s = table[key] ?? STRINGS.en[key] ?? key + if (params) for (const k in params) s = s.split('{' + k + '}').join(String(params[k])) + return s +} diff --git a/plugin-designs/posterverse/src/tabs.js b/plugin-designs/posterverse/src/tabs.js new file mode 100644 index 000000000..624a2016c --- /dev/null +++ b/plugin-designs/posterverse/src/tabs.js @@ -0,0 +1,87 @@ +/** + * tabs.js — WHICH plugins Posterverse shows, and how each row is resolved from the tree's manifest. + * + * Pure data + pure functions (no Vue), so the shell and its headless check read the same list. + * + * ★ THE WHOLE RAIL IS AN ALLOW-LIST, the Experimental folder included. The Research Gallery fills its + * Experimental folder with every bundle nobody placed (a deny-list catch-all). Copied here, that rule + * would show every research map, including key-cities, which is GATED and must never be exposed. + * So every row Posterverse can ever paint is named below, and a bundle that is not named never + * appears, whatever the manifest holds. + * + * ★ A ROW WHOSE BUNDLE IS NOT IN THE TREE IS PRUNED, not painted dead. That is how a row can be listed + * before its plugin lands: Poster Design and Bible Posters are declared with PROVISIONAL ids and stay + * invisible until a bundle of that id exists. Their owners confirm the ids when they start; change the + * `bundle`/`profile` here to match, never the row `id` (the id is the stable key for ?tab= links). + * + * ★ MOUNT THE COMPOSITE, NOT A LEAF. The profile named here is the one that mounts. my-upg-100-list and + * country-posters each ship a leaf map, a leaf wall and the capability-4 composite of the two; the + * composite is the plugin as its owner designed it (and it is what makes Posterverse capability 5). + */ + +// The rail's sections, in order. `folder` renders collapsible and starts closed, like the gallery's. +export const SECTIONS = [ + { key: 'posters', i18n: 'sec.posters', icon: 'poster' }, + { key: 'experimental', i18n: 'sec.experimental', icon: 'flask', folder: true, open: false }, +] + +// Every row Posterverse can show, in rail order. +export const TABS = [ + { id: 'upg-100', section: 'posters', icon: 'list', bundle: 'my-upg-100-list', profile: 'upg-100-mfe' }, + { id: 'country-posters', section: 'posters', icon: 'map', bundle: 'country-posters', profile: 'country-posters-mfe' }, + // Provisional ids: no poster-design bundle exists yet, so this row is pruned until one does. + { id: 'poster-design', section: 'posters', icon: 'poster', bundle: 'poster-design', profile: 'poster-design-mfe', provisional: true }, + { id: 'wall-of-people-groups', section: 'experimental', icon: 'flask', bundle: 'wall-of-people-groups', profile: 'wall-of-people-groups' }, + { id: 'all-people-groups', section: 'experimental', icon: 'flask', bundle: 'all-people-groups', profile: 'all-people-groups' }, + { id: 'upg-neighbor', section: 'experimental', icon: 'flask', bundle: 'upg-neighbor', profile: 'upg-neighbor' }, + // Provisional ids: no bible-posters bundle exists yet, so this row is pruned until one does. + { id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters', provisional: true }, +] + +// Bundles that must never be a Posterverse row. The allow-list above already excludes them; this set +// makes the exclusion an ASSERTION (check-posterverse-shelf.mjs fails if a row ever names one), so a +// later edit cannot add one by accident. +export const NEVER = new Set(['key-cities', 'posterverse', 'doxa-maps-spa', 'plugin-studio']) + +// Elements that own or NEST a WebGL map, so their pane is DESTROYED on deactivate rather than hidden +// (browsers keep ~8-16 live contexts and silently kill the oldest). Taken from the Research Gallery's +// measured HEAVY_ELEMENTS for the tags this rail shows; any profile the manifest marks `isMap` is +// treated as heavy too, so a new map row is bounded without an edit here. +const HEAVY_TAGS = new Set(['my-upg-100-list-map', 'country-posters-map', 'all-people-groups']) + +/** + * Complete each row from the manifest: the bundle's scriptUrl and the profile's element tag. A row + * whose bundle is absent from this tree, or that ships no script, is left out (pruned). + * Returns the resolved rows in rail order. + */ +export function resolveTabs(manifest, warn = () => {}) { + const bundles = (manifest && Array.isArray(manifest.bundles)) ? manifest.bundles : [] + const byBundle = Object.fromEntries(bundles.map((b) => [b && b.bundle, b])) + const out = [] + for (const d of TABS) { + if (NEVER.has(d.bundle)) continue + const b = byBundle[d.bundle] + if (!b) continue + const profiles = Array.isArray(b.profiles) ? b.profiles : [] + let p = profiles.find((x) => x && x.profile === d.profile) + if (!p) { + // The named profile is gone (renamed, or a provisional id guessed wrong). Degrade to the profile + // named like the bundle, then the first, and say so: a quiet fallback is how a composite row + // once showed a bare leaf. + p = profiles.find((x) => x && x.profile === d.bundle) || profiles[0] + if (!p) continue + warn(`[posterverse] ${d.bundle} has no profile "${d.profile}"; row "${d.id}" opens "${p.profile}" instead`) + } + const tag = p.element || (Array.isArray(b.elements) && b.elements[0]) || d.bundle + const scriptUrl = b.scriptUrl || p.scriptUrl || '' + if (!scriptUrl) continue + out.push({ + ...d, + tag, + scriptUrl, + profile: p.profile, + heavy: HEAVY_TAGS.has(tag) || !!p.isMap, + }) + } + return out +}