Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3-land

This commit is contained in:
Doxa Maps 2026-10-01 11:07:05 +00:00
commit 671295b5b1
36 changed files with 1404 additions and 31 deletions

View file

@ -415,6 +415,7 @@ every third-party script is pinned with an integrity hash (`library/constants/cd
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
| `connect-src` (Bible Posters) | `https://bible.helloao.org` | the public-domain Bible text, fetched per book at render time (no text is bundled) |
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
---

View file

@ -13,7 +13,7 @@
> bundle `parameterized` (cap-2) no matter how light it looks, and a `meta.json` `type`
> declares tiers 3/4/5.
Generated from 27 bundle(s).
Generated from 28 bundle(s).
### One canonical exemplar per tier (generated — quote these)
@ -37,6 +37,7 @@ Generated from 27 bundle(s).
| Bundle | Profile | Machine key |
|---|---|---|
| `all-people-groups` | `all-people-groups` | `single` |
| `bible-posters` | `bible-posters` | `single` |
| `cities-internet` | `connectivity-list` | `single` |
| `cities-internet` | `connectivity-map` | `single` |
| `cities-internet` | `data-tables` | `single` |

View file

@ -0,0 +1,27 @@
{
"title": "ملصقات الكتاب المقدس",
"book": "السفر",
"language": "اللغة",
"size": "الحجم",
"print": "طباعة",
"close": "إغلاق",
"loading": "جارٍ تحميل السفر…",
"fetchFailed": "تعذّر تحميل هذا النص. حاول مرة أخرى.",
"fitsAt": "يتّسع بحجم {pt} نقطة في {cols} أعمدة.",
"tooSmall": "أصغر من أن يُقرأ بهذا الحجم.",
"trySize": "جرّب {size}",
"books": {
"ACT": "أعمال الرسل",
"MAT": "متى",
"JHN": "يوحنا",
"MRK": "مرقس",
"LUK": "لوقا",
"HEB": "العبرانيين",
"GEN": "التكوين",
"1JN": "يوحنا الأولى",
"COL": "كولوسي",
"1CO": "كورنثوس الأولى",
"1PE": "بطرس الأولى",
"JOS": "يشوع"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Bibel-Poster",
"book": "Buch",
"language": "Sprache",
"size": "Größe",
"print": "Drucken",
"close": "Schließen",
"loading": "Buch wird geladen…",
"fetchFailed": "Dieser Text konnte nicht geladen werden. Bitte erneut versuchen.",
"fitsAt": "Passt bei {pt} pt in {cols} Spalten.",
"tooSmall": "Zu klein, um in dieser Größe lesbar zu sein.",
"trySize": "versuchen Sie {size}",
"books": {
"ACT": "Apostelgeschichte",
"MAT": "Matthäus",
"JHN": "Johannes",
"MRK": "Markus",
"LUK": "Lukas",
"HEB": "Hebräer",
"GEN": "1. Mose",
"1JN": "1. Johannes",
"COL": "Kolosser",
"1CO": "1. Korinther",
"1PE": "1. Petrus",
"JOS": "Josua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Bible Posters",
"book": "Book",
"language": "Language",
"size": "Size",
"print": "Print",
"close": "Close",
"loading": "Loading the book…",
"fetchFailed": "Could not load this text. Please try again.",
"fitsAt": "Fits at {pt} pt in {cols} columns.",
"tooSmall": "Too small to read at this size.",
"trySize": "try {size}",
"books": {
"ACT": "Acts",
"MAT": "Matthew",
"JHN": "John",
"MRK": "Mark",
"LUK": "Luke",
"HEB": "Hebrews",
"GEN": "Genesis",
"1JN": "1 John",
"COL": "Colossians",
"1CO": "1 Corinthians",
"1PE": "1 Peter",
"JOS": "Joshua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Carteles bíblicos",
"book": "Libro",
"language": "Idioma",
"size": "Tamaño",
"print": "Imprimir",
"close": "Cerrar",
"loading": "Cargando el libro…",
"fetchFailed": "No se pudo cargar este texto. Inténtalo de nuevo.",
"fitsAt": "Cabe a {pt} pt en {cols} columnas.",
"tooSmall": "Demasiado pequeño para leerse en este tamaño.",
"trySize": "prueba {size}",
"books": {
"ACT": "Hechos",
"MAT": "Mateo",
"JHN": "Juan",
"MRK": "Marcos",
"LUK": "Lucas",
"HEB": "Hebreos",
"GEN": "Génesis",
"1JN": "1 Juan",
"COL": "Colosenses",
"1CO": "1 Corintios",
"1PE": "1 Pedro",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Affiches bibliques",
"book": "Livre",
"language": "Langue",
"size": "Taille",
"print": "Imprimer",
"close": "Fermer",
"loading": "Chargement du livre…",
"fetchFailed": "Impossible de charger ce texte. Veuillez réessayer.",
"fitsAt": "Tient à {pt} pt sur {cols} colonnes.",
"tooSmall": "Trop petit pour être lu à cette taille.",
"trySize": "essayez {size}",
"books": {
"ACT": "Actes",
"MAT": "Matthieu",
"JHN": "Jean",
"MRK": "Marc",
"LUK": "Luc",
"HEB": "Hébreux",
"GEN": "Genèse",
"1JN": "1 Jean",
"COL": "Colossiens",
"1CO": "1 Corinthiens",
"1PE": "1 Pierre",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "बाइबल पोस्टर",
"book": "पुस्तक",
"language": "भाषा",
"size": "आकार",
"print": "प्रिंट करें",
"close": "बंद करें",
"loading": "पुस्तक लोड हो रही है…",
"fetchFailed": "यह पाठ लोड नहीं हो सका। कृपया पुनः प्रयास करें।",
"fitsAt": "{pt} pt पर {cols} स्तंभों में समाता है।",
"tooSmall": "इस आकार में पढ़ने के लिए बहुत छोटा।",
"trySize": "{size} आज़माएँ",
"books": {
"ACT": "प्रेरितों के काम",
"MAT": "मत्ती",
"JHN": "यूहन्ना",
"MRK": "मरकुस",
"LUK": "लूका",
"HEB": "इब्रानियों",
"GEN": "उत्पत्ति",
"1JN": "1 यूहन्ना",
"COL": "कुलुस्सियों",
"1CO": "1 कुरिन्थियों",
"1PE": "1 पतरस",
"JOS": "यहोशू"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Poster biblici",
"book": "Libro",
"language": "Lingua",
"size": "Dimensione",
"print": "Stampa",
"close": "Chiudi",
"loading": "Caricamento del libro…",
"fetchFailed": "Impossibile caricare questo testo. Riprova.",
"fitsAt": "Sta a {pt} pt su {cols} colonne.",
"tooSmall": "Troppo piccolo da leggere a questa dimensione.",
"trySize": "prova {size}",
"books": {
"ACT": "Atti",
"MAT": "Matteo",
"JHN": "Giovanni",
"MRK": "Marco",
"LUK": "Luca",
"HEB": "Ebrei",
"GEN": "Genesi",
"1JN": "1 Giovanni",
"COL": "Colossesi",
"1CO": "1 Corinzi",
"1PE": "1 Pietro",
"JOS": "Giosuè"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Cartazes bíblicos",
"book": "Livro",
"language": "Idioma",
"size": "Tamanho",
"print": "Imprimir",
"close": "Fechar",
"loading": "Carregando o livro…",
"fetchFailed": "Não foi possível carregar este texto. Tente novamente.",
"fitsAt": "Cabe a {pt} pt em {cols} colunas.",
"tooSmall": "Pequeno demais para ler neste tamanho.",
"trySize": "tente {size}",
"books": {
"ACT": "Atos",
"MAT": "Mateus",
"JHN": "João",
"MRK": "Marcos",
"LUK": "Lucas",
"HEB": "Hebreus",
"GEN": "Gênesis",
"1JN": "1 João",
"COL": "Colossenses",
"1CO": "1 Coríntios",
"1PE": "1 Pedro",
"JOS": "Josué"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Postere biblice",
"book": "Cartea",
"language": "Limba",
"size": "Dimensiune",
"print": "Printează",
"close": "Închide",
"loading": "Se încarcă cartea…",
"fetchFailed": "Textul nu a putut fi încărcat. Încearcă din nou.",
"fitsAt": "Încape la {pt} pt pe {cols} coloane.",
"tooSmall": "Prea mic pentru a fi citit la această dimensiune.",
"trySize": "încearcă {size}",
"books": {
"ACT": "Faptele Apostolilor",
"MAT": "Matei",
"JHN": "Ioan",
"MRK": "Marcu",
"LUK": "Luca",
"HEB": "Evrei",
"GEN": "Geneza",
"1JN": "1 Ioan",
"COL": "Coloseni",
"1CO": "1 Corinteni",
"1PE": "1 Petru",
"JOS": "Iosua"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "Библейские плакаты",
"book": "Книга",
"language": "Язык",
"size": "Размер",
"print": "Печать",
"close": "Закрыть",
"loading": "Загрузка книги…",
"fetchFailed": "Не удалось загрузить текст. Попробуйте ещё раз.",
"fitsAt": "Помещается при {pt} pt в {cols} колонках.",
"tooSmall": "Слишком мелко для чтения при этом размере.",
"trySize": "попробуйте {size}",
"books": {
"ACT": "Деяния",
"MAT": "Матфея",
"JHN": "Иоанна",
"MRK": "Марка",
"LUK": "Луки",
"HEB": "Евреям",
"GEN": "Бытие",
"1JN": "1 Иоанна",
"COL": "Колоссянам",
"1CO": "1 Коринфянам",
"1PE": "1 Петра",
"JOS": "Иисус Навин"
}
}

View file

@ -0,0 +1,27 @@
{
"title": "圣经海报",
"book": "书卷",
"language": "语言",
"size": "尺寸",
"print": "打印",
"close": "关闭",
"loading": "正在加载书卷…",
"fetchFailed": "无法加载此文本,请重试。",
"fitsAt": "以 {pt} 磅、{cols} 栏排布。",
"tooSmall": "此尺寸下字太小,无法阅读。",
"trySize": "请尝试 {size}",
"books": {
"ACT": "使徒行传",
"MAT": "马太福音",
"JHN": "约翰福音",
"MRK": "马可福音",
"LUK": "路加福音",
"HEB": "希伯来书",
"GEN": "创世记",
"1JN": "约翰一书",
"COL": "歌罗西书",
"1CO": "哥林多前书",
"1PE": "彼得前书",
"JOS": "约书亚记"
}
}

View file

@ -0,0 +1,7 @@
{
"stage": "experimental",
"type": "single",
"label": "Bible Posters",
"targets": ["web"],
"note": "A generator that shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups. Text is fetched at render time from the Free Use Bible API (public-domain / openly-licensed only); no Bibles are stored in the repo. Lives in the Posterverse Experimental shelf."
}

View file

@ -0,0 +1,194 @@
<script setup>
/**
* bible-posters.vue — the Bible Posters generator (Posterverse → Experimental).
*
* Shows an ENTIRE BOOK of the Bible on one poster, in the languages of the top unreached people groups.
* Text is fetched at RENDER TIME from the Free Use Bible API (bible.helloao.org); no Bibles are stored.
* v1 offers STRICT PUBLIC-DOMAIN translations only (the licence gate); the layout engine + credit line
* are in src/. Print goes through the shared in-place printer (works for a human click and an agent).
*
* @description A whole book of the Bible on one poster, in justified columns under a bold title, from a public-domain text.
* @profile bible-posters
* @bundle bible-posters
* @element bible-posters
*/
import { inject, ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { printDocument } from '@map/composables/printInPlace.js'
import { PACKET_BOOKS, booksForTranslation } from './src/books.js'
import { TRANSLATIONS, TRANSLATION_BY_ID, DEFAULT_TRANSLATION_ID } from './src/translations.js'
import { BIBLE_POSTER_SIZES, SIZE_BY_ID, DEFAULT_SIZE_ID } from './src/sizes.js'
import { fetchBooks, fetchBook } from './src/bibleSource.js'
import { planBiblePoster, buildBiblePosterHtml, screenBiblePoster, MIN_READABLE_PT } from './src/biblePoster.js'
const { t } = useI18n()
const profileConfig = inject('profileConfig', null)
const cfg = (typeof profileConfig === 'object' && profileConfig) || {}
const translationId = ref(cfg.translation || DEFAULT_TRANSLATION_ID)
const bookId = ref(cfg.book || 'JHN')
const sizeId = ref(cfg.size || DEFAULT_SIZE_ID)
const availableBookIds = ref(PACKET_BOOKS.map((b) => b.id)) // narrows per translation (OT books need OT)
const book = ref(null) // the fetched, normalized book
const loading = ref(false)
const progress = reactive({ done: 0, total: 0 })
const errorKey = ref('')
let inflight = null
const translation = computed(() => TRANSLATION_BY_ID[translationId.value] || TRANSLATIONS[0])
const size = computed(() => SIZE_BY_ID[sizeId.value] || BIBLE_POSTER_SIZES[0])
const offeredBooks = computed(() => booksForTranslation(availableBookIds.value))
// The layout plan + the fit hint. `book` may be null while loading; guard.
const plan = computed(() => (book.value ? planBiblePoster(size.value, book.value) : null))
const screen = computed(() => {
if (!book.value) return null
return screenBiblePoster(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir, lang: translation.value.bcp47 })
})
// Which of the ladder's sizes fit this book, for the "which size" guidance.
const ladder = computed(() => {
if (!book.value) return []
return BIBLE_POSTER_SIZES.map((s) => {
const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book.value)
return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt }
})
})
const smallestFitting = computed(() => ladder.value.find((l) => l.fits) || null)
async function loadBooksList() {
try {
const list = await fetchBooks(translationId.value)
availableBookIds.value = list.map((b) => b.id)
if (!availableBookIds.value.includes(bookId.value)) {
const firstNt = offeredBooks.value.find((b) => b.testament === 'NT') || offeredBooks.value[0]
if (firstNt) bookId.value = firstNt.id
}
} catch { /* keep the full list; the book fetch reports a real failure */ }
}
async function loadBook() {
const controller = new AbortController()
if (inflight) inflight.abort()
inflight = controller
loading.value = true; errorKey.value = ''; progress.done = 0; progress.total = 0
try {
const b = await fetchBook(translationId.value, bookId.value, {
signal: controller.signal,
dir: translation.value.dir,
onProgress: (done, total) => { progress.done = done; progress.total = total },
})
if (controller.signal.aborted) return
book.value = b
} catch (e) {
if (!controller.signal.aborted) { errorKey.value = 'biblePosters.fetchFailed'; book.value = null }
} finally {
if (inflight === controller) { inflight = null; loading.value = false }
}
}
function print() {
if (!book.value) return
const html = buildBiblePosterHtml(book.value, size.value, { credit: translation.value.credit, dir: translation.value.dir, lang: translation.value.bcp47 })
printDocument(html, { labels: { print: t('biblePosters.print'), close: t('biblePosters.close') } })
}
watch(translationId, async () => { await loadBooksList(); await loadBook() })
watch(bookId, loadBook)
onMounted(async () => { await loadBooksList(); await loadBook() })
onBeforeUnmount(() => { if (inflight) inflight.abort() })
// The preview scales the poster's world (widthIn×96 px) to fit the preview box.
const previewBox = ref(null)
const previewScale = ref(1)
function fitPreview() {
const el = previewBox.value, s = screen.value
if (!el || !s) return
const pad = 24
const sx = (el.clientWidth - pad) / s.worldW
const sy = (el.clientHeight - pad) / s.worldH
previewScale.value = Math.max(0.02, Math.min(sx, sy))
}
watch([screen, previewBox], fitPreview, { flush: 'post' })
let ro = null
onMounted(() => { ro = new ResizeObserver(fitPreview); if (previewBox.value) ro.observe(previewBox.value) })
onBeforeUnmount(() => { if (ro) ro.disconnect() })
</script>
<template>
<div class="bpx">
<header class="bpx-bar">
<div class="bpx-title">{{ t('biblePosters.title') }}</div>
<div class="bpx-controls">
<label class="bpx-field">
<span>{{ t('biblePosters.book') }}</span>
<select v-model="bookId">
<option v-for="b in offeredBooks" :key="b.id" :value="b.id">{{ t('biblePosters.books.' + b.id) }}</option>
</select>
</label>
<label class="bpx-field">
<span>{{ t('biblePosters.language') }}</span>
<select v-model="translationId">
<option v-for="tr in TRANSLATIONS" :key="tr.id" :value="tr.id">{{ tr.endonym }}</option>
</select>
</label>
<label class="bpx-field">
<span>{{ t('biblePosters.size') }}</span>
<select v-model="sizeId">
<option v-for="s in BIBLE_POSTER_SIZES" :key="s.id" :value="s.id">{{ s.label }}</option>
</select>
</label>
<button type="button" class="bpx-print" :disabled="!book || loading" @click="print">
{{ t('biblePosters.print') }}
</button>
</div>
</header>
<p class="bpx-hint" v-if="plan">
<span v-if="plan.fits">{{ t('biblePosters.fitsAt', { pt: plan.fontPt, cols: plan.cols }) }}</span>
<span v-else class="bpx-warn">
{{ t('biblePosters.tooSmall') }}
<template v-if="smallestFitting"> — {{ t('biblePosters.trySize', { size: smallestFitting.label }) }}</template>
</span>
</p>
<div class="bpx-stage" ref="previewBox">
<div v-if="loading" class="bpx-status">
{{ t('biblePosters.loading') }}<span v-if="progress.total"> {{ progress.done }} / {{ progress.total }}</span>
</div>
<div v-else-if="errorKey" class="bpx-status bpx-warn">{{ t(errorKey) }}</div>
<template v-else-if="screen">
<div class="bpx-scaler" :style="{ width: screen.worldW + 'px', height: screen.worldH + 'px', transform: 'scale(' + previewScale + ')' }">
<component :is="'style'">{{ screen.css }}</component>
<div class="bp" :lang="screen.lang || null" :dir="screen.dir" v-html="screen.html"></div>
</div>
</template>
</div>
<p class="bpx-credit" v-if="book">{{ translation.credit }}</p>
</div>
</template>
<style>
.bpx { display: flex; flex-direction: column; height: 100%; min-height: 0; background: #f3f3f1; color: #3b463d;
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif; }
.bpx-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between;
padding: 12px 16px; background: #fff; border-bottom: 1px solid #d1d4cd; }
.bpx-title { font-size: 18px; font-weight: 700; }
.bpx-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.bpx-field { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #6d766e; }
.bpx-field select { min-height: 36px; padding: 0 10px; border: 1px solid #d1d4cd; border-radius: 8px;
background: #fff; color: #3b463d; font: inherit; }
.bpx-print { min-height: 38px; padding: 0 18px; border: 0; border-radius: 8px; background: #3b463d; color: #fff;
font: inherit; font-weight: 600; cursor: pointer; }
.bpx-print:disabled { background: #b7bdb4; cursor: default; }
.bpx-hint { margin: 0; padding: 8px 16px; font-size: 13px; color: #3b463d; }
.bpx-warn { color: #9a3b2f; }
.bpx-stage { flex: 1 1 0; min-height: 0; position: relative; overflow: hidden; display: flex;
align-items: center; justify-content: center; padding: 12px; }
.bpx-scaler { transform-origin: center center; box-shadow: 0 6px 24px rgba(0,0,0,.18); background: #fff; }
.bpx-status { font-size: 14px; color: #6d766e; }
.bpx-credit { margin: 0; padding: 8px 16px; font-size: 11px; color: #6d766e; text-align: center;
border-top: 1px solid #d1d4cd; background: #fff; }
</style>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · bible-posters staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #f3f3f1; color: #3b463d; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px 20px; }
h1 { font-size: 20px; font-weight: 700; }
.sub { color: #6d766e; font-size: 13px; margin: 6px 0 20px; }
.frame { height: 80vh; border: 1px solid #d1d4cd; border-radius: 12px; overflow: hidden; background: #fff; }
bible-posters { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>bible-posters · staging</h1>
<p class="sub">An entire book of the Bible on one poster, in the languages of the unreached.
Text is fetched at render time from public-domain sources; no Bibles are stored.</p>
<div class="frame">
<bible-posters profile-config='{"profile":"bible-posters"}'></bible-posters>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,32 @@
/**
* plugin-designs/bible-posters/index.js
*
* Bundle entry for the Bible Posters generator (Posterverse → Experimental).
* Folder name = bundle name = tag. One flat profile: bible-posters.vue.
* Builds to public/dx-maps/bible-posters/bible-posters.js.
*
* This plugin has NO map — it is a text/poster generator — so it registers no colour strategies and
* needs no Mapbox token; it honours only the reader's LOCALE (interface language) through the provided
* i18n instance, exactly like every other plugin.
*
* <script src="/dx-maps/bible-posters/bible-posters.js"></script>
* <bible-posters profile-config='{"profile":"bible-posters","locale":"ar"}'></bible-posters>
*/
import { defineMapElement } from '@map/elements/defineMapElement.js'
const profileModules = import.meta.glob('./*.vue')
const BiblePostersElement = defineMapElement({
profileModules,
configureApp: (app, { pinia, i18n }) => {
// defineMapElement already installs Pinia + i18n and provides the i18n instance to ProfileLoader
// (that is how a host sets the interface language). This hook is only for extra app-level provides;
// Bible Posters needs none.
},
})
if (typeof customElements !== 'undefined' && !customElements.get('bible-posters')) {
customElements.define('bible-posters', BiblePostersElement)
}
export default BiblePostersElement

View file

@ -0,0 +1,156 @@
/**
* biblePoster.js — the Bible-book poster layout engine (pure: plan maths + an HTML string, no Vue).
*
* Modeled on wall-of-people-groups/src/blessPoster.js (the wall's engine): a `fit()` search for the
* largest readable type size, an HTML document for the shared in-place print frame, and a screen model
* that mirrors the print exactly ("what the wall shows is what prints"). The CONTENT differs — a book
* is flowing text, not a card grid — so the body is CSS multi-column justified text rather than a grid.
*
* The approved layout: a bold BOOK-NAME header, then MULTIPLE COLUMNS about the width of a phone
* screen, ~7-10 words per line, JUSTIFIED, the whole book visible at once. RTL scripts flow right-to-left.
*
* ⚠ This is a MODEL of the layout (character-advance estimates), not a measurement of the rendered page.
* A headless plan proves the arithmetic; only a browser check proves the page. Every number the model
* uses is named here so a rendered measurement can correct the constants.
*/
const PT = 1 / 72 // one point in inches
export const MIN_READABLE_PT = 6 // a book printed below this is grey noise on a poster
export const MAX_PT = 16
export const PX_IN = 96 // screen world: CSS px per inch (matches blessPoster.screenPoster)
// Layout targets (the approved design, made numeric — correct against a rendered measurement).
const WORDS_PER_LINE = 8 // "~7-10 words per line"
const AVG_WORD_CHARS = 6 // 5 letters + a space, averaged
const CHARS_PER_LINE = WORDS_PER_LINE * AVG_WORD_CHARS // 48
const CHAR_EM = 0.5 // average glyph advance as a fraction of the em (justified serif)
const LINE_LH = 1.32 // body line-height
const COL_GAP_IN = 0.28 // gap between columns
const MARGIN_IN = 0.5 // poster margin (all sides)
const HEAD_FRAC = 0.10 // header band as a fraction of poster height (min 0.6in)
const CREDIT_IN = 0.32 // credit-line band height
const CONTENT_SLACK = 1.15 // justification + word-break slack over raw character count
/** Column width (inches) that yields ~WORDS_PER_LINE words per line at this type size. */
function colWidthIn(pt) { return CHARS_PER_LINE * CHAR_EM * pt * PT }
/**
* Plan a book onto a poster of widthIn × heightIn. Returns the chosen columns + type size and whether
* the whole book fits at a readable size.
* @param {{widthIn:number,heightIn:number}} size
* @param {{charCount:number,verseCount:number,blocks:Array}} book
*/
export function planBiblePoster(size, book) {
const W = size.widthIn - 2 * MARGIN_IN
const headH = Math.max(0.6, size.heightIn * HEAD_FRAC)
const H = size.heightIn - 2 * MARGIN_IN - headH - CREDIT_IN
const headingCount = (book.blocks || []).filter((b) => b.kind === 'heading').length
// effective characters the body must hold: text + verse-number glyphs + a line's worth per heading
const effective = Math.ceil(book.charCount * CONTENT_SLACK + book.verseCount * 3 + headingCount * CHARS_PER_LINE)
const capacityAt = (pt) => {
const colW = colWidthIn(pt)
const cols = Math.max(1, Math.floor((W + COL_GAP_IN) / (colW + COL_GAP_IN)))
const lineIn = pt * LINE_LH * PT
const linesPerCol = Math.max(0, Math.floor(H / lineIn))
return { pt, colW, cols, linesPerCol, capacity: cols * linesPerCol * CHARS_PER_LINE }
}
// Largest type size (0.5pt steps) whose capacity holds the whole book.
let chosen = null
for (let pt = MAX_PT; pt >= MIN_READABLE_PT - 0.001; pt -= 0.5) {
const c = capacityAt(pt)
if (c.capacity >= effective) { chosen = c; break }
}
const fits = !!chosen
const plan = chosen || capacityAt(MIN_READABLE_PT) // if nothing fits, report the floor's numbers
return {
fits, effective,
widthIn: size.widthIn, heightIn: size.heightIn,
marginIn: MARGIN_IN, headHIn: headH, creditHIn: CREDIT_IN,
contentWIn: W, contentHIn: H,
cols: plan.cols, colWIn: plan.colW, colGapIn: COL_GAP_IN,
fontPt: plan.pt, lineHeight: LINE_LH, linesPerCol: plan.linesPerCol,
capacity: plan.capacity,
}
}
/** Given a book and a set of candidate sizes, the smallest that fits (for the "which size" hint). */
export function fitLadder(sizes, book) {
return sizes.map((s) => {
const p = planBiblePoster({ widthIn: s.widthIn, heightIn: s.heightIn }, book)
return { id: s.id, label: s.label, fits: p.fits, fontPt: p.fontPt, cols: p.cols }
})
}
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
/** The body HTML: headings and verses in reading order; verse numbers are bidi-isolated superscripts. */
function bodyHtml(book) {
let html = ''
let lastChapter = 0
for (const b of book.blocks) {
if (b.kind === 'heading') { html += '<h2 class="bh">' + esc(b.text) + '</h2>'; continue }
if (b.chapter !== lastChapter) { html += '<span class="cn">' + esc(b.chapter) + '</span>'; lastChapter = b.chapter }
html += '<span class="v"><sup class="vn" dir="ltr">' + esc(b.number) + '</sup>' + esc(b.text) + ' </span>'
}
return html
}
/** CSS shared by print and screen. Built by concatenation (no backticks inside — AGENT-BRIEF house rule
* for shadow CSS; kept here for consistency and safe embedding). `unit` scales the poster to px on screen. */
function posterCss(plan, dir, unit) {
const u = (inches) => (inches * unit).toFixed(2) + (unit === 1 ? 'in' : 'px')
const isRtl = dir === 'rtl'
return ''
+ '*{box-sizing:border-box}'
+ '.bp{position:relative;width:' + u(plan.widthIn) + ';height:' + u(plan.heightIn) + ';'
+ 'padding:' + u(plan.marginIn) + ';background:#fff;color:#1c1c1c;'
+ 'font-family:Georgia,"Times New Roman",serif;direction:' + (isRtl ? 'rtl' : 'ltr') + ';'
+ '-webkit-print-color-adjust:exact;print-color-adjust:exact;overflow:hidden}'
+ '.bp-head{height:' + u(plan.headHIn) + ';display:flex;flex-direction:column;align-items:center;'
+ 'justify-content:center;text-align:center;border-bottom:' + u(0.02) + ' solid #1c1c1c}'
+ '.bp-title{margin:0;font-weight:700;line-height:1.02;font-size:' + u(plan.headHIn * 0.5) + '}'
+ '.bp-body{height:' + u(plan.contentHIn) + ';margin-top:' + u(0.12) + ';'
+ 'column-count:' + plan.cols + ';column-gap:' + u(plan.colGapIn) + ';column-fill:auto;'
+ 'font-size:' + (unit === 1 ? plan.fontPt + 'pt' : (plan.fontPt * unit / 72).toFixed(2) + 'px') + ';'
+ 'line-height:' + plan.lineHeight + ';text-align:justify;hyphens:auto;-webkit-hyphens:auto;'
+ 'text-justify:inter-word;overflow:hidden}'
+ '.bp-body .bh{font-weight:700;font-size:1.02em;margin:0.5em 0 0.15em;break-after:avoid;'
+ 'font-family:-apple-system,"Segoe UI",Arial,sans-serif}'
+ '.bp-body .cn{font-weight:700;font-size:1.5em;line-height:1;float:' + (isRtl ? 'right' : 'left') + ';'
+ 'margin:' + (isRtl ? '0 0 0 0.12em' : '0 0.12em 0 0') + ';color:#92552a}'
+ '.bp-body .vn{font-size:0.62em;font-weight:700;color:#92552a;vertical-align:super;'
+ 'unicode-bidi:isolate;margin:0 0.12em}'
+ '.bp-body .v{}'
+ '.bp-credit{position:absolute;left:' + u(plan.marginIn) + ';right:' + u(plan.marginIn) + ';'
+ 'bottom:' + u(plan.marginIn * 0.4) + ';height:' + u(plan.creditHIn) + ';display:flex;align-items:center;'
+ 'justify-content:center;text-align:center;font-family:-apple-system,"Segoe UI",Arial,sans-serif;'
+ 'font-size:' + u(0.11) + ';color:#6d766e}'
}
/** The print document: one page, the whole book. `texts` supplies the credit line and optional dir/lang. */
export function buildBiblePosterHtml(book, size, texts = {}) {
const dir = texts.dir || book.dir || 'ltr'
const lang = texts.lang || '' // a BCP-47 tag so hyphens:auto works and the page declares its language
const plan = planBiblePoster(size, book)
const css = '@page{size:' + size.widthIn + 'in ' + size.heightIn + 'in;margin:0}html,body{margin:0;padding:0}'
+ posterCss(plan, dir, 1)
const inner = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
return '<!doctype html><html' + (lang ? ' lang="' + esc(lang) + '"' : '') + (dir === 'rtl' ? ' dir="rtl"' : '') + '><head><meta charset="utf-8">'
+ '<title>' + esc(book.name) + '</title><style>' + css + '</style></head>'
+ '<body><div class="bp">' + inner + '</div></body></html>'
}
/** The screen model (WYSIWYG): the same poster, scaled to CSS px. Returns { css, html, worldW, worldH }. */
export function screenBiblePoster(book, size, texts = {}) {
const dir = texts.dir || book.dir || 'ltr'
const plan = planBiblePoster(size, book)
const css = posterCss(plan, dir, PX_IN)
const html = '<div class="bp-head"><h1 class="bp-title" dir="auto">' + esc(book.name) + '</h1></div>'
+ '<div class="bp-body" dir="auto">' + bodyHtml(book) + '</div>'
+ '<div class="bp-credit">' + esc(texts.credit || '') + '</div>'
return { css, html, dir, lang: texts.lang || '', plan, worldW: size.widthIn * PX_IN, worldH: size.heightIn * PX_IN }
}

View file

@ -0,0 +1,114 @@
/**
* bibleSource.js — the RENDER-TIME text client for the Free Use Bible API (bible.helloao.org).
*
* Fetches a whole book on demand and returns a normalized structure the renderer can lay out.
* NOTHING is written to the repo or public/: whole Bibles are never stored (the licence gate and the
* §4 spec). A small in-memory (per-bundle, per-session) cache avoids re-fetching a book the reader
* just looked at; it is discarded on reload.
*
* The API (verified 2026-09-30):
* {base}/{translationId}/books.json → { books: [{ id, commonName, numberOfChapters, ... }] }
* {base}/{translationId}/{BOOK}/{chapter}.json → { chapter: { content: [ {type:'verse',number,content:[...]}
* | {type:'heading',content:[...]} ] } }
* CORS is open (Access-Control-Allow-Origin: *) and no key is required.
*/
export const API_BASE = 'https://bible.helloao.org/api'
const booksCache = new Map() // translationId -> [{id, name, chapters, order}]
const bookCache = new Map() // `${translationId}/${bookId}` -> normalized book
async function getJson(url, signal) {
const res = await fetch(url, { signal, headers: { accept: 'application/json' } })
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url)
return res.json()
}
/** The translation's book table: id → localized name + chapter count. Used to build the picker and to
* know which of the 12 packet books this translation actually has (OT books need an OT translation). */
export async function fetchBooks(translationId, { signal } = {}) {
if (booksCache.has(translationId)) return booksCache.get(translationId)
const d = await getJson(`${API_BASE}/${translationId}/books.json`, signal)
const raw = Array.isArray(d) ? d : (d.books || [])
const books = raw.map((b) => ({
id: b.id,
name: b.commonName || b.name || b.id,
chapters: b.numberOfChapters || 0,
order: b.order || 0,
}))
booksCache.set(translationId, books)
return books
}
/** One verse/heading item → plain text. Verse `content` is an array of strings and, occasionally,
* objects (poetry lines, notes). We keep the readable text and drop note apparatus. */
function itemText(item) {
const parts = Array.isArray(item.content) ? item.content : [item.content]
return parts
.map((p) => {
if (typeof p === 'string') return p
if (p && typeof p === 'object') return p.text || (Array.isArray(p.content) ? p.content.filter((x) => typeof x === 'string').join(' ') : '')
return ''
})
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* Fetch an ENTIRE book: every chapter, flattened to an ordered list of blocks.
* Returns { bookId, name, dir, chapters: N, blocks: [{kind:'heading'|'verse', chapter, number?, text}],
* verseCount, charCount }. `onProgress(done, total)` fires per chapter.
*/
export async function fetchBook(translationId, bookId, { signal, onProgress, dir = 'ltr', concurrency = 4 } = {}) {
const key = `${translationId}/${bookId}`
if (bookCache.has(key)) { onProgress?.(1, 1); return bookCache.get(key) }
const books = await fetchBooks(translationId, { signal })
const meta = books.find((b) => b.id === bookId)
if (!meta) throw new Error(`${translationId} has no book ${bookId}`)
const total = meta.chapters
const chapterBlocks = new Array(total) // keep chapter order regardless of fetch order
let done = 0
// One book is one atomic fetch: a chapter failure ABORTS the siblings (never a half book), while still
// honouring the caller's own signal. An internal controller chains to it so we can abort from inside.
const internal = new AbortController()
const onExt = () => internal.abort()
if (signal) { if (signal.aborted) internal.abort(); else signal.addEventListener('abort', onExt, { once: true }) }
async function one(ch) {
try {
const d = await getJson(`${API_BASE}/${translationId}/${bookId}/${ch}.json`, internal.signal)
const content = (d.chapter || d).content || []
const blocks = []
for (const item of content) {
if (item.type === 'heading') { const t = itemText(item); if (t) blocks.push({ kind: 'heading', chapter: ch, text: t }) }
else if (item.type === 'verse') { const t = itemText(item); if (t) blocks.push({ kind: 'verse', chapter: ch, number: item.number, text: t }) }
}
chapterBlocks[ch - 1] = blocks
done += 1
onProgress?.(done, total)
} catch (e) { internal.abort(); throw e } // stop the other workers on the first failure
}
// bounded-concurrency pool over chapters 1..total
const queue = []
for (let ch = 1; ch <= total; ch++) queue.push(ch)
const workers = new Array(Math.min(concurrency, total)).fill(0).map(async () => {
while (queue.length && !internal.signal.aborted) { const ch = queue.shift(); await one(ch) }
})
try { await Promise.all(workers) }
finally { if (signal) signal.removeEventListener('abort', onExt) }
const blocks = chapterBlocks.flat().filter(Boolean)
let verseCount = 0, charCount = 0
for (const b of blocks) { if (b.kind === 'verse') verseCount++; charCount += b.text.length }
const book = { bookId, name: meta.name, dir, chapters: total, blocks, verseCount, charCount }
bookCache.set(key, book)
return book
}
/** Test seam: clear the session caches. */
export function _clearCache() { booksCache.clear(); bookCache.clear() }

View file

@ -0,0 +1,39 @@
/**
* books.js — the 12 books the UPG-100 prayer packet cites, in packet-citation order.
*
* The Bible Posters generator shows an ENTIRE BOOK on one poster. These 12 are the starting set
* (the approved starting set): every book referenced by the en packet in
* library/scripts/posters/packet-strings.json. The verse list is the index; the poster offers the book.
*
* `id` — the USFM/OSIS book id the Free Use Bible API (bible.helloao.org) uses in its URLs.
* `en` — an English fallback label for the UI picker BEFORE the fetched translation is known;
* the POSTER HEADER itself uses the fetched translation's own `commonName` (in the text
* language), so a reader sees "يوحنا" / "Johannes", never the English name.
* `testament` — 'OT' books (Genesis, Joshua) exist only in a translation that HAS an Old Testament,
* which narrows the offered language set; the other 10 are New Testament and fetch from
* any NT-only translation too.
* `chaptersHint` — the chapter count for the WEB text (eng_web), used only to size a progress bar and a
* fit pre-estimate; the real count comes from the translation's own books.json at render.
*/
export const PACKET_BOOKS = [
{ id: 'ACT', en: 'Acts', testament: 'NT', chaptersHint: 28 },
{ id: 'MAT', en: 'Matthew', testament: 'NT', chaptersHint: 28 },
{ id: 'JHN', en: 'John', testament: 'NT', chaptersHint: 21 },
{ id: 'MRK', en: 'Mark', testament: 'NT', chaptersHint: 16 },
{ id: 'LUK', en: 'Luke', testament: 'NT', chaptersHint: 24 },
{ id: 'HEB', en: 'Hebrews', testament: 'NT', chaptersHint: 13 },
{ id: 'GEN', en: 'Genesis', testament: 'OT', chaptersHint: 50 },
{ id: '1JN', en: '1 John', testament: 'NT', chaptersHint: 5 },
{ id: 'COL', en: 'Colossians', testament: 'NT', chaptersHint: 4 },
{ id: '1CO', en: '1 Corinthians', testament: 'NT', chaptersHint: 16 },
{ id: '1PE', en: '1 Peter', testament: 'NT', chaptersHint: 5 },
{ id: 'JOS', en: 'Joshua', testament: 'OT', chaptersHint: 24 },
]
export const BOOK_BY_ID = Object.fromEntries(PACKET_BOOKS.map((b) => [b.id, b]))
/** Books this translation can serve: OT books need a translation whose books.json includes them. */
export function booksForTranslation(availableBookIds) {
const have = new Set(availableBookIds || [])
return PACKET_BOOKS.filter((b) => have.has(b.id))
}

View file

@ -0,0 +1,35 @@
/**
* sizes.js — the poster size ladder for Bible Posters, taken from the SHARED table.
*
* Consumes the centrally-owned ladder (library/constants/posterSizes.js, imported as
* '@map/constants/posterSizes.js'); this plugin only SELECTS its subset of keys and keeps its own
* locale-neutral dimension labels until the shared picker (with localized names) lands. The size table
* is never edited here.
*
* A book is laid out on the FINISHED poster (width × height, portrait). `sheets` is the shared entry's
* panel count; the multi-panel PRINT path (reusing library panelSheets.js) is a later milestone — the
* fit MODEL uses the finished dimensions.
*/
import { POSTER_LADDER, resolvePosterSize } from '@map/constants/posterSizes.js'
// The 5 ladder keys this plugin offers (portrait; a whole book needs the large ones). 'paper' carries
// Letter inches in the shared table; sizing it to A4 for an A4 reader (via paperFor) is a later refinement.
const BIBLE_KEYS = ['paper', '36x36', '36x60', '66x66', '60x96']
// Locale-neutral DIMENSION labels (in/ft are unit symbols, like cm) so no English word leaks into a
// non-English UI. Swap for the shared picker's localized names when it lands.
const LABELS = {
paper: '8.5 × 11 in',
'36x36': '3 × 3 ft',
'36x60': '3 × 5 ft',
'66x66': '5.5 × 5.5 ft',
'60x96': '5 × 8 ft',
}
export const BIBLE_POSTER_SIZES = BIBLE_KEYS.map((k) => {
const e = resolvePosterSize(k) || POSTER_LADDER[k]
return { id: k, key: k, label: LABELS[k], widthIn: e.inches.w, heightIn: e.inches.h, sheets: e.sheets.count }
})
export const SIZE_BY_ID = Object.fromEntries(BIBLE_POSTER_SIZES.map((s) => [s.id, s]))
export const DEFAULT_SIZE_ID = '36x60'

View file

@ -0,0 +1,63 @@
/**
* translations.js — the LICENCE-VETTED text sources for the Bible Posters generator.
*
* ⚠ LICENSING GATE (this plugin's first task). A full BOOK is displayed IN FULL on a
* printed, white-label giveaway poster, so ONLY texts whose licence permits that may ship. This v1
* list is STRICT PUBLIC-DOMAIN ONLY — the exact translations the UPG-100 packet already ships as
* public domain, plus the WEB seed. It is safe under EITHER answer to the open licensing question
* (PD-only vs. a licensed/broader set), so it is what builds now.
*
* The CC-BY / CC-BY-SA expansion (the IRV family — Hindi, Tamil, Telugu, Punjabi — and the wider
* UPG languages) is DEFERRED pending the open licensing decision; do not add a non-public-domain text
* here without explicit sign-off and a per-translation licence check (eBible details + Door43),
* never the transport's blanket "no restrictions" claim nor eBible's Redistributable flag alone.
*
* Transport: the Free Use Bible API (bible.helloao.org) — CORS, no key, per-chapter JSON, fetched at
* RENDER TIME. No whole Bibles are stored in the repo or public/. Each translation carries a
* `licenseUrl` back to its eBible record; `credit` is the poster's printed credit line.
*
* `iso639_3` is the text language, so the picker can offer the languages of the top unreached people
* groups (the Gospel Resources map data keys on iso639_3) once the CC-BY-SA set is approved.
*/
/** @typedef {Object} Translation
* @property {string} id Free Use Bible API translation id (its URL segment)
* @property {string} iso639_3 text language code
* @property {string} language English language name (UI grouping)
* @property {string} name the translation's English name
* @property {'ltr'|'rtl'} dir text direction
* @property {string} licence the licence class (v1: all 'public-domain')
* @property {string} credit the poster credit line
* @property {string} licenseUrl the licence record of source
*/
/** v1 STRICT PUBLIC-DOMAIN seed — the packet's 6 + WEB. Every entry has all 66 books (OT+NT). */
export const TRANSLATIONS = [
{ id: 'eng_web', iso639_3: 'eng', bcp47: 'en', language: 'English', endonym: 'English', name: 'World English Bible',
dir: 'ltr', licence: 'public-domain',
credit: 'World English Bible · public domain · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=eng-web' },
{ id: 'spa_r09', iso639_3: 'spa', bcp47: 'es', language: 'Spanish', endonym: 'Español', name: 'Reina Valera 1909',
dir: 'ltr', licence: 'public-domain',
credit: 'Reina Valera 1909 · dominio público · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=spaRV1909' },
{ id: 'fra_ost', iso639_3: 'fra', bcp47: 'fr', language: 'French', endonym: 'Français', name: 'French Ostervald Bible',
dir: 'ltr', licence: 'public-domain',
credit: 'Bible Ostervald · domaine public · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=fra_fob' },
{ id: 'deu_l12', iso639_3: 'deu', bcp47: 'de', language: 'German', endonym: 'Deutsch', name: 'German Luther Bible 1912',
dir: 'ltr', licence: 'public-domain',
credit: 'Lutherbibel 1912 · gemeinfrei · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=deu1912' },
{ id: 'rus_syn', iso639_3: 'rus', bcp47: 'ru', language: 'Russian', endonym: 'Русский', name: 'Russian Synodal Bible',
dir: 'ltr', licence: 'public-domain',
credit: 'Синодальный перевод · общественное достояние · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=russyn' },
{ id: 'arb_vdv', iso639_3: 'arb', bcp47: 'ar', language: 'Arabic', endonym: 'العربية', name: 'Arabic Van Dyck Bible',
dir: 'rtl', licence: 'public-domain',
credit: 'فان دايك · ملك عام · ebible.org · bible.helloao.org',
licenseUrl: 'https://ebible.org/Scriptures/details.php?id=arb-vd' },
]
export const TRANSLATION_BY_ID = Object.fromEntries(TRANSLATIONS.map((t) => [t.id, t]))
export const DEFAULT_TRANSLATION_ID = 'eng_web'

View file

@ -374,7 +374,10 @@ const NOT_EXPERIMENTAL = new Set([
// posterverse is the OTHER capability-5 shell (the poster plugins). Shown here it would be a
// whole second rail inside this pane: a shell does not nest a shell, for the same reason this
// shell excludes itself above.
'posterverse'
'posterverse',
// bible-posters lives on POSTERVERSE's Experimental shelf, and only there: one poster plugin, shown
// once, in the shell that gathers the poster plugins.
'bible-posters'
])
// ── EXPERIMENTAL ORDER = KINGDOM IMPACT, not the alphabet ────────────────────────────────

View file

@ -1,11 +1,12 @@
{
"_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element <posterverse-app> 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; a provisional row (bible-posters) joins the list in the SAME push as its bundle, since an unresolved ref fails check:capability-derived and a missing one fails check:posterverse-shelf.",
"_comment": "Declares the composability tier. 'nested-mfe-spa' = capability 5: an app shell whose single element <posterverse-app> 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. A new row's ref joins this list in the SAME push as its bundle, since an unresolved ref fails check:capability-derived and a missing one fails check:posterverse-shelf.",
"type": "nested-mfe-spa",
"composes": [
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"poster-design/poster-design-mfe",
"wall-of-people-groups/wall-of-people-groups"
"wall-of-people-groups/wall-of-people-groups",
"bible-posters/bible-posters"
],
"label": "Posterverse (vertical-tab app shell: every poster plugin)"
}

View file

@ -10,10 +10,10 @@
* 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: Bible Posters is declared with PROVISIONAL ids and stays invisible until a
* bundle of that id exists. Its owner confirms the ids; change the `bundle`/`profile` here to match,
* never the row `id` (the id is the stable key for ?tab= links). A provisional row's `composes` entry
* lands in the same push as its bundle (check:posterverse-shelf arm D).
* before its plugin lands: mark it `provisional: true` and it stays invisible until a bundle of that id
* exists. Its owner confirms the ids; change the `bundle`/`profile` here to match, never the row `id`
* (the id is the stable key for ?tab= links). A new row's `composes` entry lands in the same push as
* its bundle (check:posterverse-shelf arm D).
*
* ★ 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
@ -32,8 +32,7 @@ export const TABS = [
{ id: 'country-posters', section: 'posters', icon: 'map', bundle: 'country-posters', profile: 'country-posters-mfe' },
{ id: 'poster-design', section: 'posters', icon: 'poster', bundle: 'poster-design', profile: 'poster-design-mfe' },
{ id: 'wall-of-people-groups', section: 'experimental', icon: 'flask', bundle: 'wall-of-people-groups', profile: 'wall-of-people-groups' },
// 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 },
{ id: 'bible-posters', section: 'experimental', icon: 'flask', bundle: 'bible-posters', profile: 'bible-posters' },
]
// Bundles that must never be a Posterverse row. The allow-list above already excludes them; this set

View file

@ -2,7 +2,7 @@
This folder is GENERATED by `bun run build`; do not edit it by hand. It holds exactly one thing:
- **`dx-maps/`** — the output folder: 27 plugin bundles and 41 pages, self-contained.
- **`dx-maps/`** — the output folder: 28 plugin bundles and 42 pages, self-contained.
Copy this one folder to any web host (or open it from disk) and it works.
- `dx-maps/index.html` — the front page: the Plugin Studio, every plugin in one place.
- `dx-maps/<plugin>/<plugin>.js` — a plugin's bundle (a custom element; load it with a `<script>` tag).

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- THIS PAGE IS OURS. The only thing that knows a document belongs to the plugin is the page
the generator writes, so it says so here. library/i18n reads it before reflecting an RTL
locale on <html dir>; nothing else sets it, so a PARTNER page defaults to "not ours" and
is never touched. Absence must mean restraint — an embedded plugin flipping a partner's
whole <html> because a reader followed a ?lang=ar link is the bug this prevents. -->
<script>window.MAP_OWNS_PAGE = true;</script>
<title>bible-posters · bible-posters</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself.
HEIGHT IN svh, NOT inset:0. A fixed box with inset:0 follows the phone's DYNAMIC viewport, so the map
(and a legend sheet sized from it) changes height every time the address bar hides or shows. 100svh is
the viewport WITH the bar shown and never changes; when the bar hides, the page background shows below
instead of the map resizing. On a desktop svh = the viewport, so nothing moves there. The 100% before it
is the fallback for a browser without svh, which would otherwise size the box to its content.
--profile-min-h:0 opts out of ProfileLoader's 100dvh floor, which would grow the element again. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, bible-posters { position: fixed; inset: 0 auto auto 0; width: 100%; height: 100%; height: 100svh; --profile-min-h: 0; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
/* ── PRINT, BOTH LEAF SHAPES ────────────────────────────────────────────────────────────
Two things every generated leaf got wrong on paper, because no generated page has ever
carried an @media print rule (measured: 0 across the tree).
The background is #0a0e15 — a dark page, printed. Browsers drop background colours by
default, but a reader who ticks "Background graphics" to get the MAP also gets the page
flooded black, and that is the reader who most wants the printout.
#status is the LOADING OVERLAY: position:fixed, inset:0, over everything. On paper it is
a full sheet reading "loading…" above the content that did in fact load. It is hidden
only while it is NOT an error — an error still prints, because a blank sheet with the
explanation suppressed is worse than a sheet that says what went wrong. */
@media print {
html, body { height: auto; background: #fff; }
#status:not(.err) { display: none; }
/* ★ RELEASE THE FIXED HOST, MEASURED (a print check on a035408e2). The leaf pins both the
slot and the element with position:fixed; inset:0. A fixed element is painted once, on the
first page, so the printed document was exactly ONE viewport: 900px tall, while the content
continued to y=1753. Every plugin whose leaf is taller than a screen has been printing its
top screenful and silently dropping the rest — the dashboard printed its stats and its map,
then blank to the bottom of the sheet.
min-height, not a released height alone: a full-bleed map leaf takes its SIZE from this rule,
so height:auto with nothing else can leave the element a zero-height box and print an empty
page — a worse failure than the one being fixed, and the discriminator is not isMap
(region-dashboards is isMap:true and is a scrolling dashboard). The floor keeps a definite
box for the map case while letting a taller document grow onto page two. */
#slot, bible-posters { position: static; inset: auto; width: 100%; height: auto; min-height: 14cm; }
}
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bible-posters> custom element. -->
<script src="../bible-posters.js" integrity="sha384-x0qrqnzFOHxhCWZN4YYpnME08TLUQdfMI8oR5cAnQDtOVKu133/27BXcHCr0Ev7Q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"bible-posters","dataSource":"pray-tools","instanceId":"bible-posters-bible-posters","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = true;
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
var NESTS_MAPS = false;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. Keep it a one-shot
write: do NOT turn this into a per-frame or per-event handler (a resize loop strobes the screen). */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
// Nested maps (a composite's children) read window.MAP_TOKEN, not this element's config.
if (token && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = token; }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("bible-posters");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins,
// and 'auto' draws with MapLibre whether or not a token is available (the element decides that).
// This page only decides whether to FIND a token first: when one is on offer (a rung above, a
// host-set window.MAP_TOKEN, or a token endpoint) it resolves it, since place search uses it on
// either engine; with none, it mounts at once, keyless: no token prompt, no request to Mapbox.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
document.body.appendChild(bar);
status = bar;
askToken();
var goBtn = document.getElementById('tkGo');
if (goBtn) goBtn.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (v) { location.reload(); }
});
}
return;
}
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 27,
"profileCount": 41,
"bundleCount": 28,
"profileCount": 42,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -37,6 +37,38 @@
}
]
},
{
"bundle": "bible-posters",
"scriptUrl": "./bible-posters/bible-posters.js",
"output": "dx-maps/bible-posters/bible-posters.js",
"integrity": "sha384-x0qrqnzFOHxhCWZN4YYpnME08TLUQdfMI8oR5cAnQDtOVKu133/27BXcHCr0Ev7Q",
"elements": [
"bible-posters"
],
"stage": "experimental",
"profiles": [
{
"profile": "bible-posters",
"stage": "experimental",
"label": "Bible Posters",
"targets": [
"web"
],
"file": "bible-posters.vue",
"description": "A whole book of the Bible on one poster, in justified columns under a bold title, from a public-domain text.",
"element": "bible-posters",
"type": "single",
"isMap": false,
"iframeUrl": "./bible-posters/bible-posters/index.html",
"scriptUrl": "./bible-posters/bible-posters.js",
"embed": {
"iframe": "<iframe src=\"./bible-posters/bible-posters/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./bible-posters/bible-posters.js\" integrity=\"sha384-x0qrqnzFOHxhCWZN4YYpnME08TLUQdfMI8oR5cAnQDtOVKu133/27BXcHCr0Ev7Q\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<bible-posters profile-config='{\"profile\":\"bible-posters\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bible-posters>"
},
"bundle": "bible-posters"
}
]
},
{
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
@ -361,7 +393,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO",
"integrity": "sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH",
"elements": [
"doxa-maps-spa"
],
@ -391,7 +423,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -1134,7 +1166,7 @@
"bundle": "posterverse",
"scriptUrl": "./posterverse/posterverse.js",
"output": "dx-maps/posterverse/posterverse.js",
"integrity": "sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W",
"integrity": "sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q",
"elements": [
"posterverse-app"
],
@ -1142,7 +1174,8 @@
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"poster-design/poster-design-mfe",
"wall-of-people-groups/wall-of-people-groups"
"wall-of-people-groups/wall-of-people-groups",
"bible-posters/bible-posters"
],
"stage": "experimental",
"profiles": [
@ -1159,7 +1192,7 @@
"scriptUrl": "./posterverse/posterverse.js",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
},
"instances": [
{

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 27,
"profileCount": 41,
"bundleCount": 28,
"profileCount": 42,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -37,6 +37,38 @@
}
]
},
{
"bundle": "bible-posters",
"scriptUrl": "./bible-posters/bible-posters.js",
"output": "dx-maps/bible-posters/bible-posters.js",
"integrity": "sha384-x0qrqnzFOHxhCWZN4YYpnME08TLUQdfMI8oR5cAnQDtOVKu133/27BXcHCr0Ev7Q",
"elements": [
"bible-posters"
],
"stage": "experimental",
"profiles": [
{
"profile": "bible-posters",
"stage": "experimental",
"label": "Bible Posters",
"targets": [
"web"
],
"file": "bible-posters.vue",
"description": "A whole book of the Bible on one poster, in justified columns under a bold title, from a public-domain text.",
"element": "bible-posters",
"type": "single",
"isMap": false,
"iframeUrl": "./bible-posters/bible-posters/index.html",
"scriptUrl": "./bible-posters/bible-posters.js",
"embed": {
"iframe": "<iframe src=\"./bible-posters/bible-posters/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./bible-posters/bible-posters.js\" integrity=\"sha384-x0qrqnzFOHxhCWZN4YYpnME08TLUQdfMI8oR5cAnQDtOVKu133/27BXcHCr0Ev7Q\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<bible-posters profile-config='{\"profile\":\"bible-posters\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bible-posters>"
},
"bundle": "bible-posters"
}
]
},
{
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
@ -361,7 +393,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO",
"integrity": "sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH",
"elements": [
"doxa-maps-spa"
],
@ -391,7 +423,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-jJHY8JXkPZd8UQz7gDBNE4n+LX+DsK5MgcafqLChgSL5e1AUz+YaItO8LOX19VzO\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-jvHzikM2VJQXpIlJwQVbEXFg/JOw67aiJsBaRBMkHKc4w080G41TsDc6Gt3y3GiH\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -1134,7 +1166,7 @@
"bundle": "posterverse",
"scriptUrl": "./posterverse/posterverse.js",
"output": "dx-maps/posterverse/posterverse.js",
"integrity": "sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W",
"integrity": "sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q",
"elements": [
"posterverse-app"
],
@ -1142,7 +1174,8 @@
"my-upg-100-list/upg-100-mfe",
"country-posters/country-posters-mfe",
"poster-design/poster-design-mfe",
"wall-of-people-groups/wall-of-people-groups"
"wall-of-people-groups/wall-of-people-groups",
"bible-posters/bible-posters"
],
"stage": "experimental",
"profiles": [
@ -1159,7 +1192,7 @@
"scriptUrl": "./posterverse/posterverse.js",
"embed": {
"iframe": "<iframe src=\"./posterverse/posterverse/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./posterverse/posterverse.js\" integrity=\"sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<posterverse-app profile-config='{\"profile\":\"posterverse\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></posterverse-app>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
<script src="../../posterverse.js" integrity="sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W" crossorigin="anonymous"></script>
<script src="../../posterverse.js" integrity="sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <posterverse-app> custom element. -->
<script src="../posterverse.js" integrity="sha384-+1IEWkJujDRDE7t/piXT6lq6T0QdhCYfT4pv4OURz6EKxDK8p8YcSZNZOv5uRi0W" crossorigin="anonymous"></script>
<script src="../posterverse.js" integrity="sha384-SHwg48KTIMJixoVevPoDmKMsuJ1tAKE2fa4WBS1bOmGeUawyWBt4n029N8itST7q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: