Compare commits
13 commits
main
...
reg-post/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a435c9967 | ||
|
|
fa5721deb0 | ||
|
|
40ac08abd0 | ||
|
|
525b306f2c | ||
|
|
0e5b1626af | ||
|
|
617bd1fca6 | ||
|
|
5eabfa2346 | ||
|
|
db3931165b | ||
|
|
e74671b7ab | ||
|
|
3601e75c91 | ||
|
|
88485be7ed | ||
|
|
b7d7b6306a | ||
|
|
1954f2b589 |
236 changed files with 2952 additions and 8097 deletions
147
README.md
147
README.md
|
|
@ -37,9 +37,8 @@ Built with love, with the prayer that these maps bless the nations.
|
|||
**A blessing for you.** These maps and posters are given to bless the people who bless the nations.
|
||||
We love you, and we are thankful for your work. We bless you in Jesus' name.
|
||||
|
||||
Posterverse (`plugin-designs/posterverse/`) brings the six poster plugins together: UPG100 Posters, Country
|
||||
Posters, Poster Design, Wall of People Groups, Bible Posters and Bless India Posters. They are all in the
|
||||
Plugin Studio (`bun run dev`) too.
|
||||
Every poster is in Posterverse (`plugin-designs/posterverse/`): UPG100 Posters, Country Posters,
|
||||
Poster Design and Wall of People Groups. They are all in the Plugin Studio (`bun run dev`) too.
|
||||
|
||||
# dx-maps — one build, maps that embed everywhere
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ capabilities, three outputs, one shared library, one build.
|
|||
| 🔗 **Maps that talk to each other** | Maps share props and events, so one app can nest them and a click in one can move another. |
|
||||
| 🪶 **Small bundles** | Each map's file carries only the parts of the shared library it uses (tree-shaken). |
|
||||
| ⚙️ **One build step** | `bun run build` makes every map at once. |
|
||||
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, and a desktop app built from the same code you run in the browser — [the 3 outputs](#what-the-build-makes--3-outputs). |
|
||||
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, a desktop app — [the 3 outputs](#what-the-build-makes--3-outputs). |
|
||||
|
||||
| What dx-maps set out to do | Proof |
|
||||
|---|---|
|
||||
|
|
@ -71,7 +70,7 @@ capabilities, three outputs, one shared library, one build.
|
|||
| **One app nests every map** — and each stays embeddable on its own | [capability 5](#capability-5--app-shell-the-research-gallery) · `bun run check:composite-reg` |
|
||||
| **5 capabilities, every one shippable in every output** | `bun run verify:5-caps` |
|
||||
| **Shared library, private plugins** — reuse without breaking each other | `bun run check:library-index` |
|
||||
| **One agent per plugin, the repo agent reads every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
| **One owner per plugin, a second reader for every change, one merge at a time** | [agent swarm](#build-it-with-an-agent-swarm) |
|
||||
|
||||
## For vibe coders: build, open the Studio, embed a map
|
||||
|
||||
|
|
@ -126,9 +125,9 @@ people groups of the world. When you work here, the goal is one of these:
|
|||
library and the checks.
|
||||
- **One plugin per folder** in `plugin-designs/`; shared code goes in `library/` only if it stays reusable for
|
||||
every other plugin.
|
||||
- **Join `development`** (the pilot), one change at a time through the lock, with `gates:pre`, `gates:post` and the landing checks passing. `main` is
|
||||
- **Land on `development`**, one change at a time through the lock, with `bun run gates:pre` passing. `main` is
|
||||
production and moves only on a publish.
|
||||
- These 8 maps are in production: the 7 production maps in the gallery, plus the country map for DX Life's country pages. The
|
||||
- These 8 maps are in production: the 7 in the gallery, plus the country map for DX Life's country pages. The
|
||||
other maps are experimental: leave them as they are unless your task is one
|
||||
of them.
|
||||
|
||||
|
|
@ -136,12 +135,11 @@ people groups of the world. When you work here, the goal is one of these:
|
|||
|
||||
---
|
||||
|
||||
## The production maps
|
||||
## The 7 production maps
|
||||
|
||||
**Seven production maps DX uses today, all together in the Research Gallery**, which DX Life publishes
|
||||
as **Deep Research**; each one is also a plugin you can embed on its own. The gallery also carries two
|
||||
poster plugins still marked experimental, Country Posters and Poster Design (see Posterverse). An eighth
|
||||
production map, the country map (`country-map`), draws DX Life's country pages; it isn't in the gallery.
|
||||
**Seven maps DX uses today** — each one a plugin you can embed on its own, and all seven together in
|
||||
the Research Gallery, which DX Life publishes as **Deep Research**. An eighth production map, the country map
|
||||
(`country-map`), draws DX Life's country pages; it isn't in the gallery.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
|
@ -154,7 +152,7 @@ production map, the country map (`country-map`), draws DX Life's country pages;
|
|||
<td width="25%" valign="top"><img src="documentation/readme-images/cap4-upg100-mfe.png" alt="UPG100 Posters"><br><b>UPG100 Posters</b><br><sub>prayer lists of 100 people groups, with posters to print</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet.png" alt="Cities with Internet"><br><b>Cities with Internet</b><br><sub>internet access where unreached people groups live</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources.png" alt="Gospel Resources"><br><b>Gospel Resources</b><br><sub>which people groups have scripture, film or audio — and which have nothing</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all nine in one app — capability 5</sub></td>
|
||||
<td width="25%" valign="top"><img src="documentation/readme-images/cap5-doxa-maps-spa.png" alt="Research Gallery"><br><b>Research Gallery</b><br><sub>all seven in one app — capability 5</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -261,9 +259,10 @@ If you build with AI agents, this repo is made for you:
|
|||
updating the moment you save.
|
||||
- **Speak instead of typing.** Rudder, an offline voice-to-text tool, sits as a corner widget in the
|
||||
Studio: look at a plugin, say what you see, and hand the text to your agent. Nothing leaves your
|
||||
computer. It also runs as its own desktop app.
|
||||
- **One plugin, several outputs.** The same code ships as a web embed, a mobile app or a desktop app
|
||||
([the desktop app](#output-3--the-desktop-app--works-today)).
|
||||
computer (Linux today).
|
||||
- **One plugin, several outputs.** The same plugin ships as a web embed or a mobile app, and a plugin
|
||||
can target the desktop: Rudder is the desktop example, a corner widget in the Studio and its own
|
||||
desktop app (`bun run desktop:build rudder-voice`, see [HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
|
||||
- **Build together.** [Run it in the cloud](#run-it-in-the-cloud) gives a whole team one shared dev
|
||||
server.
|
||||
|
||||
|
|
@ -327,7 +326,7 @@ improvement on the next build.
|
|||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What the Research Gallery is made of — seven production maps, two poster plugins, an Experimental shelf, all five capabilities in one plugin</b></summary>
|
||||
<summary><b>What the Research Gallery is made of — seven production maps, an Experimental shelf, all five capabilities in one plugin</b></summary>
|
||||
|
||||
```text
|
||||
Research Gallery <doxa-maps-spa> capability 5
|
||||
|
|
@ -342,12 +341,10 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
│ ├─ regions list regions-list capability 1
|
||||
│ └─ region dashboards region-dashboards capability 1
|
||||
├─ Prayer Mobilization
|
||||
│ ├─ Prayer Progress doxa-simple-map capability 2
|
||||
│ ├─ UPG100 Posters upg-100-mfe capability 4
|
||||
│ │ ├─ zoned map upg-100-list capability 1
|
||||
│ │ └─ poster wall poster-wall capability 1
|
||||
│ ├─ Country Posters country-posters-mfe capability 4
|
||||
│ └─ Poster Design poster-design-mfe capability 4
|
||||
│ └─ Prayer Progress doxa-simple-map capability 2
|
||||
├─ Gospel Research
|
||||
│ ├─ Cities with Internet connectivity-map capability 1
|
||||
│ └─ Gospel Resources resources-map capability 1
|
||||
|
|
@ -355,6 +352,7 @@ Research Gallery <doxa-maps-spa> capability 5
|
|||
├─ prayer walk planner prayer-walk-planner capability 1
|
||||
├─ UPG neighbor upg-neighbor capability 3
|
||||
├─ all people groups all-people-groups capability 1
|
||||
├─ country posters country-posters capability 4
|
||||
├─ vision card doxa-vision-card capability 2
|
||||
├─ PPLR data maps pplr-data-maps capability 3
|
||||
└─ Wall of People Groups wall-of-people-groups capability 3
|
||||
|
|
@ -500,7 +498,7 @@ files in the repo. The `Dockerfile` and `compose.yaml` sit at the repo root; rec
|
|||
|
||||
**Plugin Studio: the public Studio, with PocketBase.** The highest route: the same container with
|
||||
`ACCESS=public`, on your research subdomain.
|
||||
- The Plugin Studio follows `development` and is open to everyone, so what agents add to the pilot shows up live.
|
||||
- The Plugin Studio follows `development` and is open to everyone, so what agents land shows up live.
|
||||
- Its `/cdn`, built from `main`, serves your site's production maps.
|
||||
- Publishing and PocketBase's dashboard ask for a password you set when you deploy; OIDC stays available.
|
||||
|
||||
|
|
@ -617,38 +615,21 @@ bun run phone # builds the app and installs it on a phone or emulator
|
|||
|
||||
### Output 3 — the desktop app ✅ works today
|
||||
|
||||
**Run it in your browser, then ship the same code as a desktop app.** [Tauri](https://tauri.app) wraps
|
||||
it in a small Rust program that runs on your computer. Rudder, the offline voice-to-text plugin, is the
|
||||
proof: its Linux desktop app works and is used to dictate the work on many of these maps. Its goal is
|
||||
private voice-to-text that you own: offline, no telemetry, no cloud.
|
||||
**We got the JAMstack map app running as a desktop app.**
|
||||
|
||||

|
||||
|
||||
**Rudder, the voice-to-text plugin, runs as its own desktop app too**, built with
|
||||
`bun run desktop:build rudder-voice` ([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)).
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary><b>More: how it works, Rudder's goal, and what isn't run yet</b></summary>
|
||||
<summary><b>More about the desktop app</b></summary>
|
||||
|
||||
**How it works.** What you build and try on the development server (`bun run dev`) is what ships to
|
||||
the desktop; there is no second code base. Rudder runs as a corner widget in the Plugin Studio and as
|
||||
its own desktop app, built with `bun run desktop:build rudder-voice`
|
||||
([HOWTO-build-the-desktop-app](./documentation/HOWTO-build-the-desktop-app.md)): look at a plugin, say
|
||||
what you see, and hand the text to an agent. The map app runs as a desktop app the same way.
|
||||
|
||||
**Rudder's goal, from the start: private voice-to-text that you own.**
|
||||
|
||||
- **Offline, with no telemetry and no cloud.** Your own code and your own self-hosted speech model
|
||||
(NVIDIA's open NeMo-Speech.cpp runtime and a small speech model, about 742 MB) run on a modest amount
|
||||
of your computer's CPU. After a one-time download of the runtime and the model, nothing needs the
|
||||
network; a trace of the speech runtime showed zero sockets and zero DNS lookups. The aim is that it
|
||||
works out of the box: today the first-run download is still a manual step.
|
||||
- **Be careful who commits to Rudder.** A voice-to-text tool hears everything said near it, so one
|
||||
bad change could turn it into spyware. Take changes to `plugin-designs/rudder-voice/` and
|
||||
`backend/rudder-engine/` only from people you trust, and have a second person read every one.
|
||||
- **Planned: an egress monitor** that shows you, as it runs, that Rudder sends no data out to anyone.
|
||||
|
||||
**What isn't run yet.** Tauri builds a Linux app and a `.deb` installer today. The macOS and Windows
|
||||
builds are set up but have not been run ([the HOWTO](./documentation/HOWTO-build-the-desktop-app.md)
|
||||
says which steps are proven). The desktop build runs on demand with the commands below; the automatic
|
||||
production build doesn't make it yet. The Research Gallery could become a strong desktop app too.
|
||||
[Tauri](https://tauri.app) wraps the same built folder into a desktop program: it produced a Linux
|
||||
app and a `.deb` installer. It isn't in the dx bundler for production yet — and the Research Gallery
|
||||
could become a strong desktop app.
|
||||
|
||||
```bash
|
||||
bun run check:tauri # checks the desktop setup — needs no Rust tools
|
||||
|
|
@ -744,9 +725,8 @@ has one agent that owns it, and the work moves through three kinds of branch:
|
|||
with hot reload follows it, so each change that joins can be seen there within about 15 seconds.
|
||||
- **The landing branch (`main`)** is what ships. It moves only when a person approves a publish, and
|
||||
the landing server rebuilds from it.
|
||||
- **PR branches** hold one plugin or feature each. They start from the pilot branch, take in the latest
|
||||
pilot by merging (never rebasing), and join it one at a time, after the checks pass and a second agent,
|
||||
usually the repository's agent, has read the change.
|
||||
- **PR branches** hold one plugin or feature each. They start from the landing branch and join the
|
||||
pilot branch, one at a time, after the checks pass and a second reader has read the change.
|
||||
|
||||
**It scales by adding PR branches, not by sharing one.** Many people, each with several agents, can
|
||||
work on many plugins at the same time: every plugin or feature gets its own PR branch, and each one
|
||||
|
|
@ -754,12 +734,10 @@ can be seen on the pilot server as soon as it joins the pilot.
|
|||
|
||||
**Bring your own harness.** dx-maps works with whatever AI coding agent or agent harness you already
|
||||
use. The repo gives you the branch strategy below, the checks (`bun run gates:pre`) and the Plugin
|
||||
Studio; it does not ship a harness. There are three roles: one agent per repository, which handles its
|
||||
branches and watches the work being done in it; one agent per plugin; and one shepherd agent that all the
|
||||
other agents talk to, which keeps order and puts the merges onto the pilot in order. Your harness gives
|
||||
these jobs to its agents.
|
||||
Studio; it does not ship a harness. Roles such as a coordinator that puts the merges onto the pilot in order, or a
|
||||
gate-keeper that reads each change before it joins the pilot, are jobs your harness gives to its agents.
|
||||
|
||||

|
||||

|
||||
|
||||
<sub>[Open the diagram full size](documentation/readme-images/delegation-model.png): on a phone its small text needs it.</sub>
|
||||
|
||||
|
|
@ -770,11 +748,11 @@ these jobs to its agents.
|
|||
```bash
|
||||
git fetch origin && git switch -c development origin/main && git push -u origin development
|
||||
```
|
||||
3. **One PR branch and worktree per feature**, each started from the pilot branch, then install
|
||||
3. **One PR branch and worktree per feature**, each started from the landing branch, then install
|
||||
once in each:
|
||||
```bash
|
||||
git worktree add -b regions/work ../dx-maps-regions origin/development
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 origin/development
|
||||
git worktree add -b regions/work ../dx-maps-regions origin/main
|
||||
git worktree add -b upg100/work ../dx-maps-upg100 origin/main
|
||||
cd ../dx-maps-regions && bun install --frozen-lockfile
|
||||
```
|
||||
4. **One preview server per agent on your own machine, each on its own port** (in the cloud, the one
|
||||
|
|
@ -782,43 +760,35 @@ these jobs to its agents.
|
|||
```bash
|
||||
bun x vite --port 5301 --strictPort # the next agent uses 5302, and so on
|
||||
```
|
||||
5. **Join the pilot one at a time, through a lock that works like a merge queue.** First merge the
|
||||
latest pilot into your PR branch (merge, never rebase) and fix any clash there. A second agent reads
|
||||
the change, and looks at it on screen where it shows. Then, inside the lock, merge the pilot once more
|
||||
in case it moved, run the checks (`gates:pre`, `gates:post`) and the landing checks (fresh bundles,
|
||||
the tree, the integrity hashes, no conflict markers), and push only if nothing fails:
|
||||
5. **Join the pilot one at a time, through a lock that works like a merge queue.** Inside the lock, merge your PR branch into a
|
||||
throwaway copy of the pilot branch, run the checks, and push that merge only if nothing fails
|
||||
(`gates:pre` exits non-zero when a check fails, so the push never runs then). Your PR branch itself
|
||||
stays one feature on the landing branch; only the pilot gets the merge:
|
||||
```bash
|
||||
git fetch origin && git merge --no-edit origin/development # on your PR branch: the latest pilot
|
||||
flock /tmp/dx-maps-development.lock sh -c '
|
||||
git fetch origin && git merge --no-edit origin/development &&
|
||||
bun run gates:pre && bun run gates:post &&
|
||||
node internal/landing-preconditions.mjs &&
|
||||
git push origin HEAD:development'
|
||||
git fetch origin && git switch --detach origin/development &&
|
||||
git merge --no-edit regions/work &&
|
||||
bun run gates:pre && git push origin HEAD:development
|
||||
git switch regions/work'
|
||||
```
|
||||
If the merge stops on a conflict, run `git merge --abort`, fix the clash on your PR branch and join
|
||||
again. `flock` guards one machine. Agents on several machines need a shared lock, such as one your
|
||||
harness provides.
|
||||
6. **Each change rebuilds what it touches.** There is no single build owner: rebuild every bundle your
|
||||
change reaches, then the tree and the integrity hashes, and commit them with the change. The landing
|
||||
checks refuse a change whose bundles are stale:
|
||||
6. **One agent owns the full build**; the others build only their own plugin:
|
||||
```bash
|
||||
BUNDLE=<name> bun x vite build # each bundle your change reaches
|
||||
bun run tree && bun run fix:sri # then the tree and the integrity hashes
|
||||
BUILD_ALLOW=1 bun run build # the build owner: every plugin
|
||||
BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri # everyone else: one plugin
|
||||
```
|
||||
Only a change to the build itself (its config or its dependencies) reaches every bundle:
|
||||
`BUILD_ALLOW=1 bun run build`.
|
||||
7. **Publish, when a person approves it:** make sure the release the landing branch is on now has its
|
||||
own version tag (that tag is the rollback point), fast-forward the landing branch to a pilot commit
|
||||
that passed the checks, tag it, and deploy from the tag:
|
||||
7. **Publish, when a person approves it:** fast-forward the landing branch to a pilot commit that
|
||||
passed the checks, tag it, and deploy from the tag:
|
||||
```bash
|
||||
git fetch origin && git switch main && git merge --ff-only origin/main
|
||||
git tag -a v0.2.0 -m "Release v0.2.0" && git push origin v0.2.0 # the outgoing release, if untagged
|
||||
git merge --ff-only <development-commit> # a commit that passed the checks
|
||||
bun run gates:pre && bun run gates:post # the checks must pass on exactly this commit
|
||||
bun run gates:pre # the checks must pass on exactly this commit
|
||||
git tag -a v1.0.0 -m "Release v1.0.0" && git push origin main v1.0.0
|
||||
```
|
||||
The tag's `public/dx-maps/` folder is the built site: deploy that, and build the landing demo from
|
||||
the same tag ([how to deploy](./documentation/DEPLOY.md)). To go back, deploy the previous release's tag. Publish to a public host, such as GitHub, only from
|
||||
The tag's `public/dx-maps/` folder is the built site: deploy that
|
||||
([how to deploy](./documentation/DEPLOY.md)). Publish to a public host, such as GitHub, only from
|
||||
the landing branch and only when it is ready.
|
||||
|
||||
### Developing in the cloud with a swarm
|
||||
|
|
@ -856,9 +826,8 @@ So the project works like a busy kitchen:
|
|||
2. **Only one change joins the pilot at a time.** Whoever holds the *pilot lock* first catches up with
|
||||
everyone else's latest work, fixes any clash itself and runs every check; the change goes into the
|
||||
pilot branch only if nothing fails. The pilot branch always works.
|
||||
3. **Someone keeps order.** One shepherd agent, which all the other agents talk to, keeps order and puts
|
||||
the merges onto the pilot in order; one agent per repository handles its branches and watches the work
|
||||
being done in it. Your harness gives these jobs to its agents.
|
||||
3. **Someone keeps order.** A coordinator can put the merges onto the pilot in order, and a gate-keeper can read
|
||||
each change by its content before it joins the pilot. Your harness gives these jobs to its agents.
|
||||
4. **The landing branch moves only when a person approves a publish**, after the checks pass on that exact version.
|
||||
The pilot server follows the pilot branch; the landing server rebuilds from the landing branch.
|
||||
|
||||
|
|
@ -876,7 +845,7 @@ That's how many plugins get built at the same time, in one project, without the
|
|||
| **main** (the landing) | the version that ships: it moves only when a person approves publishing `development` |
|
||||
| **worktree** | a private copy of the project for one agent to work in |
|
||||
| **merge conflict** | two changes touched the same lines; someone has to decide how to combine them |
|
||||
| **merge** | taking everyone else's latest work into your branch: the latest pilot goes into your PR branch (never a rebase), before joining and once more inside the pilot lock |
|
||||
| **merge** | combining your branch with everyone else's latest work; here it happens inside the pilot lock, in a throwaway copy of the pilot |
|
||||
| **pilot lock** | only one change merges onto `development` at a time; its holder catches up and runs every check first |
|
||||
| **tag** | a fixed name for one release commit, such as `v1.0.0`, so you can always deploy exactly it |
|
||||
|
||||
|
|
@ -960,7 +929,7 @@ The studio is a plugin like any other: `plugin-designs/plugin-studio/plugin-stud
|
|||
### Run it in the cloud
|
||||
|
||||
**One shared dev server for the whole team, no laptop setup.** A container clones the repo, follows
|
||||
`development`, and hot-reloads every change that joins the pilot, so everyone sees the Plugin Studio update in
|
||||
`development`, and hot-reloads every change an agent lands, so everyone sees the Plugin Studio update in
|
||||
their browser within seconds, with no rebuild. Add OIDC settings and it puts a "Sign in with *your
|
||||
provider*" page in front of everything, open only to the groups you allow. Recipe and settings:
|
||||
[deployment/cloud/](./deployment/cloud/README.md).
|
||||
|
|
@ -971,7 +940,7 @@ The three ways dx-maps ships (a drop-in into your own site, the container on its
|
|||
|
||||
### Cloud development environment
|
||||
|
||||
**Your laptop can follow what agents add to the pilot somewhere else.** By default `bun run dev` is fully local: no
|
||||
**Your laptop can follow what agents land somewhere else.** By default `bun run dev` is fully local: no
|
||||
settings, nothing fetched, and only your own edits hot-reload. To also see what agents push to a shared
|
||||
branch (an agent on a server, or the whole team), add two lines to `.env.local` (gitignored):
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@ Rudder (`plugin-designs/rudder-voice`) is voice-to-text that runs on your own co
|
|||
local engine (`backend/rudder-engine`, Go) that drives NVIDIA's NeMo-Speech.cpp speech server. The
|
||||
desktop app is a Tauri window around the built web tree, with the engine started as a **sidecar**.
|
||||
|
||||
This page is written for an agent following it cold. **Read "What is proven" first**: the Linux app is
|
||||
proven and in use; the macOS and Windows path has not been run yet, and the steps say so where that is
|
||||
the case.
|
||||
This page is written for an agent following it cold. **Read "What is proven" first**: most of the
|
||||
desktop path has never been run by anyone, and the steps say so where that is the case.
|
||||
|
||||
## What is proven, and what is not
|
||||
|
||||
|
|
@ -19,8 +18,8 @@ the case.
|
|||
| The engine **compiling** for macOS (arm64, x86_64) and Windows (x86_64) | Proven by cross-compiling. It has **never been run** on either. |
|
||||
| The engine accepting a WebSocket from the Tauri webview (`tauri://localhost`, `http://tauri.localhost`) | Proven against a running engine; another site's origin is refused. |
|
||||
| NeMo-Speech.cpp runtime for macOS / Windows | Published by NVIDIA (release v0.1.0: `macos-aarch64-cpu`, `macos-aarch64-metal`, `macos-x86_64-cpu`, `windows-x86_64-cpu` / `-cuda` / `-vulkan`). **Never run here.** |
|
||||
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The installed Linux app has since been launched and is in use: it is used to dictate the work on these maps. |
|
||||
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer. The installed Linux app works (the row above); not yet run on macOS or Windows. |
|
||||
| `bun run desktop:build rudder-voice` on **Linux** | Proven: produced `Rudder_0.1.0_amd64.deb` (8.8 MB) holding the app and the `rudder-engine` sidecar side by side. The app has **not been launched** from it yet (no display on the build box). |
|
||||
| The shell starting and stopping the engine sidecar | Built (`deployment/src-tauri/src/main.rs`), compiled into that installer; not yet run. |
|
||||
| First-run model download | **Not built.** Run `setup-runtime.sh` into the app's data folder by hand (step 7). |
|
||||
|
||||
## 1. Prerequisites
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 931 KiB After Width: | Height: | Size: 926 KiB |
BIN
documentation/readme-images/deploy5-desktop.png
Normal file
BIN
documentation/readme-images/deploy5-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
|
|
@ -116,15 +116,15 @@
|
|||
<text x="614.1" y="556.0" text-anchor="middle" font-size="12" font-weight="800" fill="#155e75">Experimental</text>
|
||||
<text x="614.1" y="570.0" text-anchor="middle" font-size="10.5" fill="#155e75">shelf · 5 plugins</text>
|
||||
<circle cx="543.4" cy="351.1" r="40" fill="#ecfeff" stroke="#0891b2" stroke-width="2.5"/>
|
||||
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">an agent</text>
|
||||
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">per plugin</text>
|
||||
<text x="543.4" y="349.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">roaming</text>
|
||||
<text x="543.4" y="364.1" text-anchor="middle" font-size="12" font-weight="700" fill="#155e75">agent</text>
|
||||
<circle cx="690" cy="248" r="56" fill="#f5f3ff" stroke="#7c3aed" stroke-width="3.5"/>
|
||||
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">shepherd</text>
|
||||
<text x="690" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#4c1d95">coordinator</text>
|
||||
<circle cx="812" cy="248" r="56" fill="#f1f5f9" stroke="#334155" stroke-width="3.5"/>
|
||||
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">repo agent</text>
|
||||
<text x="884" y="192" font-size="17" fill="#4c1d95">one shepherd: every agent talks to it; it orders the merges;</text>
|
||||
<text x="884" y="216" font-size="17" fill="#1e293b">a repo agent per repo runs its branches and watches the work</text>
|
||||
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one agent per plugin, in its own branch · the repo agent reads every change</text>
|
||||
<text x="812" y="254" text-anchor="middle" font-size="14" font-weight="800" fill="#1e293b">gate-keeper</text>
|
||||
<text x="884" y="192" font-size="17" fill="#4c1d95">a coordinator puts the merges in order;</text>
|
||||
<text x="884" y="216" font-size="17" fill="#1e293b">a gate-keeper checks each one by its content</text>
|
||||
<text x="750" y="1502" text-anchor="middle" font-size="20" font-weight="800" fill="#1e3a8a">one owner per plugin, in its own branch · a second reader for every change</text>
|
||||
<rect x="36" y="110" width="304" height="280" rx="14" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2"/>
|
||||
<circle cx="64" cy="142" r="11" fill="#166534" stroke="#14532d" stroke-width="2.5"/>
|
||||
<text x="88" y="147" font-size="15" fill="#334155">pilot branch (development)</text>
|
||||
|
|
@ -139,9 +139,9 @@
|
|||
<circle cx="64" cy="302" r="11" fill="#eff6ff" stroke="#2563eb" stroke-width="2.5"/>
|
||||
<text x="88" y="307" font-size="15" fill="#334155">agent — owns one plugin</text>
|
||||
<circle cx="64" cy="334" r="11" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2.5"/>
|
||||
<text x="88" y="339" font-size="15" fill="#334155">shepherd — orders merges</text>
|
||||
<text x="88" y="339" font-size="15" fill="#334155">coordinator — orders merges</text>
|
||||
<circle cx="64" cy="366" r="11" fill="#f1f5f9" stroke="#334155" stroke-width="2.5"/>
|
||||
<text x="88" y="371" font-size="15" fill="#334155">repo agent — one per repo</text>
|
||||
<text x="88" y="371" font-size="15" fill="#334155">gate-keeper — checks each merge</text>
|
||||
<rect x="60" y="1536" width="1380" height="232" rx="20" fill="#fef2f2" stroke="#fca5a5" stroke-width="2"/>
|
||||
<text x="750" y="1578" text-anchor="middle" font-size="22" font-weight="800" fill="#7f1d1d">How a change joins the pilot — one at a time, so many plugins can be built at once</text>
|
||||
<rect x="80.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
|
|
@ -155,8 +155,8 @@
|
|||
<rect x="425.0" y="1600" width="305" height="148" rx="14" fill="#fff" stroke="#dc2626" stroke-width="2"/>
|
||||
<circle cx="459.0" cy="1634" r="18" fill="#dc2626"/>
|
||||
<text x="459.0" y="1641" text-anchor="middle" font-size="19" font-weight="800" fill="#fff">2</text>
|
||||
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Repo agent reads</text>
|
||||
<text x="445.0" y="1680" font-size="15" fill="#475569">the repo agent reads every</text>
|
||||
<text x="487.0" y="1641" font-size="19" font-weight="800" fill="#0f172a">Second reader</text>
|
||||
<text x="445.0" y="1680" font-size="15" fill="#475569">another agent reads every</text>
|
||||
<text x="445.0" y="1702" font-size="15" fill="#475569">change by its content</text>
|
||||
<text x="445.0" y="1724" font-size="15" fill="#475569">before it may join</text>
|
||||
<text x="750.0" y="1682" text-anchor="middle" font-size="26" font-weight="800" fill="#dc2626">→</text>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
12
documentation/readme-images/src/deploy5-desktop.svg
Normal file
12
documentation/readme-images/src/deploy5-desktop.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" font-family="Inter, 'Helvetica Neue', Arial, sans-serif">
|
||||
<rect width="1200" height="630" rx="28" fill="#f8fafc"/>
|
||||
<rect x="1.5" y="1.5" width="1197" height="627" rx="27" fill="none" stroke="#e2e8f0" stroke-width="3"/>
|
||||
<g transform="translate(110,175) scale(11.5)"><path d="M13.912 0a8.72 8.72 0 0 0-8.308 6.139c1.05-.515 2.18-.845 3.342-.976 2.415-3.363 7.4-3.412 9.88-.097 2.48 3.315 1.025 8.084-2.883 9.45a6.131 6.131 0 0 1-.3 2.762 8.72 8.72 0 0 0 3.01-1.225A8.72 8.72 0 0 0 13.913 0zm.082 6.451a2.284 2.284 0 1 0-.15 4.566 2.284 2.284 0 0 0 .15-4.566zm-5.629.27a8.72 8.72 0 0 0-3.031 1.235 8.72 8.72 0 1 0 13.06 9.9131 10.173 10.174 0 0 1-3.343.965 6.125 6.125 0 1 1-7.028-9.343 6.114 6.115 0 0 1 .342-2.772zm1.713 6.27a2.284 2.284 0 0 0-2.284 2.283 2.284 2.284 0 0 0 2.284 2.284 2.284 2.284 0 0 0 2.284-2.284 2.284 2.284 0 0 0-2.284-2.284z" fill="#24C8DB"/></g>
|
||||
<text x="480" y="250" font-size="54" font-weight="800" fill="#0f172a">Desktop app (Tauri)</text>
|
||||
<text x="480" y="315" font-size="27" fill="#475569">We got the JAMstack map app running</text>
|
||||
<text x="480" y="355" font-size="27" fill="#475569">as a desktop app.</text>
|
||||
<rect x="480" y="400" width="420" height="62" rx="31" fill="#fef3c7" stroke="#d97706" stroke-width="2.5"/>
|
||||
<text x="690" y="441" text-anchor="middle" font-size="26" font-weight="800" fill="#92400e">PROVEN · NOT YET LIVE</text>
|
||||
<text x="480" y="508" font-size="22" fill="#64748b">Proven, not yet in production. The Research</text>
|
||||
<text x="480" y="538" font-size="22" fill="#64748b">Gallery could become a strong desktop app.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -237,8 +237,6 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/useSharedMapToken.js'`
|
||||
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
|
||||
`import … from '@map/composables/useTextHighlight.js'`
|
||||
- **useViewAddress** — useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
||||
`import … from '@map/composables/useViewAddress.js'`
|
||||
|
||||
### Utilities (pure helper functions)
|
||||
|
||||
|
|
@ -280,8 +278,6 @@ and a change here can break other maps.
|
|||
`import … from '@map/utils/peopleGroupName.js'`
|
||||
- **peopleGroupPoints** — peopleGroupPoints.js — SHARED (@map/utils/peopleGroupPoints.js).
|
||||
`import … from '@map/utils/peopleGroupPoints.js'`
|
||||
- **publicEmbed** — publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
|
||||
`import … from '@map/utils/publicEmbed.js'`
|
||||
- **scrollport** — findScrollportAncestor — the nearest ancestor that actually scrolls.
|
||||
`import … from '@map/utils/scrollport.js'`
|
||||
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,
|
||||
|
|
|
|||
|
|
@ -50,19 +50,6 @@ function readGated() {
|
|||
}
|
||||
export const GATED_PROFILES = readGated()
|
||||
|
||||
// The PUBLIC address of the tree for the Share menu's Embed tab (the config's shareBaseUrl): an https URL with no
|
||||
// trailing slash, or '' (unset). vite.config.js bakes it as __DOXA_SHARE_BASE__; library/utils/embedBase.js reads it.
|
||||
function readShareBase() {
|
||||
let v = ''
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
|
||||
v = typeof cfg.shareBaseUrl === 'string' ? cfg.shareBaseUrl.trim().replace(/\/+$/, '') : ''
|
||||
} catch { return '' }
|
||||
if (v && !/^https:\/\/[^\s"'<>]+$/i.test(v)) throw new Error(`[_tree-name] build config "shareBaseUrl" must be an https URL, got ${JSON.stringify(v)}`)
|
||||
return v
|
||||
}
|
||||
export const SHARE_BASE = readShareBase()
|
||||
|
||||
// The name escaped for use inside a RegExp source (a '.' in a future name must not match any char).
|
||||
export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-dev-leaf-escape.mjs — a dev leaf URL cannot write markup into the page it opens.
|
||||
*
|
||||
* THE DEFECT. The dev server's leaf route, /__leaf/<bundle>/<profile>[/<instance>] (and a production embed URL,
|
||||
* <bundle>/<profile>[/<instance>]/index.html, rewritten onto it), writes the URL's parts into the page it serves: the
|
||||
* <title> and the element's profile-config='…' attribute. It refused only `..` and `/`, so a quote or a tag in the path
|
||||
* was written raw, and a crafted link broke out of the attribute on a publicly reachable dev server.
|
||||
* internal/plugin-gallery/dev-plugin.mjs now refuses any part outside LEAF_PART (the shape every bundle folder, profile
|
||||
* id and instance id has) before either sink, and escapes the <title> as well.
|
||||
*
|
||||
* The plugin's own middleware answers each request (no server, no browser):
|
||||
* A. every leaf the dev manifest advertises (galleryModel), as /__leaf/… AND as its production embed URL → 200 with
|
||||
* its element: the rule refuses no real page;
|
||||
* B. a hostile part (a quote, a double quote, a tag, an ampersand, a space, a backtick, an encoded slash or
|
||||
* backslash, `..`, `.`, an attribute break-out) in each position, both URL forms → refused here (400) or left to
|
||||
* Vite, never a page;
|
||||
* C. escHtml leaves none of & < > " ' raw;
|
||||
* D. with the part rule set back to the old one (`..` and `/` only), a tag in the profile still reaches the <title>
|
||||
* escaped: the title has its own escaping, not only the rule in front of it.
|
||||
* Response bodies are never printed: a leaf page carries the build config's token.
|
||||
* `--self-test`: the old guard, a rule too strict for a real profile, an unescaped <title> and an escHtml that misses
|
||||
* the quote must each FAIL.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { discoverSpaRegistry } from './plugin-gallery/discover.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const PLUGIN = join(HERE, 'plugin-gallery/dev-plugin.mjs')
|
||||
const SRC = readFileSync(PLUGIN, 'utf8')
|
||||
const GUARD = 'parts.some(s => !LEAF_PART.test(s))'
|
||||
const OLD_GUARD = "parts.some(s => !s || s.includes('..') || s.includes('/'))"
|
||||
const RULE = 'export const LEAF_PART = /^[A-Za-z0-9][\\w-]{0,63}$/'
|
||||
const TITLE = '${escHtml(bundle)} · ${escHtml(profile)}'
|
||||
const ESC_CLASS = `/[&<>"']/g`
|
||||
|
||||
// A copy of the plugin, altered or not, imported from beside the original so its relative imports resolve; the
|
||||
// file is removed as soon as it is loaded.
|
||||
let seq = 0
|
||||
async function load(src) {
|
||||
if (src === SRC) return import(pathToFileURL(PLUGIN).href)
|
||||
const tmp = join(HERE, 'plugin-gallery', `.tmp-check-dev-leaf-escape-${process.pid}-${++seq}.mjs`)
|
||||
writeFileSync(tmp, src)
|
||||
try { return await import(pathToFileURL(tmp).href) } finally { rmSync(tmp, { force: true }) }
|
||||
}
|
||||
|
||||
// The plugin's middleware, answering one GET: { status, body } ('next' = not answered here, Vite would).
|
||||
function client(mod) {
|
||||
let mw = null
|
||||
mod.autoSpaDevPlugin('').configureServer({ middlewares: { use: (f) => { if (!mw) mw = f } } })
|
||||
return (url) => new Promise((done) => {
|
||||
const res = { statusCode: 200, setHeader() {}, end: (b) => done({ status: res.statusCode, body: String(b ?? '') }) }
|
||||
try { mw({ url, method: 'GET', headers: {} }, res, () => done({ status: 'next', body: '' })) }
|
||||
catch (e) { done({ status: 'threw', body: String(e && e.message) }) }
|
||||
})
|
||||
}
|
||||
|
||||
// Every /__leaf/ URL the dev manifest advertises, with the tag its page must mount.
|
||||
function advertised(mod) {
|
||||
const out = []
|
||||
const walk = (o, tag) => {
|
||||
if (!o || typeof o !== 'object') return
|
||||
for (const v of Object.values(o)) {
|
||||
if (typeof v === 'string' && v.startsWith('/__leaf/')) out.push({ url: v.split(/[?#]/)[0], tag })
|
||||
else walk(v, tag)
|
||||
}
|
||||
}
|
||||
for (const b of mod.galleryModel(discoverSpaRegistry()).bundles) walk(b, (b.elements || [])[0])
|
||||
return [...new Map(out.map((x) => [x.url, x])).values()]
|
||||
}
|
||||
|
||||
const HOSTILE = ["a'b", 'a"b', 'a<b', 'a>b', 'a&b', 'a b', 'a`b', 'a/b', 'a\\b', '..', '.', "x'><img src=x onerror=alert(1)>"]
|
||||
|
||||
async function audit(src) {
|
||||
const f = []
|
||||
let mod
|
||||
try { mod = await load(src) } catch (e) { return [`load: the plugin does not import (${e.message})`] }
|
||||
const get = client(mod)
|
||||
const leaves = advertised(mod)
|
||||
if (leaves.length < 20) f.push(`A: only ${leaves.length} advertised leaves found (the manifest walk is broken)`)
|
||||
for (const { url, tag } of leaves) {
|
||||
for (const u of [url, url.replace('/__leaf/', '/') + '/index.html']) {
|
||||
const r = await get(u)
|
||||
if (r.status !== 200 || !r.body.includes(`<${tag} `)) f.push(`A: ${u} → ${r.status}${r.status === 200 ? `, no <${tag}>` : ''} (a real page refused)`)
|
||||
}
|
||||
}
|
||||
const [b0, p0] = (leaves[0] ? leaves[0].url : '/__leaf/x/x').slice('/__leaf/'.length).split('/')
|
||||
for (const h of HOSTILE) {
|
||||
const e = encodeURIComponent(h)
|
||||
for (const parts of [[e, p0], [b0, e], [b0, p0, e]]) {
|
||||
const leaf = '/__leaf/' + parts.join('/')
|
||||
const r = await get(leaf)
|
||||
if (r.status !== 400) f.push(`B: ${leaf} → ${r.status} (want 400)`)
|
||||
const embed = '/' + parts.join('/') + '/index.html'
|
||||
const r2 = await get(embed)
|
||||
if (r2.status !== 400 && r2.status !== 'next') f.push(`B: ${embed} → ${r2.status} (want 400, or left to Vite)`)
|
||||
}
|
||||
}
|
||||
const esc = mod.escHtml ? mod.escHtml(`&<>"'`) : null
|
||||
if (esc !== '&<>"'') f.push(`C: escHtml(& < > " ') = ${JSON.stringify(esc)}`)
|
||||
if (!src.includes(GUARD)) f.push('D: the leaf guard is not the LEAF_PART rule, so the title cannot be tested behind the old one')
|
||||
else {
|
||||
let loose
|
||||
try { loose = await load(src.replace(GUARD, OLD_GUARD)) } catch (e) { f.push(`D: ${e.message}`) }
|
||||
if (loose) {
|
||||
const r = await client(loose)(`/__leaf/${b0}/${encodeURIComponent('<b>x')}`)
|
||||
const title = (/<title>([^]*?)<\/title>/.exec(r.body) || [])[1]
|
||||
if (r.status !== 200 || title === undefined) f.push(`D: behind the old rule the page did not render (${r.status})`)
|
||||
else if (title.includes('<b>') || !title.includes('<b>')) f.push('D: behind the old rule a tag in the profile reached the <title> raw')
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
const poisons = [
|
||||
['the old guard (`..` and `/` only)', SRC.replace(GUARD, OLD_GUARD), /^B: /],
|
||||
['a rule too strict for a real profile', SRC.replace(RULE, 'export const LEAF_PART = /^[a-z]{1,63}$/'), /^A: /],
|
||||
['an unescaped <title>', SRC.replace(TITLE, '${bundle} · ${profile}'), /^D: /],
|
||||
['an escHtml that misses the quote', SRC.replace(ESC_CLASS, `/[&<>"]/g`), /^C: /],
|
||||
]
|
||||
let missed = 0
|
||||
for (const [name, src, rx] of poisons) {
|
||||
if (src === SRC) { console.log(`✗ MISSED: ${name} (not planted: its source line was not found)`); missed++; continue }
|
||||
const f = await audit(src); const hit = f.find((x) => rx.test(x))
|
||||
console.log(hit ? `✓ caught: ${name} -> ${hit}` : `✗ MISSED: ${name}${f.length ? ` (failed instead: ${f[0]})` : ''}`)
|
||||
if (!hit) missed++
|
||||
}
|
||||
if (missed) process.exitCode = 1
|
||||
} else {
|
||||
const f = await audit(SRC)
|
||||
if (f.length) { for (const x of f.slice(0, 40)) console.log('✗ ' + x); console.log(`FAIL check:dev-leaf-escape (${f.length})`); process.exitCode = 1 }
|
||||
else console.log(`PASS check:dev-leaf-escape — every advertised dev leaf is served in both URL forms; ${HOSTILE.length} hostile parts are refused in every position of both forms; the <title> escapes on its own`)
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ const STATIC = [
|
|||
['check:click-sheet', 'the country sheet resolves, links are absolute'],
|
||||
['check:snapshot-guard', 'no fetcher writes around the guard'],
|
||||
['check:gospel-tables-staged', 'every asset the map fetches is in the shipped tree'],
|
||||
['check:gospel-clear-filter-url', 'Show all drops only the filter keys, not a partner\'s'],
|
||||
]
|
||||
const BROWSER = [
|
||||
['check:leaves-mount', 'all five leaves actually mount'],
|
||||
|
|
|
|||
|
|
@ -68,11 +68,6 @@ const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
|
|||
// translations, add the locale here with its count and lower it as they arrive.
|
||||
// Never raise a number; never add a locale that is already complete.
|
||||
const DEBT = {
|
||||
// 2026-10-03, the Share menu's 10 new share.json keys (shareable pages P3): localization is English-only for now.
|
||||
// tStatic falls back to the English text, so these locales show English until the translations arrive;
|
||||
// lower each to 0 then, and empty the map again.
|
||||
// + the Embed tab's 9 keys (URL / Iframe / JavaScript, P3b), the same English-only rule.
|
||||
ar: 19, de: 19, es: 19, fr: 19, hi: 19, it: 19, pt: 19, ro: 19, ru: 19, zh: 19,
|
||||
}
|
||||
|
||||
const fails = []
|
||||
|
|
|
|||
|
|
@ -39,19 +39,7 @@ const m = code.match(/host\.value\s*=\s*([^\n]+)/)
|
|||
if (!m) {
|
||||
fails.push('the area resolution moved — this gate cannot find `host.value = …` and would pass blind.')
|
||||
} else {
|
||||
let expr = m[1]
|
||||
// The resolution may live in a named function (resolveHost(), shareable pages P3: a standalone Share button
|
||||
// re-resolves at open time). Then judge THAT function's body, and fail if it cannot be found.
|
||||
const fn = expr.match(/^\s*(\w+)\(\)/)
|
||||
if (fn) {
|
||||
const body = code.match(new RegExp('function\\s+' + fn[1] + '\\s*\\(\\)\\s*\\{([\\s\\S]*?)\\n\\}'))
|
||||
// every return that resolves a named area must carry its structural fallback on the SAME expression
|
||||
// a same-line comment is not a fallback: strip `//…` before testing each return
|
||||
const rets = body ? [...body[1].matchAll(/return\s+([^\n]*closest\([^\n]*)/g)].map((r) => r[1].replace(/\s*\/\/.*$/, '')) : []
|
||||
expr = rets.length && rets.every((r) => /offsetParent|parentElement|document\.body/.test(r)) ? rets.join(' ;; ') : (rets.find((r) => !/offsetParent|parentElement|document\.body/.test(r)) || '')
|
||||
if (!body) fails.push(`the area resolution calls ${fn[1]}(), and this gate cannot find that function to read it.`)
|
||||
else if (!rets.length) fails.push(`${fn[1]}() returns no named-area resolution, so this gate would pass blind.`)
|
||||
}
|
||||
const expr = m[1]
|
||||
if (!/offsetParent|parentElement|document\.body/.test(expr)) {
|
||||
fails.push('MapControlMenu resolves its anchor from a class allowlist with no structural fallback:\n'
|
||||
+ ` ${expr.trim()}\n`
|
||||
|
|
|
|||
|
|
@ -217,22 +217,6 @@ ok(!/<em>[^<]*<\/em><\/span>/.test(full.slice(full.indexOf('class="card"'))), 't
|
|||
ok(/10/.test(caps.join(' ')), 'the bounded tally does not state its unknown count — an un-inked category reads as zero in print')
|
||||
}
|
||||
|
||||
// ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03): a group with no record in a figure's data is named in
|
||||
// that figure's caption ("15 without data"), so each caption's parts add up to the list. Both ways: the
|
||||
// fixture's figures lack records (45 groups; 30 bounded, 24 centered), and complete data prints no such part.
|
||||
{
|
||||
const sum = (cap) => [...cap.matchAll(/\d[\d,]*/g)].reduce((a, m) => a + Number(m[0].replace(/,/g, '')), 0)
|
||||
const capsOf = (html) => [...html.matchAll(/class="set-cap">([^<]*)</g)].map((m) => m[1])
|
||||
const nd = String(strings.en.sets_no_data).replace('{n}', '').trim()
|
||||
const short = capsOf(full)
|
||||
ok(short.length === 2 && short.every((c) => sum(c) === 45 && c.includes(nd)),
|
||||
`each set caption should add up to the list's 45 groups, naming the ones ${nd}: ${short.join(' | ')}`)
|
||||
const whole = capsOf(R.buildPacket(zone, groups, new Map(), '<svg/>', funnel, christian, 'full', {
|
||||
bounded: groups.map((g, i) => ({ id: g.slug, reached: i % 2 === 0 })), centered: groups.map((g, i) => ({ id: g.slug, ring: (i % 10) + 1 })) }))
|
||||
ok(whole.length === 2 && whole.every((c) => sum(c) === 45 && !c.includes(nd)),
|
||||
`with a record for every group, each caption should add up to 45 and name none ${nd}: ${whole.join(' | ')}`)
|
||||
}
|
||||
|
||||
// ── THE RING LEGEND: THE AGREED VOCABULARY, AND ONLY THE RINGS THAT HOLD SOMEONE ────────────────
|
||||
// ★ The names are COMPOSED from band x culture rather than a flat 1..10 table, because "same
|
||||
// people" (provisional, pending a product decision) appears in rings 1, 4 AND 7 — three cells that a
|
||||
|
|
|
|||
|
|
@ -16,15 +16,13 @@
|
|||
* B. a cached copy that matches is used as is: ONE request, force-cache (the fast, offline-friendly path stays);
|
||||
* C. a server that also serves the wrong bytes → rejected, err.integrity, after one fresh refetch;
|
||||
* D. a file that is not served → rejected (not an integrity failure);
|
||||
* D2. a page without crypto.subtle (not secure: plain http by LAN address) → refused saying "secure page";
|
||||
* E. loadPacketStrings with a stale cached table → the full table in every language (not the English fallback);
|
||||
* F. loadIndiaStates with a stale cached table → India's 36 states (not a rejection);
|
||||
* G. generateSheet with a stale cached generator → a drawn sheet (not an integrity refusal);
|
||||
* H. wiring: every pinned loader fetches through fetchPinned with its own pin, and none fetches its pinned file
|
||||
* directly; the classic-script path refreshes the HTTP cache and retries a refused tag once.
|
||||
* `--self-test`: fetchPinned without its fresh refetch, one that never uses the cache, one that accepts a
|
||||
* mismatch, a hash that reaches for crypto.subtle unguarded, and a loader that fetches its pinned file directly must
|
||||
* each FAIL.
|
||||
* mismatch, and a loader that fetches its pinned file directly must each FAIL.
|
||||
* Two-sided by hand at the change: the previous loaders (the pilot before this) fail E, F and G.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
|
|
@ -90,12 +88,6 @@ async function auditFetchPinned(shaSrc) {
|
|||
{ world('https://d.test', { missing: [P] }); let err = null
|
||||
try { await fetchPinned(`https://d.test/${P}`, pin) } catch (e) { err = e }
|
||||
if (!err || err.integrity) fails.push(`D: an unserved file should reject as not served (${err ? err.message : 'it resolved'})`) }
|
||||
// D2: a page that is not secure (plain http by LAN address) has no crypto.subtle: refused in WORDS, not a bare TypeError
|
||||
{ world('https://e.test', { cached: { [P]: current } }); const realCrypto = globalThis.crypto; let err = null
|
||||
Object.defineProperty(globalThis, 'crypto', { value: {}, configurable: true, writable: true })
|
||||
try { await fetchPinned(`https://e.test/${P}`, pin) } catch (e) { err = e }
|
||||
finally { Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true, writable: true }) }
|
||||
if (!err || err.integrity || !/secure page/.test(err.message)) fails.push(`D2: on a page without crypto.subtle the refusal should say "secure page", got ${err ? JSON.stringify(err.message) : 'a resolve'}`) }
|
||||
globalThis.fetch = realFetch
|
||||
return fails
|
||||
}
|
||||
|
|
@ -154,7 +146,6 @@ if (process.argv.includes('--self-test')) {
|
|||
['fetchPinned without its fresh refetch', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['force-cache']) {")), /^A: /],
|
||||
['fetchPinned that never uses the cache', () => auditFetchPinned(shaSrc.replace(FOR, "for (const cache of ['reload', 'reload']) {")), /^B: /],
|
||||
['fetchPinned that accepts a mismatch', () => auditFetchPinned(shaSrc.replace(' if (got === pin) return text\n', ' return text\n')), /^C: /],
|
||||
['a hash that reaches for crypto.subtle unguarded', () => auditFetchPinned(shaSrc.replace(/ if \(!subtle\) throw [^\n]*\n/, '')), /^D2: /],
|
||||
['a loader that fetches its pinned file directly', () => auditWiring((f) => (f === 'library/packet/packetStrings.js'
|
||||
? read(f).replace('fetchPinned(url, PACKET_STRINGS_SHA384)', "(await fetch(url, { cache: 'force-cache' })).text()") : read(f))), /^H: library\/packet\/packetStrings\.js/],
|
||||
]
|
||||
|
|
@ -168,5 +159,5 @@ if (process.argv.includes('--self-test')) {
|
|||
} else {
|
||||
const f = [...await auditFetchPinned(shaSrc), ...await auditLoaders(), ...auditWiring(read)]
|
||||
if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:pinned-fetch-fresh (${f.length})`); process.exitCode = 1 }
|
||||
else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; a page that is not secure is told so in words; every pinned loader goes through fetchPinned')
|
||||
else console.log('PASS check:pinned-fetch-fresh — a stale cached copy of a pinned file is refetched once, fresh: page 1 still draws, the packet keeps its language, India keeps its states; a matching cached copy is used as is; wrong bytes are refused; every pinned loader goes through fetchPinned')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,499 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// check:view-address — the page's address (library/composables/useViewAddress.js) and its ONE history write.
|
||||
// A. the pure helpers: the id whitelist + 64 cap; an unknown or malformed value reads as absent (the default opens);
|
||||
// the level rule (a tab change drops pane and place unless the change sets them; pane and place are siblings);
|
||||
// foreign params keep their exact text.
|
||||
// B. the writes, on a simulated browser history: an 'app' owner pushes once and keeps history.state with `current`
|
||||
// and the hash; an unchanged address writes nothing; a partner page (no owner) writes nothing; a 'host' owner
|
||||
// writes nothing and hands the host one dxm-address event; nested elements announce dxm-view; Back/Forward is
|
||||
// only read, never written.
|
||||
// C. COEXISTENCE with the filters (useShareableView, which writes through the same commitUrl): a filter write keeps
|
||||
// tab/pane/place, the hash or the query it does not own, and the router's history.state; a tab/pane/place write
|
||||
// keeps the filters (query AND hash form); both survive Back and Forward, and re-applying the old tab after Back
|
||||
// writes nothing, so Forward still exists.
|
||||
// D. THE CENSUS of history writers, enumerated from the tree: every history.pushState/replaceState call in library/
|
||||
// and plugin-designs/ is the helper's or a listed writer. A new one fails until it is listed here with its reason.
|
||||
// E. ONE WRITER, NESTED: claimView writes for an owner and announces for anyone else (one event per action, view keys
|
||||
// only); ownAddress records an announcement only while it owns (and stops it there), so a pane and its selection
|
||||
// clicked together are ONE Back step.
|
||||
// F. pane-wrapper's opt-in address, on a simulated column: a link opens its pane without writing, also on a FRESH
|
||||
// load where the column is built before its layout (it waits for a height, measured in a browser); a click is a push
|
||||
// (the first pane is the default and is never written); a click's selection rides in the same push; where the
|
||||
// reader's scroll settles is a replace; Back/Forward scroll and never write; nested it announces; without the
|
||||
// opt-in it never writes; a kept-alive column shown again claims its pane back (a replace), unless the address
|
||||
// names one.
|
||||
// G. THE WIRING in Regions: regions-mfe opts its column in, owns its leaf, and carries the place with its pane;
|
||||
// region-dashboards claims ?place= (replace at first paint, push on a choice) and re-applies it on Back. And
|
||||
// each shell (Plugin Studio, the Research Gallery, Posterverse) records its nested composites' choices
|
||||
// (ownAddress) and releases the listener before any hot-update return.
|
||||
// --self-test: each poison below must FAIL its arm. Pure node: no Vite, no build, no browser.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
// The history writers that exist today besides the helper, by call count. Adding one means a row here with a reason.
|
||||
const KNOWN = {
|
||||
'library/composables/useViewAddress.js': [1, 'THE write (commitUrl)'],
|
||||
'library/utils/languageRequest.js': [1, '?lang= on a generated page only (MAP_OWNS_PAGE); keeps state and every other param'],
|
||||
'library/layouts/app-wrapper-material/index.html': [1, "the layout page's own #/<map> hash; a fragment-only URL keeps the query"],
|
||||
'plugin-designs/gospel-internet/src/useBoard.js': [1, 'its own keys on its own leaves (writeUrl); keeps state, foreign params and the hash'],
|
||||
}
|
||||
// SOURCE files only: gates and node scripts may simulate history. A write is `.pushState(` / `.replaceState(` (an aliased
|
||||
// history included) or a computed `history[… 'pushState' …]`.
|
||||
const WRITE = /\.(?:push|replace)State\s*\(|\[[^\]\n]*['"](?:push|replace)State['"]/g
|
||||
function census() {
|
||||
const out = execFileSync('git', ['grep', '--untracked', '-cE', '(push|replace)State', '--', 'library', 'plugin-designs',
|
||||
':!*.md', ':!*.mjs', ':!**/scripts/**', ':!**/test/**', ':!**/*.test.*', ':!**/*.spec.*'], { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const counts = {}
|
||||
for (const line of out.split('\n').filter(Boolean)) {
|
||||
const i = line.lastIndexOf(':')
|
||||
const file = line.slice(0, i)
|
||||
// -c counts LINES; count the calls themselves (a ternary writes twice on one line).
|
||||
const n = (readFileSync(join(ROOT, file), 'utf8').match(WRITE) || []).length
|
||||
if (n) counts[file] = n
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// ── a browser's history, simulated: entries + index, popstate on back/forward, write counters ──────────────────────
|
||||
function browser(href, state = null) {
|
||||
const entries = [{ url: new URL(href), state }]
|
||||
let i = 0
|
||||
const writes = { push: 0, replace: 0 }
|
||||
const listeners = new Set()
|
||||
const loc = {
|
||||
get href() { return entries[i].url.href },
|
||||
get pathname() { return entries[i].url.pathname },
|
||||
get search() { return entries[i].url.search },
|
||||
get hash() { return entries[i].url.hash },
|
||||
}
|
||||
const copy = (s) => (s == null ? s : JSON.parse(JSON.stringify(s)))
|
||||
const hist = {
|
||||
get state() { return entries[i].state },
|
||||
get length() { return entries.length },
|
||||
pushState(s, _, url) { writes.push++; entries.splice(i + 1); entries.push({ url: new URL(url, entries[i].url), state: copy(s) }); i++ },
|
||||
replaceState(s, _, url) { writes.replace++; entries[i] = { url: new URL(url, entries[i].url), state: copy(s) } },
|
||||
back() { if (i > 0) { i--; listeners.forEach((h) => h()) } },
|
||||
forward() { if (i < entries.length - 1) { i++; listeners.forEach((h) => h()) } },
|
||||
}
|
||||
globalThis.location = loc; globalThis.history = hist
|
||||
globalThis.addEventListener = (t, h) => t === 'popstate' && listeners.add(h)
|
||||
globalThis.removeEventListener = (t, h) => t === 'popstate' && listeners.delete(h)
|
||||
const at = () => loc.pathname + loc.search + loc.hash
|
||||
const q = () => new URLSearchParams(loc.search)
|
||||
return { loc, hist, writes, at, q, listeners }
|
||||
}
|
||||
const unset = () => { for (const k of ['location', 'history', 'addEventListener', 'removeEventListener']) delete globalThis[k] }
|
||||
|
||||
// A column of panes for pane-wrapper, simulated: each pane is 100 px tall, scrolling dispatches 'scroll'.
|
||||
class FakeEl extends EventTarget {
|
||||
constructor() { super(); this.className = ''; this.dataset = {}; this.children = []; this.parent = null; this.scrollTop = 0; this.clientHeight = FakeEl.h0 ?? 100; this.innerHTML = '' }
|
||||
appendChild(c) { c.parent = this; this.children.push(c); return c }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter((x) => x !== this) }
|
||||
// A pane's top is its index times the column's height: 0 while the column has no layout.
|
||||
get offsetTop() { return this.parent ? this.parent.children.filter((c) => c.className === 'pane').indexOf(this) * this.parent.clientHeight : 0 }
|
||||
querySelector(sel) {
|
||||
const id = /data-pane-id="([^"]+)"/.exec(sel)?.[1]
|
||||
const walk = (n) => { for (const c of n.children) { if (c.className === 'pane' && c.dataset.paneId === id) return c; const r = walk(c); if (r) return r } return null }
|
||||
return walk(this)
|
||||
}
|
||||
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')) }
|
||||
}
|
||||
const SPEC = { paneHint: false, panes: [{ id: 'list', label: 'List', at: [0, 0] }, { id: 'dashboard', label: 'Dashboard', at: [1, 0] }] }
|
||||
const settle = () => new Promise((r) => setTimeout(r, 125)) // just past the 120 ms debounce; an earlier-due timer always fires first
|
||||
|
||||
async function arms({ va, sv, counts, pw, src }, { skipF = false } = {}) {
|
||||
const fails = []
|
||||
const eq = (tag, got, want) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${tag}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
try {
|
||||
// ── A. pure helpers ──
|
||||
eq('A: a plain id is kept', va.cleanKey('regions-dashboard'), 'regions-dashboard')
|
||||
eq('A: a 64-char id is kept', va.cleanKey('a'.repeat(64)), 'a'.repeat(64))
|
||||
for (const bad of ['a'.repeat(65), '<b>', 'a b', '-lead', '../x', 'x?y', 'x#y', '']) {
|
||||
if (va.cleanKey(bad) !== '') fails.push(`A: the whitelist lets ${JSON.stringify(bad.slice(0, 20))} through`)
|
||||
}
|
||||
eq('A: an unknown or malformed value reads as absent', va.readViewKeys('?tab=a%20b&pane=%E0%A4%A&place=angola&tab=second'), { tab: '', pane: '', place: 'angola' })
|
||||
eq('A: a tab change drops pane and place', va.viewSearch('?tab=a&pane=b&place=c&lang=ar', { tab: 'd' }), '?tab=d&lang=ar')
|
||||
eq('A: a pane change keeps tab and place (siblings)', va.viewSearch('?tab=a&pane=b&place=c', { pane: 'e' }), '?tab=a&pane=e&place=c')
|
||||
eq('A: a place change keeps tab and pane', va.viewSearch('?tab=a&pane=b&place=c', { place: 'f' }), '?tab=a&pane=b&place=f')
|
||||
eq('A: a change that sets the deeper level keeps it', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'd', pane: 'p' }), '?tab=d&pane=p')
|
||||
eq('A: the same tab changes nothing', va.viewSearch('?tab=a&pane=b&place=c', { tab: 'a' }), '?tab=a&pane=b&place=c')
|
||||
eq('A: foreign params keep their exact text', va.viewSearch('?lang=ar&access=yes%7Cpartial&q=rice+%26+beans&tab=a', { tab: 'b' }), '?tab=b&lang=ar&access=yes%7Cpartial&q=rice+%26+beans')
|
||||
eq('A: removing the last key leaves no "?"', va.viewSearch('?tab=a', { tab: '' }), '')
|
||||
eq('A: urlOwner', ['app', 'host', 'App', true, undefined, 'partner'].map((o) => va.urlOwnerOf({ urlOwner: o })), ['app', 'host', '', '', '', ''])
|
||||
|
||||
// ── B. writes ──
|
||||
{
|
||||
const b = browser('https://x.test/deep-research?lang=ar#top', { back: '/', current: '/deep-research?lang=ar#top', position: 3 })
|
||||
const r = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r !== 'written' || b.writes.push !== 1) fails.push(`B: an app owner's tab change is not ONE push (${r}, ${b.writes.push} pushes)`)
|
||||
if (b.at() !== '/deep-research?tab=regions&lang=ar#top') fails.push(`B: the write lost the hash or a foreign param: ${b.at()}`)
|
||||
const s = b.hist.state || {}
|
||||
if (s.position !== 3 || s.back !== '/') fails.push(`B: history.state was not kept: ${JSON.stringify(s)}`)
|
||||
if (s.current !== b.at()) fails.push(`B: history.state.current is not the new URL (${s.current}): a Vue Router host loses the link`)
|
||||
const r2 = va.writeViewKeys({ tab: 'regions' }, { owner: 'app', push: true })
|
||||
if (r2 !== 'unchanged' || b.writes.push + b.writes.replace !== 1) fails.push(`B: an unchanged address wrote (${r2})`)
|
||||
if (va.commitUrl({}) !== false || b.writes.push + b.writes.replace !== 1) fails.push('B: commitUrl wrote an identical URL')
|
||||
// the same values in another order is unchanged too (a link written by someone else, a host's own params first)
|
||||
va.commitUrl({ search: '?owner=x&pane=map&tab=regions' })
|
||||
const n = b.writes.push + b.writes.replace
|
||||
const r3 = va.writeViewKeys({ pane: 'map' }, { owner: 'app', push: false })
|
||||
if (r3 !== 'unchanged' || b.writes.push + b.writes.replace !== n) fails.push(`B: the same keys in another order were rewritten (${r3}): ${b.at()}`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://partner.test/page?tab=theirs')
|
||||
for (const owner of ['', undefined, 'partner']) va.writeViewKeys({ tab: 'regions' }, { owner, push: true })
|
||||
if (b.writes.push + b.writes.replace) fails.push(`B: a partner page (no owner) was written ${b.writes.push + b.writes.replace}×`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://dx.test/deep-research?lang=ar')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.ADDRESS_EVENT, (e) => got.push(e))
|
||||
const r = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (b.writes.push + b.writes.replace) fails.push("B: a 'host' owner wrote history itself (the host's router must)")
|
||||
if (r !== 'announced' || got.length !== 1) fails.push(`B: a 'host' owner did not hand the host ONE ${va.ADDRESS_EVENT} (${r}, ${got.length})`)
|
||||
else {
|
||||
const e = got[0]
|
||||
if (!e.bubbles || !e.composed) fails.push(`B: ${va.ADDRESS_EVENT} cannot leave a shadow root (bubbles ${e.bubbles}, composed ${e.composed})`)
|
||||
if (e.detail?.keys?.tab !== 'posters' || e.detail?.push !== true || e.detail?.href !== '/deep-research?tab=posters&lang=ar') fails.push(`B: ${va.ADDRESS_EVENT} detail is wrong: ${JSON.stringify(e.detail)}`)
|
||||
}
|
||||
// the host has not navigated yet; once it has, the same choice again must not ask it to navigate twice
|
||||
b.hist.replaceState(b.hist.state, '', '/deep-research?tab=posters&lang=ar'); b.writes.replace--
|
||||
const r2 = va.writeViewKeys({ tab: 'posters' }, { owner: 'host', push: true, el })
|
||||
if (r2 !== 'unchanged' || got.length !== 1) fails.push(`B: a 'host' owner announced an unchanged address (${r2}, ${got.length} events)`)
|
||||
}
|
||||
{
|
||||
browser('https://x.test/p')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
va.announceView(el, { pane: 'dashboard' })
|
||||
if (va.announceView(el, { filters: 'x' }) !== false) fails.push('B: announceView accepts a key that is not tab/pane/place')
|
||||
if (got.length !== 1 || !got[0].bubbles || !got[0].composed || got[0].detail?.changes?.pane !== 'dashboard') fails.push(`B: a nested element's ${va.VIEW_EVENT} is wrong or cannot leave its shadow root`)
|
||||
}
|
||||
|
||||
// ── C. coexistence with the filters, and Back/Forward ──
|
||||
{
|
||||
const b = browser('https://x.test/board?tab=a&pane=b&place=c#top', { current: '/board?tab=a&pane=b&place=c#top', position: 2 })
|
||||
sv.writeFilterView({ access_level: 'yes' }, 'rice', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const q = b.q()
|
||||
if (q.get('tab') !== 'a' || q.get('pane') !== 'b' || q.get('place') !== 'c') fails.push(`C: a filter write (query) dropped tab/pane/place: ${b.at()}`)
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#top') fails.push(`C: a filter write (query) lost its own params or the hash: ${b.at()}`)
|
||||
if (b.hist.state?.position !== 2 || b.hist.state?.current !== b.at()) fails.push(`C: a filter write dropped the router's history.state or its current: ${JSON.stringify(b.hist.state)}`)
|
||||
if (b.writes.push) fails.push('C: a filter write pushed (filters replace)')
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/research?tab=a&pane=b')
|
||||
sv.writeFilterView({ region: 'asia' }, '', {})
|
||||
if (b.loc.search !== '?tab=a&pane=b' || !b.loc.hash.startsWith('#f=region:asia')) fails.push(`C: a filter write (hash) dropped tab/pane/place: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/board?access=yes&q=rice#f=region:asia')
|
||||
va.writeViewKeys({ tab: 'map' }, { owner: 'app', push: true })
|
||||
const q = b.q()
|
||||
if (q.get('access') !== 'yes' || q.get('q') !== 'rice' || b.loc.hash !== '#f=region:asia') fails.push(`C: a tab write dropped the filters: ${b.at()}`)
|
||||
va.writeViewKeys({ place: 'angola' }, { owner: 'app', push: true })
|
||||
if (b.q().get('access') !== 'yes' || b.loc.hash !== '#f=region:asia') fails.push(`C: a place write dropped the filters: ${b.at()}`)
|
||||
}
|
||||
{
|
||||
// A shell with the filters inside: first paint, a filter, a tab change, another filter, then Back and Forward.
|
||||
// The app re-applies the tab on popstate the way an owner does: it sets its tab, and its tab watcher writes.
|
||||
const b = browser('https://x.test/deep-research')
|
||||
const app = { tab: '' }
|
||||
const setTab = (t, push) => { app.tab = t; va.writeViewKeys({ tab: t }, { owner: 'app', push }) }
|
||||
const off = va.onAddressChange((keys) => setTab(keys.tab || 'home', true))
|
||||
setTab('a', false)
|
||||
sv.writeFilterView({ access_level: 'yes' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
setTab('b', true)
|
||||
sv.writeFilterView({ access_level: 'no' }, '', { mode: 'query', facetKeys: { access: 'access_level' } })
|
||||
const len = b.hist.length; const w = b.writes.push + b.writes.replace
|
||||
b.hist.back()
|
||||
if (b.q().get('tab') !== 'a' || b.q().get('access') !== 'yes') fails.push(`C: after Back the entry lost its tab or its filters: ${b.at()}`)
|
||||
if (app.tab !== 'a') fails.push('C: Back did not re-apply the tab')
|
||||
if (b.writes.push + b.writes.replace !== w) fails.push('C: re-applying the tab after Back WROTE history (Forward is lost)')
|
||||
b.hist.forward()
|
||||
if (b.hist.length !== len || b.q().get('tab') !== 'b' || b.q().get('access') !== 'no') fails.push(`C: after Forward the entry lost its tab or its filters, or Forward was gone: ${b.at()} (${b.hist.length}/${len})`)
|
||||
off()
|
||||
if (b.listeners.size) fails.push('C: onAddressChange off() left its popstate listener')
|
||||
}
|
||||
|
||||
// ── E. one writer, nested ──
|
||||
{
|
||||
const b = browser('https://x.test/p?tab=regions')
|
||||
const el = new EventTarget(); const got = []
|
||||
el.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const r = va.claimView({ pane: 'dashboard', place: 'angola', filters: 'x' }, { owner: '', el, push: true })
|
||||
if (b.writes.push + b.writes.replace) fails.push('E: a claim by an element that does not own the address WROTE history')
|
||||
if (r !== 'announced' || got.length !== 1) fails.push(`E: a non-owner's claim is not ONE announcement (${r}, ${got.length})`)
|
||||
else if (JSON.stringify(got[0].detail?.changes) !== JSON.stringify({ pane: 'dashboard', place: 'angola' }) || got[0].detail?.push !== true) fails.push(`E: an announcement carries more or less than the view keys: ${JSON.stringify(got[0].detail)}`)
|
||||
const r2 = va.claimView({ pane: 'dashboard' }, { owner: 'app', el })
|
||||
if (r2 !== 'written' || got.length !== 1 || b.writes.push !== 1) fails.push(`E: an owner's claim did not write it itself (${r2}, ${got.length} events)`)
|
||||
}
|
||||
{
|
||||
const b = browser('https://x.test/shell?tab=regions&lang=ar')
|
||||
const el = new EventTarget(); let owner = 'app'
|
||||
const off = va.ownAddress(el, () => owner)
|
||||
const fire = (changes, push = true) => { const e = new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes, push } }); el.dispatchEvent(e); return e }
|
||||
const e1 = fire({ pane: 'dashboard', place: 'angola' })
|
||||
if (b.writes.push !== 1 || b.at() !== '/shell?tab=regions&pane=dashboard&place=angola&lang=ar') fails.push(`E: an owner did not record a nested pane + place as ONE push: ${b.at()} (${b.writes.push} pushes)`)
|
||||
if (!e1.cancelBubble) fails.push('E: a recorded announcement went on bubbling (an outer owner could write it twice)')
|
||||
fire({ place: 'angola' })
|
||||
if (b.writes.push !== 1) fails.push('E: the selection claimed again after its pane click was a second Back step')
|
||||
owner = ''
|
||||
const e2 = fire({ place: 'benin' })
|
||||
if (b.writes.push !== 1 || b.writes.replace) fails.push('E: ownAddress wrote while the page has no owner')
|
||||
if (e2.cancelBubble) fails.push('E: an announcement an element does not own was stopped (the real owner further out never hears it)')
|
||||
owner = 'app'
|
||||
const before = b.at()
|
||||
fire({ tab: 'posters' })
|
||||
if (b.at() !== before) fails.push(`E: a nested announcement switched the OWNER's tab: ${b.at()}`)
|
||||
off(); fire({ place: 'benin' })
|
||||
if (b.writes.push !== 1) fails.push('E: ownAddress off() left its listener')
|
||||
b.hist.back()
|
||||
if (b.at() !== '/shell?tab=regions&lang=ar') fails.push(`E: Back after a pane + place click did not return in one step: ${b.at()}`)
|
||||
}
|
||||
|
||||
// ── F. pane-wrapper's address, on a simulated column (the slow arm: it waits out the scroll debounce) ──
|
||||
globalThis.document = { createElement: () => new FakeEl() }
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/regions-mfe?pane=dashboard&lang=ar')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
const col = host.children[0]
|
||||
await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: a link to ?pane=dashboard did not open that pane (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: opening a link wrote the address')
|
||||
c.scrollTo('list'); await settle()
|
||||
if (b.writes.push !== 1 || b.at() !== '/regions-mfe?lang=ar') fails.push(`F: a click on the first pane is not ONE push that removes ?pane= (the default is never written): ${b.at()}`)
|
||||
c.scrollTo('dashboard', { changes: { place: 'angola' } }); await settle()
|
||||
if (b.writes.push !== 2 || b.at() !== '/regions-mfe?pane=dashboard&place=angola&lang=ar') fails.push(`F: a click's pane and its selection are not ONE push: ${b.at()} (${b.writes.push} pushes)`)
|
||||
const w2 = b.writes.push + b.writes.replace
|
||||
b.hist.back(); await settle()
|
||||
if (col.scrollTop !== 0 || b.writes.push + b.writes.replace !== w2) fails.push(`F: Back did not show the address's pane, or it WROTE (scrollTop ${col.scrollTop}, ${b.at()})`)
|
||||
b.hist.forward(); await settle()
|
||||
if (col.scrollTop !== 100 || b.writes.push + b.writes.replace !== w2) fails.push(`F: Forward wrote, or showed the wrong pane (scrollTop ${col.scrollTop})`)
|
||||
const w = b.writes.push + b.writes.replace
|
||||
col.scrollTo({ top: 0 }); await settle()
|
||||
if (b.writes.push + b.writes.replace !== w + 1 || b.writes.push !== 2 || b.at() !== '/regions-mfe?place=angola&lang=ar') fails.push(`F: where the reader's scroll settles is not ONE replace (place kept): ${b.at()}`)
|
||||
c.destroy()
|
||||
if (b.listeners.size) fails.push('F: destroy() left its popstate listener')
|
||||
if (got.length) fails.push('F: an owner composite announced instead of writing')
|
||||
}
|
||||
if (!skipF) {
|
||||
// A FRESH LOAD: the column is built before it has a layout (measured in a browser), and a ResizeObserver's
|
||||
// FIRST callback comes after layout, already with the full height. The link must still open.
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() {} disconnect() { this.off = true } }
|
||||
const b = browser('https://x.test/country-posters-mfe?pane=dashboard&lang=ar')
|
||||
const host = new FakeEl()
|
||||
FakeEl.h0 = 0
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
FakeEl.h0 = undefined
|
||||
const col = host.children[0]
|
||||
col.dispatchEvent(new Event('scroll')); await settle()
|
||||
if (b.at() !== '/country-posters-mfe?pane=dashboard&lang=ar') fails.push(`F: before its layout, the column overwrote the link it has not opened yet: ${b.at()}`)
|
||||
col.clientHeight = 100; ros.forEach((r) => !r.off && r.cb([])); await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: a FRESH LOAD with ?pane=dashboard did not open that pane once the column had its layout (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: opening a link on a fresh load wrote the address')
|
||||
c.destroy()
|
||||
delete globalThis.ResizeObserver
|
||||
}
|
||||
if (!skipF) {
|
||||
// The same fresh load, where the observer NEVER fires again after layout (a pane's height arrived after the
|
||||
// column's last resize): the bounded rAF loop must still open the link.
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() {} disconnect() { this.off = true } }
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 5)
|
||||
const b = browser('https://x.test/upg-100-mfe?pane=dashboard')
|
||||
const host = new FakeEl()
|
||||
FakeEl.h0 = 0
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
FakeEl.h0 = undefined
|
||||
const col = host.children[0]
|
||||
col.clientHeight = 100; await settle()
|
||||
if (col.scrollTop !== 100) fails.push(`F: with an observer that never fires again, the bounded rAF did not open the link (scrollTop ${col.scrollTop})`)
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: the rAF-opened link wrote the address')
|
||||
c.destroy()
|
||||
delete globalThis.ResizeObserver; delete globalThis.requestAnimationFrame
|
||||
}
|
||||
if (!skipF) {
|
||||
// A KEPT-ALIVE column whose tab is hidden and shown again (ResizeObserver: no height, then height).
|
||||
const ros = []
|
||||
globalThis.ResizeObserver = class { constructor(cb) { this.cb = cb; ros.push(this) } observe() { this.cb([]) } disconnect() { this.off = true } }
|
||||
const b = browser('https://x.test/shell?tab=regions')
|
||||
const host = new FakeEl()
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => 'app' } })
|
||||
const col = host.children[0]
|
||||
const resize = async (hgt) => { col.clientHeight = hgt; ros.forEach((r) => !r.off && r.cb([])); await settle() }
|
||||
c.scrollTo('dashboard', null); await settle()
|
||||
if (b.at() !== '/shell?tab=regions&pane=dashboard') fails.push(`F: scrollTo(id, null) did not behave as scrollTo(id): ${b.at()}`)
|
||||
await resize(0)
|
||||
va.writeViewKeys({ tab: 'posters' }, { owner: 'app' }); va.writeViewKeys({ tab: 'regions' }, { owner: 'app' })
|
||||
const w = b.writes.push + b.writes.replace
|
||||
await resize(100)
|
||||
if (b.at() !== '/shell?tab=regions&pane=dashboard' || b.writes.push + b.writes.replace !== w + 1 || b.writes.replace < 1) fails.push(`F: a kept-alive column shown again did not claim its pane back with ONE replace: ${b.at()}`)
|
||||
await resize(0)
|
||||
col.scrollTop = 0
|
||||
va.commitUrl({ search: '?tab=regions&pane=dashboard' })
|
||||
const w2 = b.writes.push + b.writes.replace
|
||||
await resize(100)
|
||||
if (col.scrollTop !== 100 || b.writes.push + b.writes.replace !== w2) fails.push(`F: shown again with an address that names a pane, the column did not show it (or wrote): scrollTop ${col.scrollTop}`)
|
||||
c.destroy()
|
||||
if (!ros.every((r) => r.off)) fails.push('F: destroy() left its ResizeObserver')
|
||||
delete globalThis.ResizeObserver
|
||||
}
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/shell?tab=regions')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host, address: { owner: () => '' } })
|
||||
c.scrollTo('dashboard'); await settle()
|
||||
if (b.writes.push + b.writes.replace) fails.push('F: a NESTED composite wrote history')
|
||||
if (!got.length || got[0].detail?.changes?.pane !== 'dashboard' || got[0].detail?.push !== true) fails.push('F: a nested composite did not announce its pane to the owner')
|
||||
c.destroy()
|
||||
}
|
||||
if (!skipF) {
|
||||
const b = browser('https://x.test/plain?pane=dashboard')
|
||||
const host = new FakeEl(); const got = []
|
||||
host.addEventListener(va.VIEW_EVENT, (e) => got.push(e))
|
||||
const c = pw.buildPaneColumn(SPEC, { host })
|
||||
c.scrollTo('dashboard'); await settle(); c.scrollTo('list'); await settle()
|
||||
if (b.writes.push + b.writes.replace || got.length || host.children[0].scrollTop !== 0) fails.push('F: a column WITHOUT the address opt-in read or wrote the address')
|
||||
c.destroy()
|
||||
}
|
||||
delete globalThis.document
|
||||
} finally { unset(); delete globalThis.document }
|
||||
|
||||
// ── G. the wiring in Regions ──
|
||||
{
|
||||
const m = src.mfe, d = src.dash
|
||||
if (!/buildPaneColumn\(spec\.value, \{ host: stage\.value, address: \{ owner \} \}\)/.test(m)) fails.push('G: regions-mfe does not opt its column into the address')
|
||||
if (!/ownAddress\(stage\.value, owner\)/.test(m)) fails.push("G: regions-mfe does not record its panes' announcements on its own leaf (ownAddress)")
|
||||
if (!/go\('dashboard', \{ place: detail\.countrySlug \|\| detail\.regionSlug/.test(m)) fails.push('G: regions-mfe opens the dashboard without its place (two Back steps for one click)')
|
||||
if (!/claimView\(\{ place: currentSlug\(\) \}/.test(d)) fails.push('G: region-dashboards does not claim ?place=')
|
||||
if (!/claimPlace\(false\)/.test(d) || !/claimPlace\(true\)/.test(d)) fails.push('G: region-dashboards does not replace at first paint and push on a choice')
|
||||
if (!/onAddressChange\(onAddress\)/.test(d)) fails.push('G: region-dashboards does not re-apply ?place= on Back/Forward')
|
||||
if (!/new ResizeObserver\([\s\S]{0,400}else if \(!place\) claimPlace\(false\)/.test(d)) fails.push('G: a kept-alive region-dashboards shown again does not claim its place back')
|
||||
// A composite that opts its column in also owns its own leaf: it records its panes' choices (ownAddress) and
|
||||
// releases that listener on unmount, or a choice made inside it on its own leaf is never written.
|
||||
if (!Object.keys(src.opted || {}).length) fails.push('G: no composite opts its pane column into the address (the census of opted composites is empty)')
|
||||
for (const [f, t] of Object.entries(src.opted || {})) {
|
||||
if (!/ownAddress\(stage\.value, owner\)/.test(t)) fails.push(`G: ${f} opts its column in but does not record its panes' choices (ownAddress)`)
|
||||
else if (!/offOwn\?\.\(\)/.test(t)) fails.push(`G: ${f} never releases ownAddress`)
|
||||
}
|
||||
// Each shell that owns a page records its nested composites' choices, and releases the listener on unmount
|
||||
// BEFORE any hot-update early return (or a hot remount stacks a second one).
|
||||
for (const [f, t] of Object.entries(src.shells || {})) {
|
||||
if (!/this\._offOwn = ownAddress\(this\.\$el, \(\) => urlOwnerOf\(this\.cfg\)\)/.test(t)) { fails.push(`G: the shell ${f} does not record nested choices (ownAddress)`); continue }
|
||||
const bu = t.indexOf('beforeUnmount()')
|
||||
const rel = bu < 0 ? -1 : t.indexOf('this._offOwn()', bu)
|
||||
const hot = bu < 0 ? -1 : t.indexOf('if (import.meta.hot) return', bu)
|
||||
if (rel < 0 || (hot >= 0 && hot < rel)) fails.push(`G: the shell ${f} does not release ownAddress in beforeUnmount before any hot-update return`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── D. the census ──
|
||||
for (const [file, n] of Object.entries(counts)) {
|
||||
if (!KNOWN[file]) fails.push(`D: a new history writer: ${file} (${n}). Route it through useViewAddress (commitUrl), or list it in KNOWN with its reason`)
|
||||
else if (KNOWN[file][0] !== n) fails.push(`D: ${file} now has ${n} history writes, the census says ${KNOWN[file][0]}`)
|
||||
}
|
||||
for (const file of Object.keys(KNOWN)) if (!counts[file]) fails.push(`D: ${file} is listed as a writer but no longer writes: drop its row`)
|
||||
return fails
|
||||
}
|
||||
|
||||
const va = await import(join(ROOT, 'library/composables/useViewAddress.js'))
|
||||
const sv = await import(join(ROOT, 'library/composables/useShareableView.js'))
|
||||
const PW = 'library/layouts/pane-wrapper/pane-wrapper.js'
|
||||
const pw = await import(join(ROOT, PW))
|
||||
const SHELLS = ['plugin-studio/plugin-studio.vue', 'doxa-maps-spa/doxa-maps-spa.vue', 'posterverse/posterverse.vue']
|
||||
// Every composite that opts its pane column into the address, enumerated from the tree.
|
||||
const OPTED = execFileSync('git', ['grep', '--untracked', '-lE', 'buildPaneColumn\\([^)]*address:', '--', 'plugin-designs', 'library', ':!*.md'], { cwd: ROOT, encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
||||
const src = {
|
||||
mfe: readFileSync(join(ROOT, 'plugin-designs/regions-dashboard/regions-mfe.vue'), 'utf8'),
|
||||
dash: readFileSync(join(ROOT, 'plugin-designs/regions-dashboard/region-dashboards.vue'), 'utf8'),
|
||||
shells: Object.fromEntries(SHELLS.map((f) => [f, readFileSync(join(ROOT, 'plugin-designs', f), 'utf8')])),
|
||||
opted: Object.fromEntries(OPTED.map((f) => [f, readFileSync(join(ROOT, f), 'utf8')])),
|
||||
}
|
||||
const counts = census()
|
||||
const real = { va, sv, counts, pw, src }
|
||||
// A pane-wrapper with one line changed, loaded from a sibling file so its relative import still resolves.
|
||||
let nth = 0
|
||||
async function pwVariant(from, to) {
|
||||
const s = readFileSync(join(ROOT, PW), 'utf8')
|
||||
if (!s.includes(from)) throw new Error('poison anchor not found in pane-wrapper.js: ' + from)
|
||||
const f = join(ROOT, `library/layouts/pane-wrapper/.tmp-check-view-address-${process.pid}-${nth++}.js`)
|
||||
writeFileSync(f, s.replace(from, to))
|
||||
try { return await import(f) } finally { unlinkSync(f) }
|
||||
}
|
||||
let code = 0
|
||||
if (process.argv.includes('--self-test')) {
|
||||
// The old filter write, as it was: its own replaceState(null, …) of the whole URL.
|
||||
const oldFilterWrite = (fs, term, o = {}) => {
|
||||
const url = o.mode === 'query' ? sv.serializeQuery(fs, term, location.search, o) + location.hash : location.search + sv.serializeView(fs, term, o)
|
||||
history.replaceState(null, '', location.pathname + url); return true
|
||||
}
|
||||
const poisons = [
|
||||
['a write that drops history.state.current', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...history.state }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: history\.state\.current/],
|
||||
['a write when not the owner', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^B: a partner page/],
|
||||
['a tab change that keeps a stale place', { ...real, va: { ...va, viewSearch: (s, c) => { const k = { ...va.readViewKeys(s), ...Object.fromEntries(Object.entries(c).map(([a, b]) => [a, va.cleanKey(b)])) }; const f = s.replace(/^\?/, '').split('&').filter((p) => p && !va.VIEW_KEYS.includes(p.split('=')[0])); const all = [...va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`), ...f]; return all.length ? '?' + all.join('&') : '' } } }, /^A: a tab change drops pane and place/],
|
||||
['a whitelist with no length cap', { ...real, va: { ...va, cleanKey: (v) => (/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(v ?? '')) ? String(v) : '') } }, /^A: the whitelist lets/],
|
||||
['unchanged judged by the string, not the values', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); if (s === location.search) return 'unchanged'; return va.commitUrl({ search: s }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^B: the same keys in another order were rewritten/],
|
||||
['a write when the address is unchanged', { ...real, va: { ...va, writeViewKeys: (c, o) => { const s = va.viewSearch(location.search, c); history[o.push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: location.pathname + s + location.hash }, '', location.pathname + s + location.hash); return 'written' } } }, /^B: an unchanged address wrote|^C: re-applying the tab after Back WROTE/],
|
||||
["a 'host' owner that writes history itself", { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, owner: o.owner === 'host' ? 'app' : o.owner }) } }, /^B: a 'host' owner wrote/],
|
||||
['a dxm-address that cannot leave a shadow root', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'host') return va.writeViewKeys(c, o); const s = va.viewSearch(location.search, c); o.el.dispatchEvent(new CustomEvent(va.ADDRESS_EVENT, { bubbles: true, detail: { keys: va.readViewKeys(s), href: location.pathname + s, push: o.push } })); return 'announced' } } }, /^B: dxm-address cannot leave/],
|
||||
// the three coexistence poisons
|
||||
['a FILTER write that drops tab/pane/place (no foreign params kept)', { ...real, sv: { ...sv, writeFilterView: (fs, term, o = {}) => va.commitUrl(o.mode === 'query' ? { search: sv.serializeQuery(fs, term, '', o) } : { search: '', hash: sv.serializeView(fs, term, o) }) } }, /^C: a filter write \((query|hash)\) dropped tab\/pane\/place/],
|
||||
['the old filter write: its own replaceState(null) (the router state is wiped)', { ...real, sv: { ...sv, writeFilterView: oldFilterWrite } }, /^C: a filter write dropped the router's history\.state/],
|
||||
['a TAB write that drops the filters (foreign params and hash)', { ...real, va: { ...va, writeViewKeys: (c, o) => { if (o.owner !== 'app') return va.writeViewKeys(c, o); const k = va.readViewKeys(va.viewSearch(location.search, c)); const s = va.VIEW_KEYS.filter((x) => k[x]).map((x) => `${x}=${k[x]}`).join('&'); return va.commitUrl({ search: s ? '?' + s : '', hash: '' }, { push: o.push }) ? 'written' : 'unchanged' } } }, /^C: a (tab|place) write dropped the filters/],
|
||||
['a tab change written as a REPLACE (Back leaves the page)', { ...real, va: { ...va, writeViewKeys: (c, o) => va.writeViewKeys(c, { ...o, push: false }) } }, /^B: an app owner's tab change is not ONE push|^C: after Back/],
|
||||
['a popstate handler that writes the address', { ...real, va: { ...va, onAddressChange: (cb) => va.onAddressChange((k) => { cb(k); history.pushState(history.state, '', location.href) }) } }, /^C: re-applying the tab after Back WROTE|^C: after Forward/],
|
||||
// the census
|
||||
['a new history writer in the tree', { ...real, counts: { ...counts, 'plugin-designs/some-plugin/page.vue': 1 } }, /^D: a new history writer/],
|
||||
['useShareableView writing history itself again', { ...real, counts: { ...counts, 'library/composables/useShareableView.js': 1 } }, /^D: a new history writer: library\/composables\/useShareableView\.js/],
|
||||
['region-dashboards writing its #slug itself again', { ...real, counts: { ...counts, 'plugin-designs/regions-dashboard/region-dashboards.vue': 2 } }, /^D: a new history writer: plugin-designs\/regions-dashboard/],
|
||||
// P2: nested writes, pane-wrapper, the Regions wiring
|
||||
['the old level rule: a pane change drops place', { ...real, va: { ...va, viewSearch: (s, c) => va.viewSearch(s, 'pane' in c && !('place' in c) ? { ...c, place: '' } : c) } }, /^A: a pane change keeps tab and place/],
|
||||
['a claim by a non-owner that writes', { ...real, va: { ...va, claimView: (c, o) => va.writeViewKeys(c, { ...o, owner: 'app' }) } }, /^E: a claim by an element that does not own/],
|
||||
['an announcement that carries any key', { ...real, va: { ...va, claimView: (c, o) => (o.owner ? va.claimView(c, o) : (o.el.dispatchEvent(new CustomEvent(va.VIEW_EVENT, { bubbles: true, composed: true, detail: { changes: c, push: o.push } })), 'announced')) } }, /^E: an announcement carries more or less/],
|
||||
['ownAddress that writes with no owner', { ...real, va: { ...va, ownAddress: (el, g) => va.ownAddress(el, () => g() || 'app') } }, /^E: ownAddress wrote while the page has no owner/],
|
||||
['ownAddress that lets a recorded announcement bubble on', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a recorded announcement went on bubbling/],
|
||||
['pane-wrapper writing the default pane', { ...real, pw: await pwVariant("pane: id === column[0].id ? '' : id", 'pane: id') }, /^F: a click on the first pane/],
|
||||
["pane-wrapper writing a click's selection separately", { ...real, pw: await pwVariant('if (write && known(paneId)) claimPane(paneId, push, changes)', 'if (write && known(paneId)) { claimPane(paneId, push); if (Object.keys(changes).length) claimView(changes, { owner: address.owner(), el: host, push }) }') }, /^F: a click's pane and its selection are not ONE push/],
|
||||
['pane-wrapper pushing where a scroll settles', { ...real, pw: await pwVariant('claimPane(current(), false)', 'claimPane(current(), true)') }, /^F: where the reader's scroll settles/],
|
||||
['pane-wrapper writing on Back (the pane it is leaving)', { ...real, pw: await pwVariant("show(known(pane) ? pane : column[0].id, 'smooth')", "(claimPane(current(), true), show(known(pane) ? pane : column[0].id, 'smooth'))") }, /^F: Back did not show the address's pane, or it WROTE/],
|
||||
['pane-wrapper opening a link at build time only (before the column has a layout)', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''; if (known(linked)) show(linked, 'instant')") }, /^F: a FRESH LOAD with \?pane=dashboard did not open/],
|
||||
['pane-wrapper whose settle overwrites a link still waiting to open', { ...real, pw: await pwVariant('if (col.clientHeight && !pending) claimPane(current(), false)', 'if (!pending || true) claimPane(current(), false)') }, /^F: before its layout, the column overwrote the link/],
|
||||
['pane-wrapper retrying a pending link by rAF only when there is no observer', { ...real, pw: await pwVariant("if (pending && typeof requestAnimationFrame !== 'undefined') {", "if (pending && typeof ResizeObserver === 'undefined' && typeof requestAnimationFrame !== 'undefined') {") }, /^F: with an observer that never fires again/],
|
||||
['pane-wrapper ignoring a link', { ...real, pw: await pwVariant("let pending = known(linked) && linked !== column[0].id ? linked : ''", "let pending = ''") }, /^F: a link to \?pane=dashboard did not open/],
|
||||
['pane-wrapper writing without the opt-in', { ...real, pw: await pwVariant("=> address && claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address.owner(),", "=> claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address ? address.owner() : 'app',") }, /^F: a column WITHOUT the address opt-in/],
|
||||
['ownAddress that lets a nested element switch the tab', { ...real, va: { ...va, ownAddress: (el, g) => { const h = (e) => { const o = g(); if (o) { e.stopPropagation(); va.writeViewKeys(e.detail.changes, { owner: o, el, push: e.detail.push }) } }; el.addEventListener(va.VIEW_EVENT, h); return () => el.removeEventListener(va.VIEW_EVENT, h) } } }, /^E: a nested announcement switched the OWNER's tab/],
|
||||
['pane-wrapper not claiming its pane when shown again', { ...real, pw: await pwVariant("if (typeof ResizeObserver !== 'undefined') {", 'if (false) {') }, /^F: a kept-alive column shown again/],
|
||||
['pane-wrapper letting the screen beat an address that names a pane', { ...real, pw: await pwVariant('if (known(asked) && asked !== current()) show(asked, \'instant\')\n else if (!asked) claimPane', 'if (false) show(asked, \'instant\')\n else claimPane') }, /^F: shown again with an address that names a pane/],
|
||||
['pane-wrapper whose scrollTo(id, null) throws (the old default)', { ...real, pw: await pwVariant('const { write = true, push = true, changes = {} } = o || {}', 'const { write = true, push = true, changes = {} } = o === undefined ? {} : o') }, /^F: scrollTo\(id, null\)|^THREW: .*null/],
|
||||
['region-dashboards not claiming its place when shown again', { ...real, src: { ...src, dash: src.dash.replace('else if (!place) claimPlace(false)', 'else if (!place) void 0') } }, /^G: a kept-alive region-dashboards shown again/],
|
||||
['regions-mfe opening the dashboard without its place', { ...real, src: { ...src, mfe: src.mfe.replace("go('dashboard', { place: detail.countrySlug || detail.regionSlug || '' })", "go('dashboard')") } }, /^G: regions-mfe opens the dashboard without its place/],
|
||||
['regions-mfe without the address opt-in', { ...real, src: { ...src, mfe: src.mfe.replace('{ host: stage.value, address: { owner } }', '{ host: stage.value }') } }, /^G: regions-mfe does not opt its column in/],
|
||||
['a composite that opts in without ownAddress', { ...real, src: { ...src, opted: { ...src.opted, 'plugin-designs/x/x-mfe.vue': "column = buildPaneColumn(spec.value, { host: stage.value, address: { owner } })" } } }, /^G: plugin-designs\/x\/x-mfe\.vue opts its column in but does not record/],
|
||||
['no composite opted in at all', { ...real, src: { ...src, opted: {} } }, /^G: no composite opts its pane column/],
|
||||
['Plugin Studio without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace('this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))', 'this._offOwn = null') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not record/],
|
||||
['Plugin Studio releasing ownAddress after its hot-update return', { ...real, src: { ...src, shells: { ...src.shells, 'plugin-studio/plugin-studio.vue': src.shells['plugin-studio/plugin-studio.vue'].replace(' if (this._offOwn) { this._offOwn(); this._offOwn = null }\n if (import.meta.hot) return', ' if (import.meta.hot) return\n if (this._offOwn) { this._offOwn(); this._offOwn = null }') } } }, /^G: the shell plugin-studio\/plugin-studio\.vue does not release/],
|
||||
['the Research Gallery without ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'doxa-maps-spa/doxa-maps-spa.vue': src.shells['doxa-maps-spa/doxa-maps-spa.vue'].replace('this._offOwn = ownAddress(', 'this._offOwn = (') } } }, /^G: the shell doxa-maps-spa/],
|
||||
['Posterverse never releasing ownAddress', { ...real, src: { ...src, shells: { ...src.shells, 'posterverse/posterverse.vue': src.shells['posterverse/posterverse.vue'].replace('if (this._offOwn) { this._offOwn(); this._offOwn = null }', 'void 0') } } }, /^G: the shell posterverse\/posterverse\.vue does not release/],
|
||||
['region-dashboards not re-applying ?place= on Back', { ...real, src: { ...src, dash: src.dash.replace('onAddressChange(onAddress)', 'null') } }, /^G: region-dashboards does not re-apply/],
|
||||
]
|
||||
for (const [name, inp, rx] of poisons) {
|
||||
let f
|
||||
// Only a pane-wrapper poison needs the slow arm F; every other poison must fail its own, faster arm.
|
||||
try { f = await arms(inp, { skipF: !/^\/\^F|THREW/.test(String(rx)) }) } catch (e) { f = ['THREW: ' + e.message] }
|
||||
const ok = f.some((x) => rx.test(x))
|
||||
console.log(ok ? `✓ caught: ${name} -> ${f.find((x) => rx.test(x))}` : `✗ MISSED: ${name}${f.length ? ' (failed instead: ' + f[0] + ')' : ''}`)
|
||||
if (!ok) code = 1
|
||||
}
|
||||
} else {
|
||||
const f = await arms(real)
|
||||
if (f.length) { f.forEach((x) => console.log('FAIL ' + x)); code = 1 }
|
||||
else console.log(`PASS check:view-address — the id whitelist and the level rule; one push per tab change with history.state + current kept; nothing written when unchanged, on a partner page, or by a 'host' owner (one dxm-address instead); filters and tab/pane/place keep each other through writes, Back and Forward; nested choices announced and recorded by the one owner, a pane and its selection as one Back step; pane-wrapper's opt-in ?pane= (link, click, scroll, Back) and the Regions wiring; ${Object.keys(counts).length} history writers in the tree, all accounted for`)
|
||||
}
|
||||
process.exit(code)
|
||||
|
|
@ -925,10 +925,7 @@ function mapPageHtml(opts) {
|
|||
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
|
||||
: ''
|
||||
// Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js): this generated page is OURS, so its shell may write the
|
||||
// view address (?tab=…) for a reload and a sent link. Only the page's own config gets it, never an embed snippet
|
||||
// (embedCfg), so a partner's page is never written to; and a FRAMED page drops it at runtime (below).
|
||||
const pageConfig = { ...(DEST_CONFIG.qaWidget ? { ...config, qa: true } : config), urlOwner: 'app' }
|
||||
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
|
||||
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
|
||||
// instance whose config says provider:'maplibre' gets the MapLibre peer (token-
|
||||
// free, no Mapbox account) and SKIPS the token ladder entirely — the map mounts
|
||||
|
|
@ -1102,9 +1099,6 @@ ${apiScript}
|
|||
var slot = document.getElementById('slot');
|
||||
var status = document.getElementById('status');
|
||||
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
|
||||
// Framed (the iframe embed on a partner's page): READ the address, never write it. A push inside a frame adds
|
||||
// entries to the partner page's own Back button (the joint session history).
|
||||
try { if (window.self !== window.top) delete BASE_CONFIG.urlOwner } catch (e) { delete BASE_CONFIG.urlOwner }
|
||||
// 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.)
|
||||
|
|
|
|||
|
|
@ -145,18 +145,6 @@ export function galleryModel(reg) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── ONE PART OF A LEAF URL ───────────────────────────────────────────────────────────────────
|
||||
// /__leaf/<bundle>/<profile>[/<instance>] parts are written INTO the leaf page: its <title> and the
|
||||
// element's profile-config='…' attribute. Refusing only `..` and `/` let a quote or a tag through, so a
|
||||
// crafted link broke out of the attribute on a publicly reachable dev server. Every bundle folder,
|
||||
// profile id and instance id in the tree has this shape (the address keys' own pattern,
|
||||
// library/composables/useViewAddress.js), so anything else is refused before it reaches either sink;
|
||||
// it also rules out `..` and `/`. check:dev-leaf-escape proves both directions.
|
||||
export const LEAF_PART = /^[A-Za-z0-9][\w-]{0,63}$/
|
||||
|
||||
// For an HTML text node or a quoted attribute value.
|
||||
export const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
||||
|
||||
// ── THE PRODUCTION EMBED URL, ANSWERED BY THE DEV SERVER ─────────────────────────────────────
|
||||
// The manifest advertises, and a person copies, tree-relative embed URLs of the shape
|
||||
// `<bundle>/<profile>/index.html` and `<bundle>/<profile>/<instance>/index.html` — served in
|
||||
|
|
@ -209,16 +197,6 @@ export function leafRewrite(path, treeName = TREE_NAME) {
|
|||
return '/__leaf/' + segs.join('/')
|
||||
}
|
||||
|
||||
// urlOwner 'app' (library/composables/useViewAddress.js) for a dev page that is OURS, the rule generate-tree bakes
|
||||
// into the built leaf: its shell writes ?tab=, so a clicked tab survives a reload and a copied link opens it. A
|
||||
// FRAMED page drops it: a partner page may iframe a leaf (the iframe embed), and a history push from an iframe
|
||||
// lands in the top page's Back stack. (Plugin Studio mounts its rows directly; its panes are unowned by paneConfig.) Write the element unowned and put this classic script right after it; it runs
|
||||
// before the deferred module script upgrades the element. `<` is escaped: profile/instance come from the URL.
|
||||
export function ownIfTopScript(cfg = {}) {
|
||||
const owned = JSON.stringify(JSON.stringify({ ...cfg, urlOwner: 'app' })).replace(/</g, '\\u003c')
|
||||
return `<script>try{if(window.self===window.top)document.currentScript.previousElementSibling.setAttribute('profile-config',${owned})}catch(e){}</script>`
|
||||
}
|
||||
|
||||
export function autoSpaDevPlugin(token = '') {
|
||||
// One generator for both galleries (internal/generate-gallery.mjs), driven by the same build
|
||||
// config the built page bakes. What dev changes: the model comes from discovery, the live
|
||||
|
|
@ -359,8 +337,8 @@ export function autoSpaDevPlugin(token = '') {
|
|||
try { return decodeURIComponent(s) } catch { return '' }
|
||||
})
|
||||
const [bundle, profile, instance] = parts
|
||||
// These land in a filesystem-ish URL and in the page's HTML below: LEAF_PART shapes only.
|
||||
if (!bundle || !profile || parts.some(s => !LEAF_PART.test(s))) {
|
||||
// Path traversal guard: these land in a filesystem-ish URL below.
|
||||
if (!bundle || !profile || parts.some(s => !s || s.includes('..') || s.includes('/'))) {
|
||||
res.statusCode = 400; return res.end('bad leaf path')
|
||||
}
|
||||
let reg
|
||||
|
|
@ -387,8 +365,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// renders the SAME view here as in production rather than the bundle's default.
|
||||
// assetsBase too, as the built leaf's BASE_CONFIG carries it ("../../assets" there; root-absolute
|
||||
// here, because a /__leaf/ URL's depth differs from the tree's and the pool is served at /assets).
|
||||
const leafPc = instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' }
|
||||
const pc = JSON.stringify(leafPc)
|
||||
const pc = JSON.stringify(instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' })
|
||||
// The composition tier of THIS profile, read from the same discovery the gallery uses.
|
||||
// `nested-elements-mfe` (cap-4) is the StackLayout scroll-stack tier — see the style note
|
||||
// below. An unknown profile falls through to the fixed-viewport leaf, the safe default.
|
||||
|
|
@ -399,7 +376,7 @@ export function autoSpaDevPlugin(token = '') {
|
|||
res.setHeader('Cache-Control', 'no-cache')
|
||||
return res.end([
|
||||
'<!DOCTYPE html>',
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${escHtml(bundle)} · ${escHtml(profile)}</title>`,
|
||||
`<html lang="en"><head><meta charset="UTF-8" /><title>${bundle} · ${profile}</title>`,
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
||||
'<link rel="icon" href="data:,">',
|
||||
leafTok ? `<script>window.MAP_TOKEN=${JSON.stringify(leafTok)};</script>` : '',
|
||||
|
|
@ -453,7 +430,6 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// live 19-bundle tree. Passing the base makes a nested shell see exactly what the top
|
||||
// level sees. Elements that ignore tree-base simply drop an unknown attribute.
|
||||
`<${tag} tree-base="/__plugin-gallery" profile-config='${pc}'></${tag}>`,
|
||||
ownIfTopScript(leafPc),
|
||||
`<script type="module" src="/plugin-designs/${bundle}/index.js"></script>`,
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
@ -523,7 +499,6 @@ export function autoSpaDevPlugin(token = '') {
|
|||
// manifest-shaped model the built tree bakes (19 bundles / 30 profiles), so pointing the
|
||||
// studio at it gives dev the same tab list production has.
|
||||
'<plugin-studio tree-base="/__plugin-gallery"></plugin-studio>',
|
||||
ownIfTopScript(),
|
||||
'<script type="module" src="/plugin-designs/plugin-studio/index.js"></script>',
|
||||
'</body></html>',
|
||||
].filter(Boolean).join('\n'))
|
||||
|
|
|
|||
|
|
@ -12,42 +12,9 @@
|
|||
* posterDesign { family, name, size, mobileFriendly, labelKey?, instance? } or an array of them: the poster
|
||||
* design(s) this profile shows, listed by Poster Design's Styles. Carried as written, ONLY when present
|
||||
* (every other manifest keeps its bytes); check:poster-design-catalogue validates it.
|
||||
* posterDesignLabels GENERATED beside it: { <design name>: { <locale>: title } } for every block with a labelKey,
|
||||
* resolved here from library/i18n/locales/<locale>/<area>.json. Poster Design's bundle ships only the
|
||||
* catalogue areas its own code names, so a plugin's own area (upgMap, …) would be missing there and the card
|
||||
* would read English; carrying the titles in the manifest works for any area, with no Poster Design rebuild.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const LOCALES = join(dirname(fileURLToPath(import.meta.url)), '..', 'library', 'i18n', 'locales')
|
||||
// A catalogue key ("area.a.b") in every locale that has it: { en: '…', ar: '…' }; {} when no locale does.
|
||||
function catalogueTitles(key) {
|
||||
const [area, ...path] = String(key || '').split('.')
|
||||
const out = {}
|
||||
if (!area || !path.length || !existsSync(LOCALES)) return out
|
||||
for (const loc of readdirSync(LOCALES)) {
|
||||
const f = join(LOCALES, loc, `${area}.json`)
|
||||
if (!existsSync(f)) continue
|
||||
try {
|
||||
let o = JSON.parse(readFileSync(f, 'utf8'))
|
||||
for (const p of path) o = o && typeof o === 'object' ? o[p] : undefined
|
||||
if (typeof o === 'string' && o.trim()) out[loc] = o
|
||||
} catch { /* a locale file that does not parse names nothing */ }
|
||||
}
|
||||
return out
|
||||
}
|
||||
function posterDesignLabels(pd) {
|
||||
const out = {}
|
||||
for (const b of Array.isArray(pd) ? pd : [pd]) {
|
||||
if (b && typeof b.name === 'string' && typeof b.labelKey === 'string') {
|
||||
const titles = catalogueTitles(b.labelKey)
|
||||
if (Object.keys(titles).length) out[b.name] = titles
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left']
|
||||
|
||||
|
|
@ -115,7 +82,7 @@ export function profileMetaFields(dir, profileName) {
|
|||
const m = readProfileMeta(dir, profileName)
|
||||
// visibility is emitted ONLY when it is "public": absent means team, so every manifest in the
|
||||
// tree today keeps the bytes it has and nothing becomes visible by upgrading the generator.
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}), ...(m.posterDesign && Object.keys(posterDesignLabels(m.posterDesign)).length ? { posterDesignLabels: posterDesignLabels(m.posterDesign) } : {}) }
|
||||
return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}), ...(m.mobile ? { mobile: m.mobile } : {}), ...(m.posterDesign ? { posterDesign: m.posterDesign } : {}) }
|
||||
}
|
||||
|
||||
/** A bundle is production if ANY of its profiles says so. */
|
||||
|
|
|
|||
|
|
@ -14,11 +14,6 @@
|
|||
RIGHT of left-hand controls. The side is measured, so a right-to-left page that
|
||||
mirrors the toolbar mirrors the menu as well.
|
||||
• Width: as wide as fits between the toolbar and the opposite edge, capped at `width`.
|
||||
• A STANDALONE anchor (ShareButton in a page header, no map toolbar: `standalone` on the
|
||||
provided anchor) opens BELOW its button instead, toward the side with room, inside the
|
||||
VIEWPORT, at `width` (the maps' readable 340) or the viewport minus its edges, whichever is
|
||||
less. Where it lives and where it goes are resolved when it OPENS, and its coordinates are
|
||||
converted into the block that really contains it (see placeBeside).
|
||||
• One z tier for menus (--z-menu, default 3000); only one menu is open per map at a
|
||||
time; closes on Escape or on a tap on the bare map canvas (never on legend/search).
|
||||
|
||||
|
|
@ -100,17 +95,23 @@ const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]'
|
|||
function place() {
|
||||
const tb = toolbar?.el?.value, area = host.value
|
||||
if (!tb || !area) { style.value = {}; return }
|
||||
if (toolbar.standalone) return placeBeside(tb)
|
||||
const ar = area.getBoundingClientRect(), tr = tb.getBoundingClientRect()
|
||||
const onRight = (tr.left + tr.right) / 2 > (ar.left + ar.right) / 2
|
||||
const gap = 8, edge = 12
|
||||
const offset = onRight ? (ar.right - tr.left + gap) : (tr.right - ar.left + gap)
|
||||
const width = Math.max(200, Math.min(props.width, ar.width - offset - edge))
|
||||
const top = Math.max(0, tr.top - ar.top)
|
||||
// A menu never outgrows the MAP's walls. (A header button is not placed here: placeBeside bounds it by the
|
||||
// viewport, so it opens at natural height; measured on upg-100-mfe, a header band as the wall gave a 291px
|
||||
// menu a 69px scrolling body, BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED.)
|
||||
let maxHeight = Math.max(120, ar.height - top - edge)
|
||||
// "Fit inside the map" is the rule ONLY for a real map area. When ShareButton mounted in a
|
||||
// page HEADER, its fallback self-tagged that header band as the area (data-map-area=
|
||||
// "share-fallback", see ShareButton.bindAnchor) -- and this line then dutifully clamped the
|
||||
// menu INSIDE a ~56px band: measured on upg-100-mfe, a 291px share menu rendered with a 69px
|
||||
// scrolling body (BUG-SHARE-MENU-HAS-A-SCROLLBAR-SHOULD-BE-FULLY-EXPANDED). A menu must never
|
||||
// outgrow a MAP's walls, but a header band is not a wall -- nothing clips below it -- so the
|
||||
// fallback area bounds against the VIEWPORT instead and the menu opens at natural height.
|
||||
const isFallbackArea = area.getAttribute && area.getAttribute('data-map-area') === 'share-fallback'
|
||||
let maxHeight = isFallbackArea
|
||||
? Math.max(120, (window.innerHeight || 900) - tr.top - edge) // header host: the page below is available
|
||||
: Math.max(120, ar.height - top - edge) // map host: fit inside the map
|
||||
if (props.matchToolbar) maxHeight = Math.max(120, Math.min(maxHeight, tr.height))
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
style.value = {
|
||||
|
|
@ -122,45 +123,6 @@ function place() {
|
|||
}
|
||||
}
|
||||
|
||||
// ★ BESIDE ITS BUTTON, INSIDE THE VIEWPORT (a standalone anchor: a Share button in a page header). Below the
|
||||
// button, toward the side with room (a button right of centre opens leftward, its end edges aligned; RTL headers
|
||||
// mirror with no special case, because the side is measured), at the readable width the maps use, clamped inside
|
||||
// the viewport, as tall as its content up to the viewport's bottom edge.
|
||||
// ★ THE BLOCK THAT CONTAINS IT IS MEASURED, NOT ASSUMED. The menu is position:absolute, so its coordinates are
|
||||
// relative to its containing block, which the HOST's markup decides: an unpositioned wrapper hands it to the
|
||||
// header above. Measuring against one element and positioning against another is what sent the header menu far
|
||||
// to the left (the UPG-100 list page, 2026-10-03). So the target point is computed in viewport coordinates and converted into
|
||||
// the containing block's own (its padding edge, scrolled), whatever element that turns out to be.
|
||||
function placeBeside(tb) {
|
||||
const vw = document.documentElement.clientWidth || window.innerWidth || 1024
|
||||
const vh = window.innerHeight || 800
|
||||
const r = tb.getBoundingClientRect(), gap = 8, edge = 12
|
||||
const width = Math.min(props.width, Math.max(160, vw - 2 * edge))
|
||||
const onRight = (r.left + r.right) / 2 > vw / 2
|
||||
const x = Math.max(edge, Math.min(onRight ? r.right - width : r.left, vw - edge - width))
|
||||
const y = r.bottom + gap
|
||||
let maxHeight = Math.max(120, vh - y - edge)
|
||||
if (props.maxHeight) maxHeight = Math.min(maxHeight, props.maxHeight)
|
||||
const o = blockOrigin(el.value)
|
||||
style.value = {
|
||||
top: `${Math.round(y - o.y)}px`,
|
||||
left: `${Math.round(x - o.x)}px`,
|
||||
width: `${Math.round(width)}px`,
|
||||
maxHeight: `${Math.round(maxHeight)}px`,
|
||||
...(props.height ? { height: `${Math.round(Math.min(props.height, maxHeight))}px` } : {}),
|
||||
}
|
||||
}
|
||||
// The viewport position of the origin an absolutely positioned element is placed from: its offsetParent's padding
|
||||
// edge, scrolled. A static body (or none) means the initial containing block: the document origin.
|
||||
function blockOrigin(m) {
|
||||
const cb = m && m.offsetParent
|
||||
if (!cb || ((cb === document.body || cb === document.documentElement) && getComputedStyle(cb).position === 'static')) {
|
||||
return { x: -(window.scrollX || 0), y: -(window.scrollY || 0) }
|
||||
}
|
||||
const b = cb.getBoundingClientRect()
|
||||
return { x: b.left + cb.clientLeft - cb.scrollLeft, y: b.top + cb.clientTop - cb.scrollTop }
|
||||
}
|
||||
|
||||
// Only one menu open per map: opening this one closes any other.
|
||||
const OPEN_EVENT = 'doxa-map-menu-open'
|
||||
function onOtherOpened(e) { if (props.open && e.detail !== el.value) emit('close') }
|
||||
|
|
@ -177,7 +139,6 @@ const announceClose = () => { try { host.value?.dispatchEvent(new CustomEvent(PA
|
|||
|
||||
watch(() => props.open, async (open, was) => {
|
||||
if (!open) { if (was) announceClose(); return }
|
||||
if (toolbar?.standalone) host.value = resolveHost() // layout is known now: where the menu lives
|
||||
await nextTick()
|
||||
place()
|
||||
try { host.value?.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: el.value })) } catch (_) {}
|
||||
|
|
@ -195,27 +156,15 @@ function onClickOutside(e) {
|
|||
if (path.some(n => n?.classList?.contains?.('mapboxgl-canvas') || n?.classList?.contains?.('maplibregl-canvas'))) emit('close')
|
||||
}
|
||||
|
||||
// Where the menu lives (the Teleport target). A map control: the map area, else the toolbar's offset parent. A
|
||||
// standalone button: a named area if its host declares one, else its offset parent, kept in the button's own
|
||||
// (shadow) root so the menu keeps this root's styles; else the root's top element.
|
||||
function resolveHost() {
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
if (!tbEl) return null
|
||||
if (!toolbar.standalone) return tbEl.closest(AREA) || tbEl.offsetParent || tbEl.parentElement || null
|
||||
const root = tbEl.getRootNode(), mine = (n) => n && n.getRootNode() === root ? n : null
|
||||
let top = tbEl; while (top.parentElement) top = top.parentElement
|
||||
return mine(tbEl.closest(AREA)) || mine(tbEl.offsetParent) || (root === document ? document.body : top)
|
||||
}
|
||||
// The "one menu at a time" listener follows the host, which a standalone button re-resolves on every open.
|
||||
watch(host, (h, was) => { was?.removeEventListener(OPEN_EVENT, onOtherOpened); h?.addEventListener(OPEN_EVENT, onOtherOpened) })
|
||||
|
||||
onMounted(() => {
|
||||
// ★ NEVER null. A named area is still preferred -- it is the MAP's walls, and a menu must not outgrow
|
||||
// them -- but when a plugin has not tagged one, the toolbar's own offsetParent is the containing block
|
||||
// this menu is absolutely positioned against, so placing against it is geometrically correct by
|
||||
// definition rather than by convention. A plugin that adopts a class or data-map-area later simply
|
||||
// gets the better anchor; nothing has to be registered anywhere for the menu to be on screen.
|
||||
host.value = resolveHost()
|
||||
const tbEl = toolbar?.el?.value || null
|
||||
host.value = tbEl?.closest(AREA) || tbEl?.offsetParent || tbEl?.parentElement || null
|
||||
host.value?.addEventListener(OPEN_EVENT, onOtherOpened)
|
||||
window.addEventListener('resize', onResize)
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
ShareButton.vue — Non-technical share modal for the map toolbar.
|
||||
|
||||
Redesigned to match consumer-grade UX from YouTube, Spotify, Canva, and
|
||||
Google Maps. Two tabs, share FIRST (shareable pages P3):
|
||||
Google Maps. Two tabs:
|
||||
|
||||
1. "Share this page" — the link to exactly what the reader is looking at (the page's own
|
||||
address, with its tab, where the page owns its address) + one-click copy. It is what a
|
||||
person who presses Share expects: something to send to someone.
|
||||
2. "Embed" — for putting the map on ANOTHER website: what embedding is and why it helps, the
|
||||
multiplication steps, then the code with size presets.
|
||||
1. "Share Link" — clean URL display + one-click copy + social share icons
|
||||
2. "Add to Your Website" — friendly embed guidance with size presets
|
||||
|
||||
The word "iframe" never appears in any user-visible text. The embed tab
|
||||
leads with a plain-English description, then shows the code below.
|
||||
|
|
@ -33,9 +30,7 @@ import { tStatic } from '@map/i18n/index.js'
|
|||
const T = (key, en) => tStatic(key) || en
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { resolveShareUrl, resolvePublicBase, resolveEmbedBase } from '../../utils/embedBase.js'
|
||||
import { publicLeafUrl, publicScriptCode } from '../../utils/publicEmbed.js'
|
||||
import { urlOwnerOf } from '../../composables/useViewAddress.js'
|
||||
import { resolveShareUrl } from '../../utils/embedBase.js'
|
||||
import MapControlButton from './MapControlButton.vue'
|
||||
import MapControlMenu from './MapControlMenu.vue'
|
||||
|
||||
|
|
@ -56,7 +51,7 @@ useShadowStyles(`
|
|||
/* The ring itself rotates, so the colours travel around the button in every browser. */
|
||||
@keyframes share-ring-turn{to{transform:rotate(360deg);}}
|
||||
@media(prefers-reduced-motion:reduce){.share-wrap::before{animation:none;}}
|
||||
.share-steps{margin:0 0 12px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps{margin:10px 14px 6px;padding:0 0 0 15px;list-style:'- ';font:14px/1.55 system-ui,sans-serif;color:#c9d1d9;}
|
||||
.share-steps li{margin:0 0 3px;padding-inline-start:3px;}
|
||||
.share-steps li:last-child{font-weight:600;color:#92b195;}
|
||||
.share-pop.light .share-steps{color:#3b463d;}
|
||||
|
|
@ -353,13 +348,10 @@ const rootEl = ref(null)
|
|||
const isOpen = ref(false)
|
||||
const popoverEl = ref(null)
|
||||
const btnEl = ref(null)
|
||||
const activeTab = ref('page') // 'page' | 'embed'
|
||||
const pageUrl = ref('') // "Share this page": read when the menu opens (see pageLink)
|
||||
const activeTab = ref('link') // 'link' | 'embed'
|
||||
const linkCopied = ref(false)
|
||||
const embedCopied = ref(false)
|
||||
const embedSize = ref('medium') // 'small' | 'medium' | 'large'
|
||||
const embedKind = ref('url') // 'url' | 'iframe' | 'script': the Embed tab's three codes
|
||||
const scriptState = ref({ loading: false }) // the JavaScript code from the public manifest: { code } | { missing } | { failed }
|
||||
|
||||
const sizePresets = {
|
||||
small: { label: 'Small', height: 400 },
|
||||
|
|
@ -367,9 +359,6 @@ const sizePresets = {
|
|||
large: { label: 'Large', height: 800 }
|
||||
}
|
||||
|
||||
// The size names, each key written out so the catalogue gate sees it.
|
||||
const sizeLabel = (k) => ({ small: T('share.size_small', 'Small'), medium: T('share.size_medium', 'Medium'), large: T('share.size_large', 'Large') })[k] || k
|
||||
|
||||
/* ---- computed ---- */
|
||||
/* The standalone embed page this map is shared/iframed as — resolved by
|
||||
utils/embedBase.js (its header documents the full ladder). In short:
|
||||
|
|
@ -393,76 +382,18 @@ const shareUrl = computed(() => resolveShareUrl({
|
|||
profileConfig: unref(profileConfig) || null,
|
||||
}))
|
||||
|
||||
/* ★ EMBED THIS PLUGIN, FROM THE PUBLIC SITE (shareable pages P3b). The Embed tab's three codes are for the plugin
|
||||
whose Share was pressed (its own page; its own bundle + element), even nested in Plugin Studio, Deep Research or
|
||||
Posterverse, and they point at the tree's PUBLIC address (resolvePublicBase: the build's shareBaseUrl), never a dev
|
||||
server or the page's own host. With no public address configured, the tree this bundle loaded from stands in. */
|
||||
const embedBase = computed(() => resolvePublicBase(unref(profileConfig) || null) || resolveEmbedBase(unref(profileConfig) || null))
|
||||
const embedPathOf = () => props.embedPath || (unref(profileConfig) || {}).shareEmbedPath || ''
|
||||
// URL: the plugin's own generated page, and nothing else: never the app page it is shown in. With no base (the dev
|
||||
// server, where modules are not bundled) or no tree-relative path, it is '' and the tab shows the plain note.
|
||||
const leafUrl = computed(() => publicLeafUrl(embedBase.value, embedPathOf()))
|
||||
const embedSnippet = computed(() => {
|
||||
const h = sizePresets[embedSize.value].height
|
||||
return '<iframe src="' + leafUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
||||
return '<iframe src="' + shareUrl.value + '" width="100%" height="' + h + '" style="border:none; border-radius:12px;" allowfullscreen></iframe>'
|
||||
})
|
||||
// JavaScript: the plugin's embed.script from the public manifest, read once the Embed tab opens. "missing" means the
|
||||
// public site does not carry this plugin yet, so the tab shows a plain note instead of codes that would 404.
|
||||
function loadScriptCode() {
|
||||
if (scriptState.value.code || scriptState.value.loading) return
|
||||
scriptState.value = { loading: true }
|
||||
publicScriptCode(embedBase.value, embedPathOf()).then((r) => { scriptState.value = r })
|
||||
}
|
||||
const embedText = computed(() => embedKind.value === 'url' ? leafUrl.value
|
||||
: embedKind.value === 'iframe' ? embedSnippet.value : (scriptState.value.code || ''))
|
||||
const EMBED_KINDS = ['url', 'iframe', 'script']
|
||||
const kindLabel = (k) => ({ url: T('share.kind_url', 'URL'), iframe: T('share.kind_iframe', 'Iframe'), script: T('share.kind_script', 'JavaScript') })[k]
|
||||
// One plain sentence per code, for a reader who has never embedded anything.
|
||||
const kindHint = (k) => ({
|
||||
url: T('share.url_hint', 'A link to open the map on its own page.'),
|
||||
iframe: T('share.iframe_hint', 'Put the map in your page, in a frame.'),
|
||||
script: T('share.script_hint', 'Load the map straight into your page, with no frame.'),
|
||||
})[k]
|
||||
|
||||
/* ★ SHARE THIS PAGE. Where the page OWNS its address (useViewAddress: urlOwner 'app' on our own pages,
|
||||
'host' on DX Life), the address bar already names the exact view (the tab, and the filters), so the
|
||||
link is the page's own URL, read when the menu opens and again at copy time, since the reader can
|
||||
switch tabs in between. The owner is this map's own profile-config, or the nearest element above it,
|
||||
across shadow roots, whose profile-config names one: a map nested in the gallery is told nothing, and
|
||||
the gallery is the page. On a partner's page (no owner) the address is the partner's, so the link is
|
||||
this map's own page, as before. */
|
||||
const cfgOf = (c) => { if (typeof c !== 'string') return c || null; try { return JSON.parse(c) } catch { return null } }
|
||||
function pageOwner() {
|
||||
if (urlOwnerOf(cfgOf(unref(profileConfig)))) return true
|
||||
for (let n = rootEl.value; n; ) {
|
||||
n = n.parentNode || n.host
|
||||
if (n && n.nodeType === 1 && urlOwnerOf(cfgOf(n.profileConfig ?? n.getAttribute('profile-config')))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// A leaf page reads a token from ?tk= and leaves it in its address, so the page link drops it: a reader must
|
||||
// never send a credential by pressing Share. The URL is rewritten only when tk is there, so every other link is
|
||||
// the address exactly as it stands.
|
||||
function pageLink() {
|
||||
try {
|
||||
if (pageOwner() && window.location.href) {
|
||||
const u = new URL(window.location.href)
|
||||
if (u.searchParams.has('tk')) { u.searchParams.delete('tk'); return u.href }
|
||||
return window.location.href
|
||||
}
|
||||
} catch { /* no window */ }
|
||||
return shareUrl.value
|
||||
}
|
||||
|
||||
/* ---- actions ---- */
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
activeTab.value = 'page'
|
||||
pageUrl.value = pageLink()
|
||||
activeTab.value = 'link'
|
||||
linkCopied.value = false
|
||||
embedCopied.value = false
|
||||
embedKind.value = 'url'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -494,15 +425,14 @@ async function copyText(text) {
|
|||
}
|
||||
|
||||
async function copyLink() {
|
||||
pageUrl.value = pageLink()
|
||||
if (await copyText(pageUrl.value)) {
|
||||
if (await copyText(shareUrl.value)) {
|
||||
linkCopied.value = true
|
||||
setTimeout(() => { linkCopied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEmbed() {
|
||||
if (embedText.value && await copyText(embedText.value)) {
|
||||
if (await copyText(embedSnippet.value)) {
|
||||
embedCopied.value = true
|
||||
setTimeout(() => { embedCopied.value = false }, 2000)
|
||||
}
|
||||
|
|
@ -547,24 +477,39 @@ async function copyEmbed() {
|
|||
const providedToolbar = inject('mapToolbar', null)
|
||||
const selfAnchor = ref(null)
|
||||
// Re-provide UNCONDITIONALLY so the value is stable for the child's inject(); `el` stays null when
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead. `standalone` tells
|
||||
// MapControlMenu this anchor is ONE button outside any map (a page header), placed beside the button
|
||||
// inside the viewport, at open time (MapControlMenu.placeBeside).
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor, standalone: true })
|
||||
// a real toolbar was found, and MapControlMenu keeps using that toolbar instead.
|
||||
provide('mapToolbar', providedToolbar || { el: selfAnchor })
|
||||
const hasOwnAnchor = !providedToolbar
|
||||
|
||||
/*
|
||||
★ THE ANCHOR IS THE BUTTON, AND NOTHING IS DECIDED AT RENDER TIME (shareable pages P3). This used to tag
|
||||
`el.offsetParent || el.parentElement` as the menu's area DURING RENDER (a function ref, so it ran before
|
||||
MapControlMenu latched its area). Before layout offsetParent is null, so the tag fell on the button's
|
||||
immediate parent: in a header with a narrow, unpositioned wrapper (measured on the UPG-100 list page: .hdr-btns) the menu
|
||||
measured against that wrapper, clamped to its 200px floor, and was positioned against the header above
|
||||
it: far to the left, and narrow (reported 2026-10-03). Now the button only names itself;
|
||||
MapControlMenu resolves where the menu lives and where it goes when it OPENS, with layout known.
|
||||
★ THIS IS A FUNCTION REF, AND onMounted WOULD NOT HAVE WORKED — the hazard is worth naming,
|
||||
because the fix silently does NOTHING if it is written the obvious way.
|
||||
|
||||
MapControlMenu latches its area ONCE, in its own onMounted:
|
||||
host.value = toolbar?.el?.value?.closest(AREA) || null
|
||||
and place() bails forever after if that is null. MapControlMenu is a CHILD of this component, and
|
||||
Vue runs a child's mounted hook BEFORE its parent's. So an onMounted here would set the anchor
|
||||
one tick too late: the child would have already read `null`, latched `host = null`, and no later
|
||||
assignment would revive it — the menu would go on opening at its static position and the fix
|
||||
would look applied while changing nothing.
|
||||
|
||||
A function ref is invoked during RENDER, before any mounted hook on either component, so the
|
||||
anchor and the area tag are in place by the time the child looks for them.
|
||||
|
||||
`data-map-area` is what MapControlMenu.AREA finds via closest(). It is tagged on the OFFSET
|
||||
PARENT, not on .share-wrap: the wrapper is a 32px button, and an area that small would clamp the
|
||||
menu to its 200px minimum jammed against the edge. The offset parent is the top bar, which spans
|
||||
the row, so the menu has the full width to open leftward into.
|
||||
*/
|
||||
function bindAnchor(el) {
|
||||
rootEl.value = el || null
|
||||
selfAnchor.value = hasOwnAnchor && el ? el : null
|
||||
if (!hasOwnAnchor || !el) { if (!el) selfAnchor.value = null; return }
|
||||
const area = el.offsetParent || el.parentElement
|
||||
// Never re-tag a host that already declares itself an area — that host owns the decision.
|
||||
if (area && !area.closest('.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]')) {
|
||||
area.setAttribute('data-map-area', 'share-fallback')
|
||||
}
|
||||
selfAnchor.value = el
|
||||
}
|
||||
|
||||
/* Placement, stacking, outside-click and Escape live in MapControlMenu (shared by every
|
||||
|
|
@ -572,8 +517,8 @@ function bindAnchor(el) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- :ref is a FUNCTION, deliberately: the anchor must be set before MapControlMenu's mounted hook
|
||||
reads it (a child mounts before its parent). See bindAnchor() in <script>. -->
|
||||
<!-- :ref is a FUNCTION, deliberately — it must run during render, before MapControlMenu's
|
||||
mounted hook latches its area. See bindAnchor() in <script> for why onMounted is too late. -->
|
||||
<div :ref="bindAnchor" class="share-wrap" :class="{ 'share-wrap--rounded-square': shape === 'rounded-square' }" data-share-root>
|
||||
<MapControlButton
|
||||
ref="btnEl"
|
||||
|
|
@ -598,26 +543,29 @@ function bindAnchor(el) {
|
|||
<!-- =================== Menu (shared MapControlMenu owns placement + chrome) =================== -->
|
||||
<MapControlMenu :open="isOpen" :title="t('buttons.share')" :is-dark="isDark" @close="close">
|
||||
<div ref="popoverEl" class="share-pop" :class="{ light: !isDark }">
|
||||
<!-- Tabs: sharing the PAGE first (what a person pressing Share expects), Embed second -->
|
||||
<div class="share-tabs" role="tablist">
|
||||
<!-- The multiplication path — one line each, top of the menu -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<!-- Tabs -->
|
||||
<div class="share-tabs">
|
||||
<button
|
||||
class="share-tab" role="tab" data-share-tab="page"
|
||||
:class="{ active: activeTab === 'page' }" :aria-selected="String(activeTab === 'page')"
|
||||
@click="activeTab = 'page'"
|
||||
>{{ T('share.tab_page', 'Share this page') }}</button>
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'link' }"
|
||||
@click="activeTab = 'link'"
|
||||
>{{ T('share.embed_link', 'Embed link') }}</button>
|
||||
<button
|
||||
class="share-tab" role="tab" data-share-tab="embed"
|
||||
:class="{ active: activeTab === 'embed' }" :aria-selected="String(activeTab === 'embed')"
|
||||
@click="activeTab = 'embed'; loadScriptCode()"
|
||||
>{{ T('share.tab_embed', 'Embed') }}</button>
|
||||
class="share-tab"
|
||||
:class="{ active: activeTab === 'embed' }"
|
||||
@click="activeTab = 'embed'"
|
||||
>{{ T('share.add_to_website', 'Add to Your Website') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 1: Share this page ===== -->
|
||||
<div v-if="activeTab === 'page'" class="share-body">
|
||||
<div class="share-embed-desc">{{ T('share.page_hint', 'A link that opens this exact page for whoever you send it to.') }}</div>
|
||||
<!-- ===== Tab 1: Share Link ===== -->
|
||||
<div v-if="activeTab === 'link'" class="share-body">
|
||||
<!-- URL display -->
|
||||
<div class="share-url-box">
|
||||
<div class="share-url-text" data-share-url>{{ pageUrl }}</div>
|
||||
<div class="share-url-text">{{ shareUrl }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy Link primary CTA -->
|
||||
|
|
@ -638,72 +586,50 @@ function bindAnchor(el) {
|
|||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ linkCopied ? T('share.copied', 'Copied!') : T('share.copy_link', 'Copy link') }}
|
||||
{{ linkCopied ? 'Copied!' : 'Copy Link' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Tab 2: Embed (for another website): THIS plugin, from the public site ===== -->
|
||||
<!-- ===== Tab 2: Add to Your Website ===== -->
|
||||
<div v-if="activeTab === 'embed'" class="share-body">
|
||||
<!-- What embedding is and why it helps, in plain words, before any code -->
|
||||
<!-- Friendly description -->
|
||||
<div class="share-embed-desc">
|
||||
{{ T('share.embed_what', 'Embedding puts this live map inside your own website, so your visitors can explore it and pray without leaving your site. It stays up to date by itself.') }}
|
||||
{{ T('share.embed_paste_hint', 'Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).') }}
|
||||
</div>
|
||||
<!-- The multiplication path, one line each -->
|
||||
<ul class="share-steps">
|
||||
<li v-for="(step, i) in tm('buttons.shareSteps')" :key="i">{{ rt(step) }}</li>
|
||||
</ul>
|
||||
<!-- Not on the public site yet (or no page of its own to point at): one plain note instead of codes that would
|
||||
404 or that would embed the app around it -->
|
||||
<div v-if="scriptState.missing || !leafUrl" class="share-embed-desc" data-embed-note>
|
||||
{{ T('share.not_public', "This map isn't on the public site yet. Its embed codes will appear here once it's released.") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Three ways, one at a time: a link, a frame, or straight into the page -->
|
||||
<div class="share-size-pills" role="tablist">
|
||||
<button
|
||||
v-for="k in EMBED_KINDS" :key="k" type="button" role="tab"
|
||||
class="share-size-pill" :data-embed-kind="k"
|
||||
:class="{ active: embedKind === k }" :aria-selected="String(embedKind === k)"
|
||||
@click="embedKind = k"
|
||||
>{{ kindLabel(k) }}</button>
|
||||
</div>
|
||||
<div class="share-embed-desc">{{ kindHint(embedKind) }}</div>
|
||||
<!-- Iframe: its height -->
|
||||
<div v-if="embedKind === 'iframe'" class="share-size-pills">
|
||||
<button
|
||||
v-for="(preset, key) in sizePresets"
|
||||
:key="key"
|
||||
class="share-size-pill"
|
||||
:class="{ active: embedSize === key }"
|
||||
@click="embedSize = key"
|
||||
>{{ sizeLabel(key) }}</button>
|
||||
</div>
|
||||
<!-- JavaScript: read from the public site when this tab opens -->
|
||||
<div v-if="embedKind === 'script' && !scriptState.code" class="share-embed-desc">
|
||||
{{ scriptState.failed ? T('share.public_failed', "Couldn't reach the public site just now. Try again in a moment.") : T('share.script_loading', 'Reading the public site…') }}
|
||||
</div>
|
||||
<div v-else class="share-code-box" data-embed-code>{{ embedText }}</div>
|
||||
<!-- One Copy button -->
|
||||
|
||||
<!-- Size presets -->
|
||||
<div class="share-size-pills">
|
||||
<button
|
||||
v-if="embedText"
|
||||
class="share-secondary-btn"
|
||||
:class="{ copied: embedCopied }"
|
||||
@click="copyEmbed"
|
||||
>
|
||||
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ embedCopied ? T('share.copied', 'Copied!') : (embedKind === 'url' ? T('share.copy_link', 'Copy link') : T('share.copy_code', 'Copy code')) }}
|
||||
</button>
|
||||
</template>
|
||||
v-for="(preset, key) in sizePresets"
|
||||
:key="key"
|
||||
class="share-size-pill"
|
||||
:class="{ active: embedSize === key }"
|
||||
@click="embedSize = key"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Code box -->
|
||||
<div class="share-code-box">{{ embedSnippet }}</div>
|
||||
|
||||
<!-- Copy Code button -->
|
||||
<button
|
||||
class="share-secondary-btn"
|
||||
:class="{ copied: embedCopied }"
|
||||
@click="copyEmbed"
|
||||
>
|
||||
<svg v-if="!embedCopied" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ embedCopied ? 'Copied!' : 'Copy Code' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MapControlMenu>
|
||||
|
|
|
|||
|
|
@ -36,15 +36,6 @@ const esc = (s) => String(s ?? '')
|
|||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
// A sheet the host GENERATES (opts.sheet) that fails still falls through to the stored one, so a print never comes
|
||||
// out blank. It must not fall through SILENTLY: a stored sheet with an old header once printed in place of the
|
||||
// generated one, and nothing said why. The console names the sheet and the reason. An empty result is not a
|
||||
// failure: a generator returns '' on purpose when it cannot draw that sheet.
|
||||
const generated = (draw, id, format) => Promise.resolve().then(draw).catch((e) => {
|
||||
try { console.warn(`[posterPrints] the ${format} sheet for ${id} could not be generated, so the stored sheet prints instead: ${e?.message || e}`); } catch (_) { /* no console */ }
|
||||
return '';
|
||||
});
|
||||
|
||||
/**
|
||||
* THE PRINT STANDARD — one call gives any map the poster, the card deck, the prayer list and the
|
||||
* prayer packet (full or lightweight) for ANY item: a zone, a country, or a custom list of people.
|
||||
|
|
@ -176,7 +167,7 @@ export function usePosterPrints(opts = {}) {
|
|||
// that cannot generate, and a generation that fails falls through to them rather than to nothing.
|
||||
if (opts.sheet) {
|
||||
set(item, kind, 'render', tr('wall.hud.generatingPoster', 'generating the poster'), 35);
|
||||
const html = await generated(() => opts.sheet(item, 'letter'), id, 'letter');
|
||||
const html = await Promise.resolve().then(() => opts.sheet(item, 'letter')).catch(() => '');
|
||||
if (html) {
|
||||
set(item, kind, 'save', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
|
||||
await deliver(html, item, kind);
|
||||
|
|
@ -422,7 +413,7 @@ export function usePosterPrints(opts = {}) {
|
|||
const mapId = item && 'mapId' in item ? item.mapId : item?.id;
|
||||
if (!mapSvg && opts.sheet && mapId) {
|
||||
const target = mapId === item?.id ? item : { ...item, id: mapId };
|
||||
const html = await generated(() => opts.sheet(target, 'packet'), mapId, 'packet');
|
||||
const html = await Promise.resolve().then(() => opts.sheet(target, 'packet')).catch(() => '');
|
||||
if (html) mapSvg = R.liftMap(html, { labelFor: await mapLabelFor() });
|
||||
}
|
||||
if (!mapSvg && opts.mapImage) {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@
|
|||
* stays unconditional, because restoring a link someone sent must work everywhere.
|
||||
*/
|
||||
import { watch, onMounted } from 'vue'
|
||||
import { commitUrl } from './useViewAddress.js'
|
||||
|
||||
const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
|
||||
// Minimal escaping, maximal legibility: reserve our delimiters + %, keep spaces as `+` (the
|
||||
|
|
@ -178,25 +177,17 @@ export function parseQuery(search, facetKeys = [], { searchKey = 'q' } = {}) {
|
|||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter write. It goes through useViewAddress's commitUrl, the page's ONE history write, so it can never undo
|
||||
* a tab/pane/place write and keeps history.state (+ `current`) on a Vue Router host. It used to write
|
||||
* replaceState(null, …) itself, which wiped the router's state there. Query mode replaces only its own params; hash
|
||||
* mode replaces only the hash. Everything else in the URL stays. Exported so check:view-address drives this exact
|
||||
* write. Returns whether it wrote.
|
||||
*/
|
||||
export function writeFilterView(filterSet, searchTerm, options = {}) {
|
||||
try {
|
||||
if (typeof location === 'undefined') return false
|
||||
return options.mode === 'query'
|
||||
? commitUrl({ search: serializeQuery(filterSet, searchTerm, location.search, options) })
|
||||
: commitUrl({ hash: serializeView(filterSet, searchTerm, options) })
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
export function useShareableView(bus, options = {}) {
|
||||
const { mode = 'hash', write: shouldWrite = true, facetKeys } = options
|
||||
const push = () => { if (shouldWrite) writeFilterView(bus.filterSet, bus.searchTerm?.value, options) }
|
||||
const push = () => {
|
||||
try {
|
||||
if (typeof location === 'undefined' || !shouldWrite) return
|
||||
const url = mode === 'query'
|
||||
? serializeQuery(bus.filterSet, bus.searchTerm?.value, location.search, { ...options, facetKeys }) + location.hash
|
||||
: location.search + serializeView(bus.filterSet, bus.searchTerm?.value, options)
|
||||
history.replaceState(null, '', location.pathname + url)
|
||||
} catch (_) { /* sandboxed */ }
|
||||
}
|
||||
watch(() => ({ ...bus.filterSet, __q: bus.searchTerm?.value }), push, { deep: true })
|
||||
onMounted(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
/**
|
||||
* useViewAddress.js — the page's ADDRESS for the view a reader is on: which app tab, which view inside it, which
|
||||
* place. One shared vocabulary and ONE history writer, so a tab, a view or a selection can be reloaded and sent as a
|
||||
* link on every host, while a partner's page is never written to.
|
||||
*
|
||||
* ★ THE SCHEME (query, not hash: a hash is a host page's anchor, and filters already live there).
|
||||
* ?tab=<app tab>&pane=<view inside the tab>&place=<selection>
|
||||
* Levels: changing `tab` drops `pane` and `place`, which belong to the tab. `pane` and `place` are siblings: a
|
||||
* composite keeps its selection while the reader looks at another of its panes (Regions keeps the region while the
|
||||
* reader is on the list), so the address keeps it too. Every other parameter (a host's
|
||||
* own, the filters useShareableView writes) and the hash are kept as they are. A value that is not a plain id
|
||||
* (letters, digits, `-`, `_`, at most 64) reads as absent, so a stale or hand-edited link falls back to the default.
|
||||
* `pane`, not `view`: `?view=` is already published by four of our own pages (gospel-internet's board/map, Gospel
|
||||
* Resources' map ?view=zero, pplr-data-maps and upg-neighbor lenses), and one of them sits inside a shell tab.
|
||||
*
|
||||
* ★ ONE WRITER PER PAGE. Only the OUTERMOST element that owns the address writes it; profile-config `urlOwner` says
|
||||
* who that is:
|
||||
* 'app' our own pages (a generated leaf, Plugin Studio): writeViewKeys() writes history itself;
|
||||
* 'host' a host with its own router (DX Life): writeViewKeys() dispatches ADDRESS_EVENT and the HOST writes;
|
||||
* absent a partner's page: nothing is written; links are still READ (readViewKeys works everywhere).
|
||||
* A nested element never writes: it announces its choice upward (announceView), and the owner records it (ownAddress).
|
||||
* claimView() is the one call for a reader's choice: the owner writes it, any other element announces it.
|
||||
*
|
||||
* ★ THE ONE HISTORY WRITE: commitUrl(). The view keys and useShareableView's filters both write through it, so those
|
||||
* two can never undo each other. The tree's other history writers are pinned by check:view-address's census and move
|
||||
* onto commitUrl in later phases. On a push, commitUrl copies the previous entry's state (Vue Router's `position` and
|
||||
* `back` included). That is correct for a replace. A Vue Router page is a 'host' owner, so its router does its own
|
||||
* pushes. It keeps `history.state` and sets its `current` to the
|
||||
* new URL. MEASURED on a Vue Router host (2026-09-30): a pushState that drops Router's state fields makes the next
|
||||
* in-app navigation rewrite the entry back to Router's last-known URL, and the link is lost. It writes nothing when
|
||||
* the URL would not change. A user's tab/pane/place change is a push (one Back step); first paint, link
|
||||
* normalisation and filters are a replace.
|
||||
*
|
||||
* Pure helpers, no dependency. Safe under node and in a sandboxed frame (every access is guarded).
|
||||
*/
|
||||
|
||||
export const VIEW_KEYS = ['tab', 'pane', 'place']
|
||||
/** nested element → the address owner: { changes: { tab?, pane?, place? }, push }. Composed + bubbling, so it leaves
|
||||
* shadow roots. One event per reader action, so one action is one Back step. */
|
||||
export const VIEW_EVENT = 'dxm-view'
|
||||
/** an owner with urlOwner 'host' → the host's router: { keys, href, push }; the host does router[push ? 'push' : 'replace'](href). */
|
||||
export const ADDRESS_EVENT = 'dxm-address'
|
||||
|
||||
/** A plain id (letters, digits, `-`, `_`, at most 64), or '' for anything else. */
|
||||
export const cleanKey = (v) => { const s = String(v ?? '').trim(); return /^[A-Za-z0-9][\w-]{0,63}$/.test(s) ? s : '' }
|
||||
|
||||
/** { tab, pane, place } from a search string; '' for a key that is absent or not a plain id. The FIRST of a repeated
|
||||
* key decides, as URLSearchParams.get does for the shells that already read ?tab=; a malformed escape reads as absent. */
|
||||
export function readViewKeys(search) {
|
||||
const q = new URLSearchParams(search || '')
|
||||
return Object.fromEntries(VIEW_KEYS.map((k) => [k, cleanKey(q.get(k))]))
|
||||
}
|
||||
|
||||
// Bubbling + composed, so the event leaves every shadow root on its way to the owner or the host.
|
||||
const send = (el, type, detail) => !!el?.dispatchEvent && (el.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })), true)
|
||||
|
||||
/**
|
||||
* The search string after `changes` ({ tab?, pane?, place? }; '' or null removes a key), with the level rule: a
|
||||
* changed `tab` drops `pane` and `place` unless `changes` sets them itself. Foreign parameters keep their place and
|
||||
* their exact text (useShareableView's legible `,` and unicode are not re-encoded). Returns '' or '?…'.
|
||||
*/
|
||||
export function viewSearch(search, changes = {}) {
|
||||
const next = readViewKeys(search)
|
||||
VIEW_KEYS.forEach((k, level) => {
|
||||
if (!(k in changes) || cleanKey(changes[k]) === next[k]) return
|
||||
next[k] = cleanKey(changes[k])
|
||||
if (!level) VIEW_KEYS.slice(1).forEach((d) => { if (!(d in changes)) next[d] = '' })
|
||||
})
|
||||
const all = VIEW_KEYS.filter((k) => next[k]).map((k) => k + '=' + next[k])
|
||||
.concat((search || '').replace(/^\?/, '').split('&').filter((p) => p && !VIEW_KEYS.includes(p.split('=')[0])))
|
||||
return all.length ? '?' + all.join('&') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* THE one write to the address bar. `search` ('' or '?…') and `hash` ('' or '#…') default to the current ones, which
|
||||
* is the shape viewSearch, serializeQuery and serializeView all return. Keeps history.state and sets its
|
||||
* `current` (Vue Router's field) to the new URL. Returns true when it wrote; false when the URL was already that, or
|
||||
* the page cannot be written (no history, a sandbox).
|
||||
*/
|
||||
export function commitUrl({ search, hash } = {}, { push = false } = {}) {
|
||||
try {
|
||||
const p = location.pathname
|
||||
const url = p + (search ?? location.search) + (hash ?? location.hash)
|
||||
if (url === p + location.search + location.hash) return false
|
||||
history[push ? 'pushState' : 'replaceState']({ ...(history.state || {}), current: url }, '', url)
|
||||
return true
|
||||
} catch (_) { return false }
|
||||
}
|
||||
|
||||
/** 'app' | 'host' | '' from a profile-config. Anything else is '' (a partner's page: read only). */
|
||||
export const urlOwnerOf = (cfg) => (cfg && (cfg.urlOwner === 'app' || cfg.urlOwner === 'host') ? cfg.urlOwner : '')
|
||||
|
||||
/**
|
||||
* The owner's write for tab/pane/place. owner 'app': commitUrl (push for a reader's choice). owner 'host': dispatch
|
||||
* ADDRESS_EVENT from `el` for the host's router; nothing is written here. Anything else: nothing. Returns what it did:
|
||||
* 'written' | 'announced' | 'unchanged' | 'none'.
|
||||
*/
|
||||
export function writeViewKeys(changes, { owner = '', push = true, el = null } = {}) {
|
||||
if ((owner !== 'app' && owner !== 'host') || !globalThis.location) return 'none'
|
||||
const search = viewSearch(location.search, changes)
|
||||
// Unchanged means the same VALUES, not the same string: viewSearch puts the view keys first, so comparing strings
|
||||
// rewrote a link like ?owner=x&pane=map as ?pane=map&owner=x on every fresh load (measured in a browser), a
|
||||
// needless replace (and on a host, a needless router.replace).
|
||||
const now = readViewKeys(location.search), next = readViewKeys(search)
|
||||
if (VIEW_KEYS.every((k) => now[k] === next[k])) return 'unchanged'
|
||||
if (owner === 'app') return commitUrl({ search }, { push }) ? 'written' : 'unchanged'
|
||||
return send(el, ADDRESS_EVENT, { keys: readViewKeys(search), href: location.pathname + search + location.hash, push }) ? 'announced' : 'none'
|
||||
}
|
||||
|
||||
// Only the view keys, each value cleaned: what an announcement may carry.
|
||||
const viewChanges = (changes) => Object.fromEntries(Object.entries(changes || {}).filter(([k]) => VIEW_KEYS.includes(k)).map(([k, v]) => [k, cleanKey(v)]))
|
||||
|
||||
/** A nested element reports a reader's choice to whoever owns the address (it never writes itself). */
|
||||
export function announceView(el, changes, { push = true } = {}) {
|
||||
const c = viewChanges(changes)
|
||||
return Object.keys(c).length > 0 && send(el, VIEW_EVENT, { changes: c, push })
|
||||
}
|
||||
|
||||
/** THE call for a reader's choice: the owner writes it ('written' | 'announced' | 'unchanged' | 'none'); any other
|
||||
* element announces it to the owner above it ('announced' | 'none'). */
|
||||
export const claimView = (changes, { owner = '', el = null, push = true } = {}) =>
|
||||
owner ? writeViewKeys(changes, { owner, el, push }) : (announceView(el, changes, { push }) ? 'announced' : 'none')
|
||||
|
||||
/** An owner records what the elements inside it announce: their pane and place. `tab` is the owner's own key, so a
|
||||
* nested element cannot switch it. getOwner() is read per event (a host may set profile-config after mount); while
|
||||
* it is '' the event passes on to an owner further out. Returns off(). */
|
||||
export function ownAddress(el, getOwner) {
|
||||
const h = (e) => {
|
||||
const owner = getOwner()
|
||||
if (!owner || !e.detail) return
|
||||
e.stopPropagation()
|
||||
const { tab, ...changes } = viewChanges(e.detail.changes)
|
||||
if (Object.keys(changes).length) writeViewKeys(changes, { owner, el, push: e.detail.push !== false })
|
||||
}
|
||||
el?.addEventListener?.(VIEW_EVENT, h)
|
||||
return () => el?.removeEventListener?.(VIEW_EVENT, h)
|
||||
}
|
||||
|
||||
/** Back / Forward: cb(readViewKeys(location.search)). Re-apply state only; never write from here. Returns off(). */
|
||||
export function onAddressChange(cb) {
|
||||
const h = () => cb(readViewKeys(location.search))
|
||||
globalThis.addEventListener?.('popstate', h)
|
||||
return () => globalThis.removeEventListener?.('popstate', h)
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "كورنثوس الأولى",
|
||||
"1PE": "بطرس الأولى",
|
||||
"JOS": "يشوع"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "ملصق سفر من الكتاب المقدس"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "لا",
|
||||
"printHome": "أي طابعة منزلية",
|
||||
"printShop": "مطبعة (ملف PDF، صفحة لكل ورقة)",
|
||||
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره.",
|
||||
"sideLanes": "{dims}، أعمدة جانبية",
|
||||
"packetPage1": "{paper} (الصفحة 1 من حزمة الصلاة)",
|
||||
"panels": "{dims}، عدد الألواح: {n}",
|
||||
"anySize": "أي حجم من جدول الأحجام المشترك"
|
||||
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "صفحات الخرائط",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "بطاقات الصلاة",
|
||||
"wall": "ألواح الجدار",
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"plugins": "من إضافات أخرى",
|
||||
"experimental": "تجريبية (قيد العمل)",
|
||||
"upgZone": "ملصقات مناطق UPG100"
|
||||
"plugins": "من إضافات أخرى"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "صفحة خريطة الدولة",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "حزمة الصلاة، مختصرة",
|
||||
"cards": "بطاقات الصلاة",
|
||||
"blessPanels": "ألواح جدار البركة",
|
||||
"mapPrint": "طباعة الخريطة",
|
||||
"upgZonePoster": "ملصق منطقة UPG100",
|
||||
"upgZonePacket": "حزمة صلاة UPG100، الصفحة 1"
|
||||
"mapPrint": "طباعة الخريطة"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "ملصقات UPG100",
|
||||
"wallHeading": "ملصقات UPG100",
|
||||
"wallIntroA": "كيف تُطعم جمعًا من خمسة آلاف؟ أجلسهم يسوع جماعاتٍ من خمسين ومن مئة، فصار المستحيل قابلًا للمشاركة (لوقا ٩:١٤). فعلنا المثل مع {n} شعوب غير مبلَّغة: ٢٧ قائمة من نحو خمسين إلى مئة. تُطبع كل قائمة حزمةَ صلاة وملصقًا، ليحمل ٢٧ تلميذًا أو كنيسة أو قائدًا محليًا كلٌّ واحدة — مصلّين لأجلهم ",
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة",
|
||||
"posterDesignTerritory": "ملصق منطقة UPG100"
|
||||
"peopleStillLoading": "لا تزال الشعوب قيد التحميل، حاول مرة أخرى بعد لحظة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "سجّل للصلاة من أجل {name}",
|
||||
"commentsSoon": "التعليقات قادمة قريبًا."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1. Korinther",
|
||||
"1PE": "1. Petrus",
|
||||
"JOS": "Josua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Bibelbuch-Poster"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "Nein",
|
||||
"printHome": "Jeder Drucker zu Hause",
|
||||
"printShop": "Eine Druckerei (ein PDF, eine Seite pro Bogen)",
|
||||
"layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist.",
|
||||
"sideLanes": "{dims}, seitliche Namensspalten",
|
||||
"packetPage1": "{paper} (Seite 1 der Gebetsmappe)",
|
||||
"panels": "{dims}, Tafeln: {n}",
|
||||
"anySize": "Jede Größe aus der gemeinsamen Größentabelle"
|
||||
"layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Kartenblätter",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Gebetskarten",
|
||||
"wall": "Wandtafeln",
|
||||
"mapPrint": "Kartendruck",
|
||||
"plugins": "Aus anderen Plugins",
|
||||
"experimental": "Experimentell (in Arbeit)",
|
||||
"upgZone": "UPG100-Gebietsplakate"
|
||||
"plugins": "Aus anderen Plugins"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Länder-Kartenblatt",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Gebetsmappe, kompakt",
|
||||
"cards": "Gebetskarten",
|
||||
"blessPanels": "Tafeln der Segnen-Wand",
|
||||
"mapPrint": "Kartendruck",
|
||||
"upgZonePoster": "UPG100-Gebietsplakat",
|
||||
"upgZonePacket": "UPG100-Gebetsmappe, Seite 1"
|
||||
"mapPrint": "Kartendruck"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Die Bögen, die Klebestreifen und die Lage der Kartenreihen stammen aus der gemeinsamen Tafelgeometrie; die Karten sind als Layout dargestellt, weil die Segnen-Wand ihre Karten in ihrem eigenen Plugin zeichnet.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "UPG100-Poster",
|
||||
"wallHeading": "UPG100-Poster",
|
||||
"wallIntroA": "Wie würden Sie eine Menge von fünftausend speisen? Jesus ließ sie in Gruppen zu fünfzig und hundert lagern, und das Unmögliche wurde teilbar (Lukas 9,14). Dasselbe haben wir mit den {n} unerreichten Volksgruppen getan: 27 Listen von etwa fünfzig bis hundert. Jede Liste druckt als Gebetspaket und als Poster, damit 27 Jünger, Gemeinden oder einheimische Leiter je eine tragen können — und für sie beten ",
|
||||
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal",
|
||||
"posterDesignTerritory": "UPG100-Gebietsplakat"
|
||||
"peopleStillLoading": "die Volksgruppen werden noch geladen; versuchen Sie es gleich noch einmal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Melden Sie sich an, um für {name} zu beten",
|
||||
"commentsSoon": "Kommentare folgen in Kürze."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Corinthians",
|
||||
"1PE": "1 Peter",
|
||||
"JOS": "Joshua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Bible book poster"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "No",
|
||||
"printHome": "Any home printer",
|
||||
"printShop": "A print shop (a PDF, one page per sheet)",
|
||||
"layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available.",
|
||||
"sideLanes": "{dims}, side lanes",
|
||||
"packetPage1": "{paper} (packet page 1)",
|
||||
"panels": "{dims}, {n} panels",
|
||||
"anySize": "Any size from the shared size table"
|
||||
"layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Map sheets",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Prayer cards",
|
||||
"wall": "Wall panels",
|
||||
"mapPrint": "Map print",
|
||||
"plugins": "From other plugins",
|
||||
"experimental": "Experimental (in progress)",
|
||||
"upgZone": "UPG100 zone posters"
|
||||
"plugins": "From other plugins"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Country map sheet",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Prayer packet, lightweight",
|
||||
"cards": "Prayer cards",
|
||||
"blessPanels": "Bless wall panels",
|
||||
"mapPrint": "Map print",
|
||||
"upgZonePoster": "UPG100 zone poster",
|
||||
"upgZonePacket": "UPG100 prayer packet, page 1"
|
||||
"mapPrint": "Map print"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "The sheets, the glue strips and where the card rows go come from the shared panel geometry; the cards are shown as a layout, because the Bless wall draws its cards in its own plugin.",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,5 @@
|
|||
{
|
||||
"embed_link": "Embed link",
|
||||
"add_to_website": "Add to Your Website",
|
||||
"embed_paste_hint": "Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets).",
|
||||
"tab_page": "Share this page",
|
||||
"tab_embed": "Embed",
|
||||
"page_hint": "A link that opens this exact page for whoever you send it to.",
|
||||
"embed_what": "Embedding puts this live map inside your own website, so your visitors can explore it and pray without leaving your site. It stays up to date by itself.",
|
||||
"copy_link": "Copy link",
|
||||
"copy_code": "Copy code",
|
||||
"copied": "Copied!",
|
||||
"size_small": "Small",
|
||||
"size_medium": "Medium",
|
||||
"size_large": "Large",
|
||||
"kind_url": "URL",
|
||||
"kind_iframe": "Iframe",
|
||||
"kind_script": "JavaScript",
|
||||
"url_hint": "A link to open the map on its own page.",
|
||||
"iframe_hint": "Put the map in your page, in a frame.",
|
||||
"script_hint": "Load the map straight into your page, with no frame.",
|
||||
"script_loading": "Reading the public site…",
|
||||
"not_public": "This map isn't on the public site yet. Its embed codes will appear here once it's released.",
|
||||
"public_failed": "Couldn't reach the public site just now. Try again in a moment."
|
||||
"embed_paste_hint": "Paste this code into your website builder (WordPress, Squarespace, Wix, or any site that accepts HTML widgets)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "UPG100 Posters",
|
||||
"wallHeading": "UPG100 Posters",
|
||||
"wallIntroA": "How would you feed a crowd of five thousand? Jesus sat them down in groups of fifty and a hundred, and the impossible became shareable (Luke 9:14). We did the same with the {n} unreached people groups: 27 lists of about fifty to a hundred. Each list prints as a prayer packet and a poster, so 27 disciples, churches or local leaders can each carry one — praying for them ",
|
||||
"peopleStillLoading": "the people groups are still loading, try again in a moment",
|
||||
"posterDesignTerritory": "UPG100 zone poster"
|
||||
"peopleStillLoading": "the people groups are still loading, try again in a moment"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Sign up to pray for {name}",
|
||||
"commentsSoon": "Comments are coming soon."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Corintios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Cartel de un libro de la Biblia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "No",
|
||||
"printHome": "Cualquier impresora doméstica",
|
||||
"printShop": "Una imprenta (un PDF, una página por hoja)",
|
||||
"layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible.",
|
||||
"sideLanes": "{dims}, columnas laterales",
|
||||
"packetPage1": "{paper} (página 1 del paquete de oración)",
|
||||
"panels": "{dims}, paneles: {n}",
|
||||
"anySize": "Cualquier tamaño de la tabla de tamaños compartida"
|
||||
"layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Hojas de mapa",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Tarjetas de oración",
|
||||
"wall": "Paneles de pared",
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"plugins": "De otros complementos",
|
||||
"experimental": "Experimentales (en desarrollo)",
|
||||
"upgZone": "Carteles de zonas UPG100"
|
||||
"plugins": "De otros complementos"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Hoja de mapa del país",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Paquete de oración, ligero",
|
||||
"cards": "Tarjetas de oración",
|
||||
"blessPanels": "Paneles del muro Bendecir",
|
||||
"mapPrint": "Impresión del mapa",
|
||||
"upgZonePoster": "Cartel de zona UPG100",
|
||||
"upgZonePacket": "Paquete de oración UPG100, página 1"
|
||||
"mapPrint": "Impresión del mapa"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Las hojas, las franjas de pegado y el lugar de las filas de tarjetas vienen de la geometría de paneles compartida; las tarjetas se muestran como un diseño, porque el muro Bendecir dibuja sus tarjetas en su propio complemento.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Carteles UPG100",
|
||||
"wallHeading": "Carteles UPG100",
|
||||
"wallIntroA": "¿Cómo alimentarías a una multitud de cinco mil? Jesús los sentó en grupos de cincuenta y de cien, y lo imposible se volvió compartible (Lucas 9:14). Hicimos lo mismo con los {n} pueblos no alcanzados: 27 listas de unos cincuenta a cien. Cada lista se imprime como paquete de oración y cartel, para que 27 discípulos, iglesias o líderes locales lleven una cada uno — orando por ellos ",
|
||||
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento",
|
||||
"posterDesignTerritory": "Cartel de zona UPG100"
|
||||
"peopleStillLoading": "los pueblos aún se están cargando; inténtalo de nuevo en un momento"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Inscríbase para orar por {name}",
|
||||
"commentsSoon": "Los comentarios llegarán pronto."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Corinthiens",
|
||||
"1PE": "1 Pierre",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Affiche d'un livre de la Bible"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "Non",
|
||||
"printHome": "N'importe quelle imprimante",
|
||||
"printShop": "Un imprimeur (un PDF, une page par feuille)",
|
||||
"layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible.",
|
||||
"sideLanes": "{dims}, colonnes latérales",
|
||||
"packetPage1": "{paper} (page 1 du dossier de prière)",
|
||||
"panels": "{dims}, panneaux : {n}",
|
||||
"anySize": "Toute taille du tableau de tailles commun"
|
||||
"layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Feuilles de carte",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Cartes de prière",
|
||||
"wall": "Panneaux muraux",
|
||||
"mapPrint": "Impression de la carte",
|
||||
"plugins": "D’autres plugins",
|
||||
"experimental": "Expérimentaux (en cours)",
|
||||
"upgZone": "Affiches de zones UPG100"
|
||||
"plugins": "D’autres plugins"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Feuille de carte du pays",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Dossier de prière, allégé",
|
||||
"cards": "Cartes de prière",
|
||||
"blessPanels": "Panneaux du mur Bénir",
|
||||
"mapPrint": "Impression de la carte",
|
||||
"upgZonePoster": "Affiche de zone UPG100",
|
||||
"upgZonePacket": "Dossier de prière UPG100, page 1"
|
||||
"mapPrint": "Impression de la carte"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Les feuilles, les bandes de collage et l'emplacement des rangées de cartes viennent de la géométrie de panneaux partagée ; les cartes sont montrées comme une mise en page, car le mur Bénir dessine ses cartes dans son propre module.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Affiches UPG100",
|
||||
"wallHeading": "Affiches UPG100",
|
||||
"wallIntroA": "Comment nourririez-vous une foule de cinq mille personnes ? Jésus les a fait asseoir par groupes de cinquante et de cent, et l’impossible est devenu partageable (Luc 9.14). Nous avons fait de même avec les {n} peuples non atteints : 27 listes d’environ cinquante à cent. Chaque liste s’imprime en dossier de prière et en affiche, pour que 27 disciples, Églises ou responsables locaux en portent chacun une — en priant pour eux ",
|
||||
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant",
|
||||
"posterDesignTerritory": "Affiche de zone UPG100"
|
||||
"peopleStillLoading": "les peuples sont encore en cours de chargement ; réessayez dans un instant"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Inscrivez-vous pour prier pour {name}",
|
||||
"commentsSoon": "Les commentaires arrivent bientôt."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 कुरिन्थियों",
|
||||
"1PE": "1 पतरस",
|
||||
"JOS": "यहोशू"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "बाइबल पुस्तक पोस्टर"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "नहीं",
|
||||
"printHome": "कोई भी घरेलू प्रिंटर",
|
||||
"printShop": "प्रिंटिंग प्रेस (PDF, हर शीट के लिए एक पेज)",
|
||||
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।",
|
||||
"sideLanes": "{dims}, किनारे के कॉलम",
|
||||
"packetPage1": "{paper} (प्रार्थना पैकेट का पेज 1)",
|
||||
"panels": "{dims}, पैनल: {n}",
|
||||
"anySize": "साझा आकार तालिका का कोई भी आकार"
|
||||
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "मानचित्र पृष्ठ",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "प्रार्थना कार्ड",
|
||||
"wall": "दीवार पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"plugins": "अन्य प्लगइन से",
|
||||
"experimental": "प्रयोगात्मक (काम जारी)",
|
||||
"upgZone": "UPG100 क्षेत्र पोस्टर"
|
||||
"plugins": "अन्य प्लगइन से"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "देश का मानचित्र पृष्ठ",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "प्रार्थना पैकेट, हल्का",
|
||||
"cards": "प्रार्थना कार्ड",
|
||||
"blessPanels": "आशीष दीवार के पैनल",
|
||||
"mapPrint": "मानचित्र प्रिंट",
|
||||
"upgZonePoster": "UPG100 क्षेत्र का पोस्टर",
|
||||
"upgZonePacket": "UPG100 प्रार्थना पैकेट, पेज 1"
|
||||
"mapPrint": "मानचित्र प्रिंट"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "UPG100 पोस्टर",
|
||||
"wallHeading": "UPG100 पोस्टर",
|
||||
"wallIntroA": "पाँच हज़ार की भीड़ को आप कैसे खिलाते? यीशु ने उन्हें पचास-पचास और सौ-सौ के समूहों में बिठाया, और असंभव बाँटने योग्य हो गया (लूका 9:14). हमने {n} अनपहुँचे जनसमूहों के साथ वही किया: लगभग पचास से सौ तक की 27 सूचियाँ. हर सूची प्रार्थना पैकेट और पोस्टर के रूप में छपती है, ताकि 27 शिष्य, कलीसियाएँ या स्थानीय अगुवे एक-एक ले सकें — उनके लिए प्रार्थना करते हुए ",
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें",
|
||||
"posterDesignTerritory": "UPG100 क्षेत्र का पोस्टर"
|
||||
"peopleStillLoading": "जनसमूह अभी लोड हो रहे हैं, थोड़ी देर में फिर कोशिश करें"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "{name} के लिए प्रार्थना करने हेतु पंजीकरण करें",
|
||||
"commentsSoon": "टिप्पणियाँ जल्द ही आ रही हैं।"
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Corinzi",
|
||||
"1PE": "1 Pietro",
|
||||
"JOS": "Giosuè"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Poster di un libro della Bibbia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "No",
|
||||
"printHome": "Qualsiasi stampante di casa",
|
||||
"printShop": "Una tipografia (un PDF, una pagina per foglio)",
|
||||
"layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile.",
|
||||
"sideLanes": "{dims}, colonne laterali",
|
||||
"packetPage1": "{paper} (pagina 1 del fascicolo di preghiera)",
|
||||
"panels": "{dims}, pannelli: {n}",
|
||||
"anySize": "Qualsiasi formato dalla tabella dei formati comune"
|
||||
"layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Fogli mappa",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Schede di preghiera",
|
||||
"wall": "Pannelli da parete",
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"plugins": "Da altri plugin",
|
||||
"experimental": "Sperimentali (in corso)",
|
||||
"upgZone": "Poster delle zone UPG100"
|
||||
"plugins": "Da altri plugin"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foglio mappa del paese",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Fascicolo di preghiera, leggero",
|
||||
"cards": "Schede di preghiera",
|
||||
"blessPanels": "Pannelli del muro Benedici",
|
||||
"mapPrint": "Stampa della mappa",
|
||||
"upgZonePoster": "Poster di zona UPG100",
|
||||
"upgZonePacket": "Fascicolo di preghiera UPG100, pagina 1"
|
||||
"mapPrint": "Stampa della mappa"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "I fogli, le strisce da incollare e la posizione delle file di schede vengono dalla geometria dei pannelli condivisa; le schede sono mostrate come impaginazione, perché il muro Benedici disegna le sue schede nel proprio plugin.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Poster UPG100",
|
||||
"wallHeading": "Poster UPG100",
|
||||
"wallIntroA": "Come sfameresti una folla di cinquemila? Gesù li fece sedere a gruppi di cinquanta e di cento, e l’impossibile divenne condivisibile (Luca 9:14). Abbiamo fatto lo stesso con i {n} popoli non raggiunti: 27 liste di circa cinquanta-cento. Ogni lista si stampa come pacchetto di preghiera e manifesto, così 27 discepoli, chiese o responsabili locali ne portano una ciascuno — pregando per loro ",
|
||||
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento",
|
||||
"posterDesignTerritory": "Poster di zona UPG100"
|
||||
"peopleStillLoading": "i popoli si stanno ancora caricando; riprova tra un momento"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Iscriviti per pregare per {name}",
|
||||
"commentsSoon": "I commenti arriveranno presto."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Coríntios",
|
||||
"1PE": "1 Pedro",
|
||||
"JOS": "Josué"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Cartaz de um livro da Bíblia"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "Não",
|
||||
"printHome": "Qualquer impressora doméstica",
|
||||
"printShop": "Uma gráfica (um PDF, uma página por folha)",
|
||||
"layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível.",
|
||||
"sideLanes": "{dims}, colunas laterais",
|
||||
"packetPage1": "{paper} (página 1 do pacote de oração)",
|
||||
"panels": "{dims}, painéis: {n}",
|
||||
"anySize": "Qualquer tamanho da tabela de tamanhos compartilhada"
|
||||
"layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Folhas de mapa",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Cartões de oração",
|
||||
"wall": "Painéis de parede",
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"plugins": "De outros plugins",
|
||||
"experimental": "Experimentais (em andamento)",
|
||||
"upgZone": "Cartazes de zonas UPG100"
|
||||
"plugins": "De outros plugins"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Folha de mapa do país",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Pacote de oração, leve",
|
||||
"cards": "Cartões de oração",
|
||||
"blessPanels": "Painéis do mural Abençoar",
|
||||
"mapPrint": "Impressão do mapa",
|
||||
"upgZonePoster": "Cartaz de zona UPG100",
|
||||
"upgZonePacket": "Pacote de oração UPG100, página 1"
|
||||
"mapPrint": "Impressão do mapa"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "As folhas, as faixas de colagem e a posição das fileiras de cartões vêm da geometria de painéis compartilhada; os cartões aparecem como um layout, porque o mural Abençoar desenha os seus cartões no seu próprio plugin.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Cartazes UPG100",
|
||||
"wallHeading": "Cartazes UPG100",
|
||||
"wallIntroA": "Como você alimentaria uma multidão de cinco mil? Jesus os sentou em grupos de cinquenta e de cem, e o impossível tornou-se compartilhável (Lucas 9.14). Fizemos o mesmo com os {n} povos não alcançados: 27 listas de cerca de cinquenta a cem. Cada lista imprime como pacote de oração e cartaz, para que 27 discípulos, igrejas ou líderes locais levem uma cada um — orando por eles ",
|
||||
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante",
|
||||
"posterDesignTerritory": "Cartaz de zona UPG100"
|
||||
"peopleStillLoading": "os povos ainda estão carregando; tente de novo em um instante"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Inscreva-se para orar por {name}",
|
||||
"commentsSoon": "Os comentários chegarão em breve."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Corinteni",
|
||||
"1PE": "1 Petru",
|
||||
"JOS": "Iosua"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Poster cu o carte a Bibliei"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "Nu",
|
||||
"printHome": "Orice imprimantă de acasă",
|
||||
"printShop": "O tipografie (un PDF, o pagină pe coală)",
|
||||
"layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil.",
|
||||
"sideLanes": "{dims}, coloane laterale",
|
||||
"packetPage1": "{paper} (pagina 1 a pachetului de rugăciune)",
|
||||
"panels": "{dims}, panouri: {n}",
|
||||
"anySize": "Orice dimensiune din tabelul comun de dimensiuni"
|
||||
"layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Foi de hartă",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Cartonașe de rugăciune",
|
||||
"wall": "Panouri de perete",
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"plugins": "Din alte pluginuri",
|
||||
"experimental": "Experimentale (în lucru)",
|
||||
"upgZone": "Postere de zone UPG100"
|
||||
"plugins": "Din alte pluginuri"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Foaie de hartă a țării",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Pachet de rugăciune, ușor",
|
||||
"cards": "Cartonașe de rugăciune",
|
||||
"blessPanels": "Panourile peretelui Binecuvântează",
|
||||
"mapPrint": "Tipărirea hărții",
|
||||
"upgZonePoster": "Poster de zonă UPG100",
|
||||
"upgZonePacket": "Pachet de rugăciune UPG100, pagina 1"
|
||||
"mapPrint": "Tipărirea hărții"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Colile, benzile de lipire și locul rândurilor de cartonașe vin din geometria comună a panourilor; cartonașele sunt arătate ca machetă, pentru că peretele Binecuvântează își desenează cartonașele în propriul modul.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Afișe UPG100",
|
||||
"wallHeading": "Afișe UPG100",
|
||||
"wallIntroA": "Cum ai hrăni o mulțime de cinci mii? Isus i-a așezat în grupuri de cincizeci și de o sută, iar imposibilul a devenit împărțibil (Luca 9:14). Am făcut la fel cu cele {n} popoare neatinse: 27 de liste de aproximativ cincizeci până la o sută. Fiecare listă se tipărește ca pachet de rugăciune și afiș, ca 27 de ucenici, biserici sau lideri locali să poarte fiecare câte una — rugându-se pentru ei ",
|
||||
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă",
|
||||
"posterDesignTerritory": "Poster de zonă UPG100"
|
||||
"peopleStillLoading": "popoarele încă se încarcă; încearcă din nou peste o clipă"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Înscrieți-vă să vă rugați pentru {name}",
|
||||
"commentsSoon": "Comentariile vor fi disponibile în curând."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "1 Коринфянам",
|
||||
"1PE": "1 Петра",
|
||||
"JOS": "Иисус Навин"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "Плакат книги Библии"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "Нет",
|
||||
"printHome": "Любой домашний принтер",
|
||||
"printShop": "Типография (PDF, одна страница на лист)",
|
||||
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов.",
|
||||
"sideLanes": "{dims}, боковые колонки",
|
||||
"packetPage1": "{paper} (страница 1 молитвенного пакета)",
|
||||
"panels": "{dims}, панелей: {n}",
|
||||
"anySize": "Любой размер из общей таблицы размеров"
|
||||
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов."
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "Листы карт",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "Молитвенные карточки",
|
||||
"wall": "Настенные панели",
|
||||
"mapPrint": "Печать карты",
|
||||
"plugins": "Из других плагинов",
|
||||
"experimental": "Экспериментальные (в работе)",
|
||||
"upgZone": "Плакаты зон UPG100"
|
||||
"plugins": "Из других плагинов"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "Лист карты страны",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "Молитвенный пакет, облегчённый",
|
||||
"cards": "Молитвенные карточки",
|
||||
"blessPanels": "Панели стены «Благословить»",
|
||||
"mapPrint": "Печать карты",
|
||||
"upgZonePoster": "Плакат зоны UPG100",
|
||||
"upgZonePacket": "Молитвенный пакет UPG100, страница 1"
|
||||
"mapPrint": "Печать карты"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "Плакаты UPG100",
|
||||
"wallHeading": "Плакаты UPG100",
|
||||
"wallIntroA": "Как накормить толпу в пять тысяч человек? Иисус рассадил их группами по пятьдесят и по сто, и невозможное стало посильным (Лк. 9:14). Мы поступили так же с {n} неохваченными народами: 27 списков примерно по пятьдесят-сто. Каждый список печатается как молитвенный пакет и плакат, чтобы 27 учеников, церквей или местных служителей взяли по одному — молясь о них ",
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение",
|
||||
"posterDesignTerritory": "Плакат зоны UPG100"
|
||||
"peopleStillLoading": "народы ещё загружаются; попробуйте ещё раз через мгновение"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "Запишитесь, чтобы молиться за {name}",
|
||||
"commentsSoon": "Комментарии скоро появятся."
|
||||
}
|
||||
|
|
@ -23,8 +23,5 @@
|
|||
"1CO": "哥林多前书",
|
||||
"1PE": "彼得前书",
|
||||
"JOS": "约书亚记"
|
||||
},
|
||||
"posterDesign": {
|
||||
"label": "圣经书卷海报"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@
|
|||
"no": "否",
|
||||
"printHome": "任何家用打印机",
|
||||
"printShop": "印刷店(PDF,每张纸一页)",
|
||||
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。",
|
||||
"sideLanes": "{dims},两侧名单栏",
|
||||
"packetPage1": "{paper}(祷告资料包第 1 页)",
|
||||
"panels": "{dims},展板数:{n}",
|
||||
"anySize": "共享尺寸表中的任意尺寸"
|
||||
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。"
|
||||
},
|
||||
"family": {
|
||||
"mapSheet": "地图页",
|
||||
|
|
@ -40,9 +36,7 @@
|
|||
"cards": "祷告卡",
|
||||
"wall": "墙面展板",
|
||||
"mapPrint": "地图打印",
|
||||
"plugins": "来自其他插件",
|
||||
"experimental": "实验性(进行中)",
|
||||
"upgZone": "UPG100 区域海报"
|
||||
"plugins": "来自其他插件"
|
||||
},
|
||||
"tpl": {
|
||||
"mapSheetCountry": "国家地图页",
|
||||
|
|
@ -51,9 +45,7 @@
|
|||
"packetLightweight": "祷告资料包(精简版)",
|
||||
"cards": "祷告卡",
|
||||
"blessPanels": "祝福墙展板",
|
||||
"mapPrint": "地图打印",
|
||||
"upgZonePoster": "UPG100 区域海报(单张)",
|
||||
"upgZonePacket": "UPG100 祷告资料包,第 1 页"
|
||||
"mapPrint": "地图打印"
|
||||
},
|
||||
"missing": {
|
||||
"bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,5 @@
|
|||
"displayName": "UPG100 海报",
|
||||
"wallHeading": "UPG100 海报",
|
||||
"wallIntroA": "你会怎样喂饱五千人的群众?耶稣让他们一排五十、一排一百地坐下,不可能的事就变得可以分担了(路加福音 9:14)。我们对 {n} 个未得之民做了同样的事:27 份名单,每份约五十到一百。每份名单都能印成祷告手册和海报,让 27 位门徒、教会或当地领袖各领一份 — 为他们祷告 ",
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试",
|
||||
"posterDesignTerritory": "UPG100 区域海报(单张)"
|
||||
"peopleStillLoading": "族群仍在加载中,请稍后再试"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"signUpToPray": "报名为{name}祷告",
|
||||
"commentsSoon": "评论功能即将推出。"
|
||||
}
|
||||
|
|
@ -34,8 +34,6 @@
|
|||
* see every pane laid out like posters on a wall.
|
||||
*/
|
||||
|
||||
import { readViewKeys, claimView, onAddressChange } from '../../composables/useViewAddress.js'
|
||||
|
||||
export const PANE_GRID_STYLES = `
|
||||
/* The column that scrolls. Vertical neighbours stack here and the user scrolls
|
||||
between them; scroll-snap makes each pane settle cleanly instead of stopping
|
||||
|
|
@ -114,14 +112,9 @@ export const PANE_GRID_STYLES = `
|
|||
* @param {(pane:Object, mountEl:HTMLElement) => Promise<void>} opts.mountPane
|
||||
* called for each pane that should be LIVE; the caller owns element creation
|
||||
* so this layout never needs to know about bundles, tags or tokens.
|
||||
* @param {{ owner: () => string }} [opts.address] OPT-IN: the open pane is in the page address as ?pane=
|
||||
* (library/composables/useViewAddress.js). owner() is the composite's urlOwner, read per write: on its own
|
||||
* leaf the composite writes; nested in a shell it announces, and the shell writes. The first pane is the
|
||||
* default and is never written. A click (scrollTo) is a push; where the reader's own scroll settles is a
|
||||
* replace; a link opens its pane; Back / Forward scroll to the address's pane and never write.
|
||||
* @returns {{ panes:Object[], scrollTo:(paneId:string, o?:{write?:boolean, push?:boolean, changes?:Object})=>void, destroy:()=>void }}
|
||||
* @returns {{ panes:Object[], scrollTo:(paneId:string)=>void, destroy:()=>void }}
|
||||
*/
|
||||
export function buildPaneColumn(spec, { host, mountPane, address }) {
|
||||
export function buildPaneColumn(spec, { host, mountPane }) {
|
||||
const panes = Array.isArray(spec?.panes) ? spec.panes : []
|
||||
// No pane declaration → not a pane profile. The caller falls back to its
|
||||
// original single-mount path, so nothing changes for existing profiles.
|
||||
|
|
@ -176,66 +169,6 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
|
|||
|
||||
host.appendChild(col)
|
||||
|
||||
const paneEl = (id) => col.querySelector(`.pane[data-pane-id="${id}"]`)
|
||||
const known = (id) => column.some((p) => p.id === id)
|
||||
const current = () => {
|
||||
const tops = column.map(p => ({ id: p.id, d: Math.abs((paneEl(p.id)?.offsetTop ?? 0) - col.scrollTop) }))
|
||||
tops.sort((a, b) => a.d - b.d)
|
||||
return tops[0]?.id || column[0].id
|
||||
}
|
||||
// The first pane is the default, so it is never written: '' removes ?pane=.
|
||||
const claimPane = (id, push, changes) => address && claimView({ pane: id === column[0].id ? '' : id, ...changes }, { owner: address.owner(), el: host, push })
|
||||
const show = (id, behavior) => { const t = paneEl(id); if (t) col.scrollTo({ top: t.offsetTop, behavior }) }
|
||||
const offs = []
|
||||
if (address) {
|
||||
// A link opens its pane. Reading writes nothing, and the default needs no write. ★ NOT AT ONCE. MEASURED in a
|
||||
// browser (a fresh load with ?pane=<second pane>, on Country Posters and Regions): when the column is built it
|
||||
// has no layout yet, so the pane's offsetTop is 0 and the column stayed on its first pane. The link waits until
|
||||
// the column has a height and the pane a top, then shows it, once. 'instant': 'auto' would take the column's CSS
|
||||
// smooth scrolling.
|
||||
const linked = readViewKeys(typeof location !== 'undefined' ? location.search : '').pane
|
||||
let pending = known(linked) && linked !== column[0].id ? linked : ''
|
||||
const openLink = () => {
|
||||
if (!pending || !col.clientHeight || !(paneEl(pending)?.offsetTop > 0)) return
|
||||
show(pending, 'instant')
|
||||
pending = ''
|
||||
}
|
||||
openLink()
|
||||
// Retried by the ResizeObserver below, by every settled scroll, and by a bounded rAF loop that runs even WITH an
|
||||
// observer: the observer fires only when the COLUMN resizes, and a pane's height can arrive later (styles adopted
|
||||
// late in a shadow root).
|
||||
if (pending && typeof requestAnimationFrame !== 'undefined') {
|
||||
let n = 0
|
||||
const tick = () => { openLink(); if (pending && ++n < 120) requestAnimationFrame(tick) }
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
// Where the reader's own scroll settles is a REPLACE, so a copied link opens what is on screen without a Back
|
||||
// step per scroll. A column with no height (a hidden tab) has no "where", and a link still waiting to open must
|
||||
// not be overwritten, so neither writes.
|
||||
let t = null
|
||||
const settle = () => { if (t) clearTimeout(t); t = setTimeout(() => { openLink(); if (col.clientHeight && !pending) claimPane(current(), false) }, 120) }
|
||||
col.addEventListener('scroll', settle, { passive: true })
|
||||
offs.push(() => { if (t) clearTimeout(t); col.removeEventListener('scroll', settle) })
|
||||
offs.push(onAddressChange(({ pane }) => show(known(pane) ? pane : column[0].id, 'smooth')))
|
||||
// A KEPT-ALIVE column shown again (a shell switched back to its tab: no height, then height). The tab change
|
||||
// dropped ?pane= while the column kept its pane, so the screen claims it again (a replace). An address that names
|
||||
// a pane wins: Back to an entry of this tab is applied while the column had no layout to scroll.
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
let h = null
|
||||
const ro = new ResizeObserver(() => {
|
||||
openLink()
|
||||
const was = h
|
||||
h = col.clientHeight
|
||||
if (was !== 0 || !h) return
|
||||
const asked = readViewKeys(location.search).pane
|
||||
if (known(asked) && asked !== current()) show(asked, 'instant')
|
||||
else if (!asked) claimPane(current(), false)
|
||||
})
|
||||
ro.observe(col)
|
||||
offs.push(() => ro.disconnect())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
panes: column,
|
||||
/** Animate to a pane by id — this is what the View fieldset tabs call. */
|
||||
|
|
@ -244,15 +177,19 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
|
|||
// window's top, and a host shorter than the window then showed its white space under the map. The column is
|
||||
// the scroller (position:absolute; overflow-y:auto) and each pane's offsetParent, so its offsetTop is exactly
|
||||
// the scroll position that puts the pane at the top.
|
||||
// With `address`, the reader's choice is also written (a push). `changes` rides along in the SAME write, so a
|
||||
// click that opens a pane AND selects in it is one Back step. write:false is for a caller re-showing a pane.
|
||||
scrollTo(paneId, o) {
|
||||
const { write = true, push = true, changes = {} } = o || {}
|
||||
show(paneId, 'smooth')
|
||||
if (write && known(paneId)) claimPane(paneId, push, changes)
|
||||
scrollTo(paneId) {
|
||||
const target = col.querySelector(`.pane[data-pane-id="${paneId}"]`)
|
||||
if (target) col.scrollTo({ top: target.offsetTop, behavior: 'smooth' })
|
||||
},
|
||||
/** Which pane is currently filling the viewport (for URL sync). */
|
||||
current,
|
||||
current() {
|
||||
const tops = column.map(p => {
|
||||
const el = col.querySelector(`.pane[data-pane-id="${p.id}"]`)
|
||||
return { id: p.id, d: Math.abs((el?.offsetTop ?? 0) - col.scrollTop) }
|
||||
})
|
||||
tops.sort((a, b) => a.d - b.d)
|
||||
return tops[0]?.id || column[0].id
|
||||
},
|
||||
onScroll(cb) {
|
||||
let t = null
|
||||
const h = () => { if (t) clearTimeout(t); t = setTimeout(cb, 120) }
|
||||
|
|
@ -260,6 +197,6 @@ export function buildPaneColumn(spec, { host, mountPane, address }) {
|
|||
return () => { if (t) clearTimeout(t); col.removeEventListener('scroll', h) }
|
||||
},
|
||||
mountEl: (paneId) => mounts.get(paneId) || null,
|
||||
destroy() { offs.forEach((off) => off()); col.remove(); mounts.clear() },
|
||||
destroy() { col.remove(); mounts.clear() },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,11 +376,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
.pray-head{position:absolute;inset-inline-end:${M}px;display:grid;grid-template-columns:repeat(${PRAY_COLS.length},${PRAY_CELL}px);
|
||||
height:${PRAY_HEAD_H - 6}px;align-items:end;font:700 15px/1.2 Georgia,serif;color:#6f6a5c;text-align:center}
|
||||
.pray-head span{text-wrap:balance;padding:0 0 5px;border-bottom:2px solid #3b463d}
|
||||
/* Page 1 of the Full detail list: the explanation, then the head row and the rows right under it (listPages). */
|
||||
.fd-first{position:absolute;left:${M}px;top:${M + HEAD}px;width:${CW.toFixed(0)}px}
|
||||
.fd-top{max-height:${FD_TOP_H}px;overflow:hidden}
|
||||
.fd-rows{position:relative}
|
||||
.fd-rows .pray-head{inset-inline-end:0}
|
||||
.fd-top{position:absolute;left:${M}px;top:${M + HEAD}px;width:${CW.toFixed(0)}px;height:${FD_TOP_H}px;overflow:hidden}
|
||||
.hd-r{position:absolute;right:${M}px;top:54px;font:400 19px Georgia,serif;color:#8c8170}
|
||||
/* ⚠ box-sizing:border-box IS LOAD-BEARING HERE, NOT HOUSEKEEPING. Without it the row's height
|
||||
is the CONTENT box and the padding + border are added OUTSIDE it, so the row renders taller
|
||||
|
|
@ -1195,14 +1191,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
PRAY_COLS.map((key) => `<span>${esc(t(key))}</span>`).join('')}</div>`;
|
||||
for (let start = 0, page = 0; start < groups.length; page += 1) {
|
||||
const per = page === 0 ? FD_FIRST : PER_PAGE;
|
||||
// ★ PAGE 1'S ROWS FOLLOW THE EXPLANATION (2026-10-03). The explanation reserved a fixed FD_TOP_H and the
|
||||
// rows sat below the reserve, so every language shorter than the tallest left a gap above the head row
|
||||
// (0.36-1.30 in, measured in all 11). Now the explanation (at most FD_TOP_H) and the rows share one
|
||||
// column (.fd-first), and the rows are placed inside .fd-rows, right under it. The page still holds
|
||||
// FD_FIRST rows, sized for the full reserve, so a shorter explanation leaves the room at the page's foot.
|
||||
// The rows stay absolutely placed (see below), only their origin moves: no row can reflow to a page.
|
||||
const rowsTop = page === 0 ? PRAY_HEAD_H : M + HEAD;
|
||||
const left = page === 0 ? 0 : M;
|
||||
const rowsTop = page === 0 ? FD_ROWS_TOP : M + HEAD;
|
||||
const slice = groups.slice(start, start + per);
|
||||
// ★ ONE FULL-WIDTH ROW PER GROUP. The column index is gone: `top` advances by the row, and the
|
||||
// three cells sit inside it. Absolute positioning is kept rather than switching to flow layout
|
||||
|
|
@ -1219,7 +1208,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
const img = src
|
||||
? `<img src="${esc(src)}" alt="">`
|
||||
: `<div class="nophoto">${esc(t('no_photo'))}</div>`;
|
||||
return `<div class="card" style="left:${left}px;top:${top.toFixed(0)}px;
|
||||
return `<div class="card" style="left:${M}px;top:${top.toFixed(0)}px;
|
||||
width:${CW.toFixed(0)}px;height:${CH.toFixed(0)}px">${img}
|
||||
<div class="d-cell">
|
||||
<div class="nm">${i + 1}. ${esc(g.name)}</div>
|
||||
|
|
@ -1228,17 +1217,15 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
<div class="pray-cols">${PRAY_COLS.map(() => '<span class="pray-col"><i class="pray-box"></i></span>').join('')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const rowsH = PRAY_HEAD_H + slice.length * (CH + GUT) - GUT;
|
||||
const body = page === 0
|
||||
? `<div class="fd-first"><div class="fd-top">${explainTop()}${explainBand(groups.length,
|
||||
const first = page === 0
|
||||
? `<div class="fd-top">${explainTop()}${explainBand(groups.length,
|
||||
{ w: PRAY_COLS.length * PRAY_CELL, cols: `repeat(${PRAY_COLS.length},${PRAY_CELL}px)`, gap: 0, cell: 'xb-cell xb-cell-fd' })}</div>`
|
||||
+ `<div class="fd-rows" style="height:${rowsH.toFixed(0)}px">${prayHead(0)}${cards}</div></div>`
|
||||
: `${prayHead(rowsTop - PRAY_HEAD_H)}${cards}`;
|
||||
: '';
|
||||
pages.push(sheet(`
|
||||
<div class="hd">${esc(zone.label || zone.id)}</div>
|
||||
<div class="hd-r">${esc(fill(t('list_header'),
|
||||
{ from: num(start + 1), to: num(Math.min(start + per, groups.length)), count: num(groups.length) }))}</div>
|
||||
${body}`));
|
||||
${first}${prayHead(rowsTop - PRAY_HEAD_H)}${cards}`));
|
||||
start += per;
|
||||
}
|
||||
return pages;
|
||||
|
|
@ -1397,7 +1384,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
// its own width, with per-script advances measured on this page in the packet's fonts and rounded UP; a
|
||||
// tier is taken only when the estimate fits with PT_SAFE to spare. check:packet-render measures the real
|
||||
// page in all 11 locales behind it (pointsOver).
|
||||
const PT_MIN = { head: 22, sub: 19, text: 18, refs: 16, gap: 3, passage: 16, th: 19, thb: 20, scr: 18 };
|
||||
const PT_MIN = { head: 22, sub: 19, text: 18.5, refs: 16, gap: 5, passage: 16, th: 19, thb: 20, scr: 18 };
|
||||
const PT_MAX = { head: 25, sub: 21, text: 22.5, refs: 18.5, gap: 18, passage: 20, th: 21, thb: 22, scr: 19 };
|
||||
const PT_TOP = 82, PT_TIERS = 10, PT_SAFE = 0.97;
|
||||
const PT_BODY_H = H - (M + PT_TOP) - 80; // the .points-body box: 1719px
|
||||
|
|
@ -1645,18 +1632,13 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
+ `<rect x="${(cx - arm / 2).toFixed(1)}" y="${(cy - h / 2 + h * 0.24).toFixed(1)}" width="${arm.toFixed(1)}" height="${bar.toFixed(1)}" rx="1.5"/></g>`;
|
||||
}
|
||||
|
||||
function setsPage(zone, sets, listSize) {
|
||||
function setsPage(zone, sets) {
|
||||
// '' WITHOUT DATA, exactly like funnelPage and christianPage — a page that cannot state true
|
||||
// figures must not appear at all. This is what keeps FULL valid before a host supplies sets.
|
||||
if (!sets || (!Array.isArray(sets.bounded) && !Array.isArray(sets.centered))) return '';
|
||||
const bounded = Array.isArray(sets.bounded) ? sets.bounded : [];
|
||||
const centered = Array.isArray(sets.centered) ? sets.centered : [];
|
||||
if (!bounded.length && !centered.length) return '';
|
||||
// ★ EVERY GROUP OF THE LIST IS COUNTED (2026-10-03). A group with no record in a figure's data (no
|
||||
// near-neighbour data, say) is in none of its figures, so a caption used to add up to less than the
|
||||
// list: Laos printed "88 placed · 0 at the centre · 6 unplaced", 94 of its 95. The groups missing from
|
||||
// a figure's data are now named in its caption ("1 without data"), so its parts add up to the list.
|
||||
const noData = (n) => (Number.isFinite(listSize) && listSize > n ? ` · ${fill(t('sets_no_data'), { n: num(listSize - n) })}` : '');
|
||||
const panels = [];
|
||||
let legend = '';
|
||||
|
||||
|
|
@ -1679,7 +1661,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
${cross(L.cx, L.cy, 44)}
|
||||
</svg>
|
||||
<p class="set-cap">${esc(fill(t('sets_bounded_tally'),
|
||||
{ inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }) + noData(bounded.length))}</p>
|
||||
{ inside: num(T.inside), outside: num(T.outside), unknown: num(T.unknown) }))}</p>
|
||||
</div>` });
|
||||
}
|
||||
|
||||
|
|
@ -1731,7 +1713,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
${bands.join('')}${dots}${nums.join('')}${cross(0, 0, 40)}
|
||||
</svg>
|
||||
<p class="set-cap">${esc(fill(t('sets_centered_tally'),
|
||||
{ placed: num(bf.placed.length), centre: num(T.centre), unplaced: num(T.unplaced + bf.unplaced.length) }) + noData(centered.length))}</p>
|
||||
{ placed: num(bf.placed.length), centre: num(T.centre), unplaced: num(T.unplaced + bf.unplaced.length) }))}</p>
|
||||
</div>` });
|
||||
const rows = ringLegend(T);
|
||||
// The legend first says what a ring IS, in plain words, then lists the rings that hold someone.
|
||||
|
|
@ -1898,7 +1880,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
|
|||
// packet lists the SAME groups as a worksheet (LIST_STYLE); the full packet as profiles.
|
||||
...(LIST_STYLE[variant] ? LIST_STYLE[variant](zone, groups, photos) : listPages(zone, groups, photos)),
|
||||
// BACK MATTER: after the profiles (brief A1). Full-detail only.
|
||||
keep('sets') && setsPage(zone, sets, Array.isArray(groups) ? groups.length : NaN),
|
||||
keep('sets') && setsPage(zone, sets),
|
||||
keep('wheel') && wheelPage(),
|
||||
keep('points') && pointsPage(),
|
||||
].filter(Boolean);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
// The sha384 of library/packet/poster-render.js. The browser fetches the served copy of that file
|
||||
// (<assets>/lib/poster-render.js), hashes it, and runs it only when it matches this pin: code loaded at
|
||||
// print time is integrity-pinned like every embed, because it executes on partner pages.
|
||||
export const POSTER_RENDER_SHA384 = 'sha384-BNFUrsdCCaEetmp5cSBzDklAUsB5XBEefQEcCd3+dNNDghxRuZNytNsVfl5mBOeP'
|
||||
export const POSTER_RENDER_SHA384 = 'sha384-TvWYyN1nK44Yjuw4yEIyrJmJG2zcAOTIBiTryFGtNHOI67cyWzEXRxHOmsRZagq1'
|
||||
// The sha384 of library/scripts/posters/data/ne-places.json, served at <assets>/boundaries/ne-places.json.
|
||||
// Data, not code, but pinned the same way: a sheet uses the places only when they are the pinned ones.
|
||||
export const NE_PLACES_SHA384 = 'sha384-Qtz2q8mcnA9Ei8Kwa2MRMyX95hSbSnUXjXdtsy8rHTX++mgSCih4LO4Rdrb2MIQy'
|
||||
// The sha384 of the classic-script copy (<assets>/lib/poster-render.global.js, globalVariant of the file above),
|
||||
// loaded with <script integrity> where a page's policy forbids importing a blob: of the verified text.
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-idoDSzFXNuBK27dktAJffZD6QhubGkNOk+g9vwPobzs0HWHfnUq3cSyX7FyzwvtQ'
|
||||
export const POSTER_RENDER_GLOBAL_SHA384 = 'sha384-N0M7S+HNC5jPovGXyhRpAVrGCgmjzt8tSxQ7GiZOnEeZ8FsBJZRVXeoOimLJhAea'
|
||||
// The sha384 of library/scripts/posters/packet-strings.json, served at <assets>/lib/packet-strings.json. The
|
||||
// packet, list and card text in every language, fetched at print time (packet/packetStrings.js) and used only
|
||||
// when it matches; otherwise the print falls back to the bundled English block.
|
||||
export const PACKET_STRINGS_SHA384 = 'sha384-YAzPNeMU+DF26OIEJrzo+Rr5f3lvoCjhae292a4tvGQZo5fggUQroiBVJCgBDtTF'
|
||||
export const PACKET_STRINGS_SHA384 = 'sha384-NhjD/zyyBCJBF6Jj9IZ0UBtAbX5pw8hdKJTSXdVoahkzK82YpeyT2m733E0H0p4/'
|
||||
|
|
|
|||
|
|
@ -287,14 +287,10 @@ const HEAD_COLUMNS_CSS = `
|
|||
// at 22 / 21px with darker ink: about 8pt on the printed page. Only the full layout (HEAD_COLUMNS) gets this; the
|
||||
// series-wide rules above stay as they were, so the series' SPARSE posters are byte-identical.
|
||||
// Print size = sheet px x 0.92 (the packet's margins) x 0.408 (1500px = 8.5in): 22px ≈ 8.3pt, 19px ≈ 7.1pt.
|
||||
// ★ THE NAMES COME FIRST (2026-10-03): the header columns step down to 18px (≈6.8pt; keys 15.5, headings 19) so
|
||||
// the people-group names beside the map can be the larger type. The shorter band gives the map ~80px more height,
|
||||
// and the names' cap on this layout rises to 19px (see MAXF). These sizes must equal HC's (fs, keyFs, headFs).
|
||||
const HEAD_COLUMNS_FULL_CSS = `
|
||||
.hcolk{font-size:15.5px;fill:#2f2a22;font-weight:800;letter-spacing:.1em}
|
||||
.hcolt{font-size:18px;fill:#3a362c}
|
||||
.hcolb{font-weight:700;fill:#2f2a22}
|
||||
.hcolh{font-family:Georgia,'Times New Roman',serif;font-size:19px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps}
|
||||
.hcolk{font-size:19px;fill:#2f2a22;font-weight:800;letter-spacing:.1em}
|
||||
.hcolt{font-size:22px;fill:#3a362c}
|
||||
.hcolh{font-family:Georgia,'Times New Roman',serif;font-size:23px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps}
|
||||
.hvref{font-size:19px}
|
||||
.hverse{font-size:19.5px}
|
||||
.hsrc{font-size:22px;fill:#4a4636}
|
||||
|
|
@ -694,9 +690,7 @@ function layoutLabels(pins, project) {
|
|||
col.forEach((it) => { it.maxNum = MAXNUM_L; });
|
||||
// A map-only sheet frees the header and footer bands the same way SPARSE frees the vision
|
||||
// columns, so it gets SPARSE's ceiling; the solver still only uses what the column can hold.
|
||||
// The three-column sheet (HEAD_COLUMNS) gets it too: there the names are the main text, and the cap of 15
|
||||
// held 21 of the 28 zones' names at 15px (≈5.6pt printed) with room to spare in the band.
|
||||
const MAXF = (SPARSE || MAP_ONLY || HEAD_COLUMNS) ? 19 : 15;
|
||||
const MAXF = (SPARSE || MAP_ONLY) ? 19 : 15;
|
||||
// AVAIL IS THE COLUMN, AND THE NAME DOES NOT GET ALL OF IT.
|
||||
// The renderer draws the name starting one number-column IN from the column's
|
||||
// map-facing edge (`nameEdge = tx ± numColW`) but does not shrink its budget by
|
||||
|
|
@ -730,23 +724,18 @@ function layoutLabels(pins, project) {
|
|||
if (line) out.push(line);
|
||||
return out.length ? out : [it.name];
|
||||
};
|
||||
// The air between rows, as a share of the size. The three-column sheet sets it tighter (0.22): its densest
|
||||
// zones (95-118 names) are held by the band's height, and the tighter rows turn that height into type
|
||||
// (west-africa 12.5 -> 13.8px, laos 15.8 -> 17.3px). A row's underline sits 0.30 below its baseline and the
|
||||
// next row's capitals start 0.56 below it, so they stay clear. Every other sheet keeps 0.34.
|
||||
const GAPF = HEAD_COLUMNS ? 0.22 : 0.34;
|
||||
let font = MAXF, wraps = null;
|
||||
for (let f = MAXF; f >= 8; f -= 0.25) {
|
||||
const ws = col.map((it) => wrapName(it, f));
|
||||
const totalLines = ws.reduce((a, w) => a + w.length, 0);
|
||||
// Each line needs f*1.06 of pitch, plus a little air between rows.
|
||||
if (totalLines * (f * 1.06) + col.length * (f * GAPF) <= bandH) { font = f; wraps = ws; break; }
|
||||
if (totalLines * (f * 1.06) + col.length * (f * 0.34) <= bandH) { font = f; wraps = ws; break; }
|
||||
}
|
||||
if (!wraps) wraps = col.map((it) => wrapName(it, font));
|
||||
// Re-pitch rows by LINE COUNT so a two-line name gets the room it needs and
|
||||
// single-line rows are not stretched to match it.
|
||||
const lineH = font * 1.06;
|
||||
const gap = font * GAPF;
|
||||
const gap = font * 0.34;
|
||||
const usedH = wraps.reduce((a, w) => a + w.length * lineH, 0) + col.length * gap;
|
||||
const slack = Math.max(0, bandH - usedH) / Math.max(1, col.length);
|
||||
let cursor = bandTop + slack / 2;
|
||||
|
|
@ -841,8 +830,7 @@ function paraInto(push, text, { x, y, w, fs, lh, cls, lead }) {
|
|||
// 1 Jesus' strategy to feed the 5000
|
||||
// 2 the facts: a bold key above each value (countries, administrative areas, people groups, series)
|
||||
// 3 what the series is
|
||||
// The sizes equal HEAD_COLUMNS_FULL_CSS (.hcolt = fs, .hcolk = keyFs, .hcolh = headFs); leading and gaps scale with them.
|
||||
const HC = { gut: 32, fs: 18, lh: 22, keyFs: 15.5, keyLh: 19.5, headLh: 24.5, gap: 7, headFs: 19 };
|
||||
const HC = { gut: 32, fs: 22, lh: 27, keyFs: 19, keyLh: 24, headLh: 30, gap: 8, headFs: 23 };
|
||||
// textW is calibrated for Lato. Without Lato the browser falls back to a system sans that sets
|
||||
// these columns 2-6% wider (measured 1.05-1.06x in DejaVu Sans). Each block is centred on its
|
||||
// width at 1.03x, halfway, so any overrun splits across both sides instead of all running right.
|
||||
|
|
@ -863,50 +851,17 @@ const strategyParas = () => (POSTER_TEXT.strategyBody ? [].concat(POSTER_TEXT.st
|
|||
// series.json (poster.sideBoxMore: one paragraph, or a list; [] prints none), as UPG100 does to keep its band even.
|
||||
const whatIsMore = () => (POSTER_TEXT.sideBoxMore !== undefined ? [].concat(POSTER_TEXT.sideBoxMore) : [WHAT_IS_MORE]);
|
||||
const WHAT_IS_MORE = 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.';
|
||||
// A paragraph's lines as SVG text content, its **...** runs set bold (.hcolb); the markers are not printed.
|
||||
// The wrap measures the plain text: a short bold run is a few px wider, inside the measure's room.
|
||||
function boldLines(text, width) {
|
||||
let plain = '', bold = [], on = false;
|
||||
for (const part of String(text).replace(/\s+/g, ' ').trim().split('**')) {
|
||||
for (let i = 0; i < part.length; i++) { plain += part[i]; bold.push(on); } // per UTF-16 unit, as plain[p] reads
|
||||
on = !on;
|
||||
}
|
||||
// A scripture reference never breaks between its book and its chapter:verse ("Mark 6:40"): that space becomes a
|
||||
// placeholder for the wrap and prints as a space again. One character for one, so the bold flags stay aligned.
|
||||
plain = plain.replace(/([A-Za-z]+) (\d+:\d+)/g, '$1\u0001$2');
|
||||
let p = 0;
|
||||
return wrapPx(plain, HC.fs, width).map((ln) => {
|
||||
while (plain[p] === ' ') p++;
|
||||
let out = '', run = '', runBold = null;
|
||||
const flush = () => { if (run) out += runBold ? `<tspan class="hcolb">${esc(run)}</tspan>` : esc(run); run = ''; };
|
||||
for (let k = 0; k < ln.length; k++, p++) {
|
||||
if (bold[p] !== runBold) { flush(); runBold = bold[p]; }
|
||||
run += plain[p] === '\u0001' ? ' ' : plain[p];
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
});
|
||||
}
|
||||
function headColumnsBand(top, facts, approxLists) {
|
||||
const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G;
|
||||
const AVAIL = GRID_W - 2 * HC.gut;
|
||||
const CW = (GRID_W - 2 * HC.gut) / 3;
|
||||
// The measure leaves room for a fallback font (see HC_FALLBACK): a block set to it stays inside its column.
|
||||
const measure = (cw) => (cw - 10 - BAND_TEXT_PAD / 2) * 0.92;
|
||||
// ★ EVEN COLUMNS (2026-10-03): when the facts column runs longer than the two text columns it is WIDENED (and
|
||||
// shorter, it is narrowed) so the three end at about the same height. The two side columns stay equal, so they
|
||||
// still mirror each other round the centre. The centre's share is searched from 26% to 46% of the three-column
|
||||
// width; the split whose TALLEST column is shortest wins, and a tie goes to the split nearest equal widths, so a
|
||||
// zone that is already balanced keeps equal columns. measureHeader calls this same function, so the map below
|
||||
// gets exactly the height that is left.
|
||||
const TW = (CW - 10 - BAND_TEXT_PAD / 2) * 0.92;
|
||||
// Uppercase, letterspaced keys: measured as capitals plus the .14em tracking.
|
||||
const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14;
|
||||
// Each column's height at a given measure (the same arithmetic the drawing below advances y by).
|
||||
const proseH = (col, tw) => HC.headLh + col[0].paras.reduce((h, t, k) => h + (k ? HC.gap : 0) + boldLines(t, tw).length * HC.lh, 0);
|
||||
const factsH = (tw) => facts.reduce((h, [, value], k) => h + (k ? HC.gap : 0) + HC.keyLh + wrapPx(value, HC.fs, tw).length * HC.lh, 0);
|
||||
// Column order (2026-10-02): Jesus' strategy | the facts | what the series is.
|
||||
const cols = [
|
||||
[{ head: 'Jesus’ Strategy to Feed the 5000', paras: strategyParas() }],
|
||||
null, // the facts column, wrapped below at the centre's measure
|
||||
facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, TW) })),
|
||||
[{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [
|
||||
(POSTER_TEXT.sideBoxBody
|
||||
|| 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.')
|
||||
|
|
@ -914,22 +869,9 @@ function headColumnsBand(top, facts, approxLists) {
|
|||
...whatIsMore(),
|
||||
] }],
|
||||
];
|
||||
// The split: equal thirds first, then centre shares on a 1% step ordered by their distance from a third, so
|
||||
// among splits whose tallest column is equally short, the one nearest equal widths is found first and kept.
|
||||
const shares = Array.from({ length: 21 }, (_, k) => 0.26 + k * 0.01).sort((a, b) => Math.abs(a - 1 / 3) - Math.abs(b - 1 / 3));
|
||||
let best = null;
|
||||
for (const cwC of [1 / 3, ...shares].map((f) => AVAIL * f)) {
|
||||
const cwS = (AVAIL - cwC) / 2;
|
||||
const tall = Math.max(proseH(cols[0], measure(cwS)), factsH(measure(cwC)), proseH(cols[2], measure(cwS)));
|
||||
if (!best || tall < best.tall - 0.5) best = { cwC, cwS, tall };
|
||||
}
|
||||
const widths = [best.cwS, best.cwC, best.cwS];
|
||||
const lefts = [GRID_L, GRID_L + best.cwS + HC.gut, GRID_L + best.cwS + HC.gut + best.cwC + HC.gut];
|
||||
cols[1] = facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, measure(best.cwC)) }));
|
||||
let frags = '', bottom = top;
|
||||
cols.forEach((col, i) => {
|
||||
const CW = widths[i], TW = measure(CW);
|
||||
const centre = lefts[i] + CW / 2;
|
||||
const centre = GRID_L + i * (CW + HC.gut) + CW / 2;
|
||||
if (col[0] && col[0].key !== undefined) { // the facts column: a bold key above each value
|
||||
const blockW = Math.min(TW, Math.max(...col.map((r) => Math.max(keyW(r.key), ...r.lines.map((ln) => textW(ln, HC.fs))))));
|
||||
const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
|
||||
|
|
@ -943,22 +885,20 @@ function headColumnsBand(top, facts, approxLists) {
|
|||
bottom = Math.max(bottom, y);
|
||||
} else {
|
||||
const { head, paras } = col[0];
|
||||
// ★ MIRRORED (2026-10-03): both prose columns take the full measure, so each sits the same distance from
|
||||
// its rule and the two outer columns mirror each other round the centred facts column. (Each used to be
|
||||
// centred on its own widest line, so the two could sit at different distances from their rules.)
|
||||
const blockW = TW;
|
||||
const wrapped = paras.map((t) => wrapPx(t, HC.fs, TW));
|
||||
const blockW = Math.min(TW, Math.max(textW(head, HC.headFs), ...wrapped.flat().map((ln) => textW(ln, HC.fs))));
|
||||
const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
|
||||
frags += `<text x="${x}" y="${top}" class="hcolh">${esc(head)}</text>`;
|
||||
let y = top + HC.headLh;
|
||||
paras.forEach((text, k) => {
|
||||
wrapped.forEach((lines, k) => {
|
||||
if (k) y += HC.gap;
|
||||
for (const ln of boldLines(text, TW)) { frags += `<text x="${x}" y="${y}" class="hcolt">${ln}</text>`; y += HC.lh; }
|
||||
for (const ln of lines) { frags += `<text x="${x}" y="${y}" class="hcolt">${esc(ln)}</text>`; y += HC.lh; }
|
||||
});
|
||||
bottom = Math.max(bottom, y);
|
||||
}
|
||||
});
|
||||
for (let i = 1; i < 3; i++) {
|
||||
const rx = (lefts[i] - HC.gut / 2).toFixed(1);
|
||||
const rx = (GRID_L + i * (CW + HC.gut) - HC.gut / 2).toFixed(1);
|
||||
frags += `<line x1="${rx}" y1="${top - 14}" x2="${rx}" y2="${bottom - 12}" class="hcolrule"/>`;
|
||||
}
|
||||
return { frags, bottom };
|
||||
|
|
@ -2404,7 +2344,7 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
let cy0 = bodyTop;
|
||||
strategyParas().forEach((text, k) => {
|
||||
if (k) cy0 += 5;
|
||||
cy0 = paraInto(push, String(text).replace(/\*\*/g, ''), { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
|
||||
cy0 = paraInto(push, text, { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
|
||||
});
|
||||
|
||||
let cy1 = bodyTop;
|
||||
|
|
@ -2415,11 +2355,9 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
|| 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.')
|
||||
.replace('{approxLists}', String(approxLists))
|
||||
.replace('{seriesName}', SERIES_NAME);
|
||||
// A **...** bold marker is the band's (boldLines); this classic header prints plain, so the markers are dropped.
|
||||
const plain = (t) => String(t).replace(/\*\*/g, '');
|
||||
[whatIs, ...whatIsMore()].forEach((text, k) => {
|
||||
if (k) cy1 += 5;
|
||||
cy1 = paraInto(push, plain(text), { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
|
||||
cy1 = paraInto(push, text, { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
|
||||
});
|
||||
|
||||
const b1Bot = Math.max(cy0, cy1);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
// The Subresource-Integrity form of a text's SHA-384 ("sha384-<base64>"), via WebCrypto, so a file fetched at
|
||||
// print time can be compared with the pin baked into the bundle (poster-render.integrity.js) before it is used.
|
||||
// The same value pin-poster-render.mjs computes with node:crypto.
|
||||
// ★ ONLY ON A SECURE PAGE. A browser gives crypto.subtle only to a secure page (https, or http://localhost). Opened any
|
||||
// other way (a dev server reached over plain http by its LAN address, from a phone on the same Wi-Fi), every pinned
|
||||
// file was refused with a bare "reading 'digest'": the packet printed in English and page 1 fell back to its stored
|
||||
// sheet, with nothing saying why. Say it in words.
|
||||
export async function sha384(text) {
|
||||
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
||||
if (!subtle) throw new Error('pinned files can only be checked on a secure page (https, or http://localhost); this page is not one')
|
||||
const d = new Uint8Array(await subtle.digest('SHA-384', new TextEncoder().encode(text)))
|
||||
const d = new Uint8Array(await crypto.subtle.digest('SHA-384', new TextEncoder().encode(text)))
|
||||
let bin = ''
|
||||
for (const b of d) bin += String.fromCharCode(b)
|
||||
return `sha384-${btoa(bin)}`
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,401 +0,0 @@
|
|||
// legend-tree.js — slice 04t §1(a)
|
||||
//
|
||||
// Pure shaper: buildLegendNodes(clusters) -> TreeNode[]
|
||||
//
|
||||
// Turns clusters.json (13 regional `lists` + 2 `deaf_lists` + 12 `asia_lists`)
|
||||
// into the NESTED `nodes` tree consumed by the forge-shared
|
||||
// src/components/SemanticTreeLegend.vue (S04t-D4: nested). It does NOT rebuild a
|
||||
// legend — it only shapes data.
|
||||
//
|
||||
// Tree node shape (per SemanticTreeLegend.vue:13-30):
|
||||
// { id, label, color?, count?, pop?, filter?, info?, children?: TreeNode[] }
|
||||
//
|
||||
// Output shape (26 LEAF rows total):
|
||||
// - 13 Region leaves (top level)
|
||||
// - 1 Deaf leaf (top level; the two deaf_lists collapse into ONE
|
||||
// A017 row, count 123 — R1.1 design correction)
|
||||
// - 1 Asia parent ▸ India (10 state-cluster leaves) / China (2 leaves)
|
||||
//
|
||||
// Parents (Asia / India / China) carry rolled-up count. A stable zoneKey === leaf
|
||||
// id is assigned so the paint layer's ['match', ['get','zoneKey'], …] and the
|
||||
// legend's `select` emit share one key space.
|
||||
|
||||
// Deterministic per-leaf color: even hue spread across the 26 leaves, comma-HSL
|
||||
// (Mapbox paint expressions reject the modern slash hsl() syntax). Mirrors the
|
||||
// slice-04b color rule (profiles/upg-100-list.vue:77).
|
||||
export function leafColor(i, n) {
|
||||
return `hsl(${Math.round((i * 360) / (n || 1))}, 72%, 56%)`;
|
||||
}
|
||||
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
// Count for one leaf: regional/asia carry `count`, deaf carries the A017 total.
|
||||
function leafCount(row) {
|
||||
if (typeof row.count === 'number') return row.count;
|
||||
if (typeof row.expected_count === 'number') return row.expected_count;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── slice 04u §1(b): population per leaf ─────────────────────────────────────
|
||||
// Sum parseInt(member.population) over a zone's MEMBER records — the same member
|
||||
// set used for `count`. population is a flat string on the live record (full
|
||||
// recache parses it to int; parseInt here is belt-and-suspenders for either form).
|
||||
function popOf(record) {
|
||||
const n = parseInt(record && record.population, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
// Resolve a zone's member records, then sum their population.
|
||||
// - regional + deaf rows use the selector-evaluator (country_code / wagf_* /
|
||||
// union / affinity_bloc) — same path zone-geometry uses for `count`.
|
||||
// - ASIA rows carry a `pg_slugs` selector the evaluator intentionally REJECTS
|
||||
// (it resolves geometry by pre-tagged listId, not by evaluating slugs). For
|
||||
// population we sum records whose slug ∈ selector.values directly — no
|
||||
// evaluator change, no shared-src edit.
|
||||
function leafPopulation(row, records) {
|
||||
if (!Array.isArray(records) || records.length === 0) return undefined;
|
||||
const sel = row && row.selector;
|
||||
if (sel == null) return undefined;
|
||||
|
||||
// Asia state-clusters: explicit pg_slugs member list.
|
||||
if (sel.type === 'pg_slugs') {
|
||||
const want = new Set(Array.isArray(sel.values) ? sel.values : []);
|
||||
let sum = 0;
|
||||
for (const r of records) if (r && want.has(r.slug)) sum += popOf(r);
|
||||
return sum;
|
||||
}
|
||||
|
||||
// Everything else the evaluator handles → Set<slug> → sum member population.
|
||||
const slugs = evaluate(sel, records);
|
||||
let sum = 0;
|
||||
for (const r of records) if (r && slugs.has(r.slug)) sum += popOf(r);
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* buildLegendNodes(clusters, opts?) -> TreeNode[]
|
||||
* @param {object} clusters parsed clusters.json
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.deafCount=123] A017 PG count for the unified deaf leaf
|
||||
* @param {Array} [opts.records] normalized live records — when supplied, every
|
||||
* node also gets `pop` (Σ member population; parents roll up).
|
||||
* Omit to keep the prior count-only behaviour (pop undefined).
|
||||
* @returns {Array} nested TreeNode[] (3 top-level groups, 26 leaves total)
|
||||
*/
|
||||
// ★ THE UNIFIED DEAF LEAF'S SELECTOR, DEFINED ONCE. The legend collapses the two deaf_lists into one
|
||||
// leaf (affinity bloc A017) whose selector exists nowhere in clusters.json, so every consumer that
|
||||
// RESOLVES zones had to know it — and each typed its own copy of the literal (the map in
|
||||
// upg-100-list.vue, generate.mjs, packet.mjs). The browser packet path had no copy at all, which is why
|
||||
// the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently.
|
||||
export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
|
||||
|
||||
// The catalogue key (poster.<key>) that names each REGION_OF grouping in the reader's language. The
|
||||
// English values stay the grouping identity (ids are derived from them), so the heading is translated
|
||||
// at render: here for the legend via opts.regionTitle, and by poster-wall.vue for the wall's sections.
|
||||
// One table for both surfaces, so a region added here cannot be translated on one and English on the other.
|
||||
export const REGION_KEY = {
|
||||
'Africa': 'region_africa',
|
||||
'Middle East': 'region_middle_east',
|
||||
'Central Asia': 'region_central_asia',
|
||||
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
|
||||
'China + Mongolia': 'region_china_mongolia',
|
||||
'India': 'region_india',
|
||||
'Other': 'region_other',
|
||||
'Americas': 'region_americas',
|
||||
'Europe': 'region_europe',
|
||||
'Deaf': 'region_deaf',
|
||||
};
|
||||
|
||||
export function buildLegendNodes(clusters, opts = {}) {
|
||||
const deafCount = opts.deafCount ?? 123;
|
||||
const records = Array.isArray(opts.records) ? opts.records : null;
|
||||
// ★ OPTIONAL GENERATED TITLE (2026-09-27). `opts.title(zone)` returns a localized title
|
||||
// composed from already-translated parts, or NULL for a zone whose title cannot be generated — see
|
||||
// data/zone-title.js. Passing nothing keeps every hand-written English label exactly as it was, which
|
||||
// is why this is an option rather than a rewrite: the legend, the poster and the packet adopt it
|
||||
// independently, and a caller that has no records yet (records:null) still builds a correct tree.
|
||||
// ⚠ NULL MEANS "KEEP YOURS", NOT "NO TITLE". 18 of 28 zones decline (their labels are sub-national or
|
||||
// taxonomic, and Intl has no subdivisions), so the fallback is the normal path, not the error path.
|
||||
const titleOf = typeof opts.title === 'function'
|
||||
? (zone, fallback) => opts.title(zone) || fallback
|
||||
: (_zone, fallback) => fallback;
|
||||
// `opts.regionTitle(english)` names a region section in the reader's language (see REGION_KEY); the
|
||||
// node id stays derived from the English grouping key, so selection and ids never depend on a locale.
|
||||
const regionTitleOf = typeof opts.regionTitle === 'function'
|
||||
? (english) => opts.regionTitle(english) || english
|
||||
: (english) => english;
|
||||
const lists = clusters.lists || [];
|
||||
const asia = clusters.asia_lists || [];
|
||||
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
|
||||
// Himalaya, SA-West, Central-Asia, Mongolia, Japan, Scatter). They carry a
|
||||
// `country_codes` selector → resolved by zone-geometry's REGIONAL/ADM0 path
|
||||
// (same as the 13 regional rows), NOT the asia ADM1 path. Default empty so
|
||||
// pre-09b-6 clusters (no uncovered_lists) keep the prior 26-leaf shape.
|
||||
const uncovered = clusters.uncovered_lists || [];
|
||||
|
||||
// Deaf row's selector is implicit (affinity bloc A017), so population can be
|
||||
// summed the same way as the regional rows. Module-level and exported — see DEAF_SELECTOR below.
|
||||
|
||||
// ── ordered leaf list (drives color index space) ───────────────────────────
|
||||
// order: 13 regional, 1 deaf, asia (10 India + 2 China), then the Frontier-Asia
|
||||
// uncovered rows — matches the legend reading order and the paint match arms.
|
||||
// ★ COUNTS ARE DERIVED FROM clusters.json, never asserted here (2026-09-21). This comment
|
||||
// used to claim "33 leaves" and "7 Frontier-Asia uncovered"; the shipped clusters.json
|
||||
// carries 13 + 12 + 2 + 1 deaf = 28, with exactly TWO uncovered rows
|
||||
// (uncovered-scatter, uncovered-nas-a). The stale 26-vs-reality gap also shipped as a
|
||||
// "27/26 zones" badge in upg-100-list.vue. A hardcoded count next to data that grows is a
|
||||
// drift waiting to happen — read the arrays instead.
|
||||
const indiaRows = asia.filter((a) => a.id.startsWith('asia-india'));
|
||||
const chinaRows = asia.filter((a) => a.id.startsWith('asia-china'));
|
||||
const orderedLeafIds = [
|
||||
...lists.map((l) => l.id),
|
||||
'deaf',
|
||||
...indiaRows.map((r) => r.id),
|
||||
...chinaRows.map((r) => r.id),
|
||||
...uncovered.map((u) => u.id),
|
||||
];
|
||||
const N = orderedLeafIds.length; // 33 (26 + 7 uncovered)
|
||||
const colorOf = (id) => leafColor(orderedLeafIds.indexOf(id), N);
|
||||
|
||||
// `pop` is attached only when records were supplied; undefined otherwise so the
|
||||
// prior count-only callers are unaffected. `withPop` adds the key conditionally.
|
||||
const withPop = (node, row, selector) => {
|
||||
if (!records) return node;
|
||||
const sel = selector ?? (row && row.selector);
|
||||
node.pop = leafPopulation({ selector: sel }, records);
|
||||
return node;
|
||||
};
|
||||
// Sum child pops (parent roll-up, S04u-D3). Returns undefined when no records.
|
||||
const sumPop = (children) =>
|
||||
records ? children.reduce((s, c) => s + (c.pop || 0), 0) : undefined;
|
||||
|
||||
// ── 13 Region leaves ───────────────────────────────────────────────────────
|
||||
const regionLeaves = lists.map((l) =>
|
||||
withPop(
|
||||
{
|
||||
id: l.id,
|
||||
zoneKey: l.id,
|
||||
label: titleOf(l, l.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(l.provinceLabel ? { provinceLabel: l.provinceLabel } : {}),
|
||||
color: colorOf(l.id),
|
||||
count: leafCount(l),
|
||||
},
|
||||
l,
|
||||
),
|
||||
);
|
||||
|
||||
// ── 1 Deaf leaf (unified A017; R1.1) ───────────────────────────────────────
|
||||
// D2: deaf pop = Σ population of the 123 A017 members. This WILL double-count
|
||||
// vs the geographic rows (those members also fall inside a regional zone) —
|
||||
// the design-approved deaf double-count (slice 08). Per-zone equality still
|
||||
// holds; only a naïve global sum would over-count, which the proof avoids.
|
||||
const deafLeaf = withPop(
|
||||
{
|
||||
id: 'deaf',
|
||||
zoneKey: 'deaf',
|
||||
// Named like every other leaf: opts.title reads the bloc's name from the strings it was given
|
||||
// (zone-title.js, zone_affinity_A017); the English label is only the no-title fallback.
|
||||
label: titleOf({ id: 'deaf', selector: DEAF_SELECTOR }, 'Deaf (affinity bloc A017)'),
|
||||
color: colorOf('deaf'),
|
||||
count: deafCount,
|
||||
info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).',
|
||||
},
|
||||
null,
|
||||
DEAF_SELECTOR,
|
||||
);
|
||||
|
||||
// ── Asia parent ▸ India / China ────────────────────────────────────────────
|
||||
const mkAsiaLeaf = (r) =>
|
||||
withPop(
|
||||
{
|
||||
id: r.id,
|
||||
zoneKey: r.id,
|
||||
label: titleOf(r, r.label),
|
||||
// The province enumeration the region-name label replaced. Carried through
|
||||
// like adm0/provinces above — a node rebuilt field-by-field silently drops
|
||||
// anything not named, which is exactly how adm0 and population were lost.
|
||||
...(r.provinceLabel ? { provinceLabel: r.provinceLabel } : {}),
|
||||
color: colorOf(r.id),
|
||||
count: leafCount(r),
|
||||
// MIXED GEOMETRY: carry the optional `adm0` ISO3 list through to the node.
|
||||
// zone-geometry.js ALREADY supports it — an Asia zone may union whole ADM0
|
||||
// countries alongside its ADM1 provinces, and its comment names the Nepal
|
||||
// case explicitly — but the node was rebuilt field-by-field here and
|
||||
// silently dropped adm0, so the hook could never fire.
|
||||
...(Array.isArray(r.adm0) && r.adm0.length ? { adm0: r.adm0 } : {}),
|
||||
// Same passthrough reason as adm0: the province enumeration is data the
|
||||
// poster header prints, and rebuilding the node field-by-field drops it.
|
||||
...(Array.isArray(r.provinces) && r.provinces.length ? { provinces: r.provinces } : {}),
|
||||
},
|
||||
r,
|
||||
);
|
||||
const indiaChildren = indiaRows.map(mkAsiaLeaf);
|
||||
const chinaChildren = chinaRows.map(mkAsiaLeaf);
|
||||
|
||||
// (The India / China / "Asia (admin-1)" and "Frontier Asia (uncovered)" PARENT nodes that used to be
|
||||
// built here are gone: since the regional grouping below, the tree is assembled from the LEAVES, so those
|
||||
// parents were constructed and never returned — dead code whose English labels read as reader text.)
|
||||
|
||||
// ── Frontier Asia parent ▸ 7 uncovered zones (09b-6 §1a, W-D1) ─────────────
|
||||
// Each uncovered row carries a `country_codes` selector → ADM0 polygons
|
||||
// (Nepal→NPL, SA-West→AFG+PAK, …). Same leaf shape as regional rows so the
|
||||
// tapestry's single ['match',['get','zoneKey'],…] paints them with the rest.
|
||||
const mkUncoveredLeaf = (u) =>
|
||||
withPop(
|
||||
{
|
||||
id: u.id,
|
||||
zoneKey: u.id,
|
||||
label: titleOf(u, u.label),
|
||||
// Carry the province enumeration the region-name label replaced.
|
||||
...(u.provinceLabel ? { provinceLabel: u.provinceLabel } : {}),
|
||||
color: colorOf(u.id),
|
||||
count: leafCount(u),
|
||||
},
|
||||
u,
|
||||
);
|
||||
const uncoveredChildren = uncovered.map(mkUncoveredLeaf);
|
||||
|
||||
// ── REGIONAL GROUPING — the SAME sections the poster page uses ─────────────
|
||||
// The legend used to emit 14 LOOSE LEAVES at top level (West Africa, Sudan,
|
||||
// Brazil, Laos, …) beside two odd parents, "Asia (admin-1)" and "Frontier Asia
|
||||
// (uncovered)". That grouping read poorly beside the poster page, so the legend
|
||||
// now uses the poster page's own sections.
|
||||
//
|
||||
// So the same 28 zones now group into the poster page's nine regions. The
|
||||
// mapping is duplicated from poster-wall.vue rather than imported because this
|
||||
// file is a PURE SHAPER with no imports by design — but the two lists must stay
|
||||
// in step, so any edit here belongs in poster-wall.vue's REGION_OF too.
|
||||
const REGION_OF = {
|
||||
'west-africa': 'Africa', 'sudan': 'Africa', 'east-africa': 'Africa',
|
||||
'central-africa': 'Africa', 'southern-africa-islands': 'Africa',
|
||||
'maghreb-egypt': 'Middle East', 'middle-east-core': 'Middle East',
|
||||
'uncovered-nas-a': 'Central Asia',
|
||||
'laos': 'Southeast Asia + Oceania', 'oceania-indonesia': 'Southeast Asia + Oceania',
|
||||
'mainland-se-asia-philippines': 'Southeast Asia + Oceania',
|
||||
'brazil': 'Americas', 'andean-caribbean': 'Americas', 'uncovered-scatter': 'Americas',
|
||||
'europe': 'Europe', 'deaf': 'Deaf', 'deaf-old-world': 'Deaf',
|
||||
};
|
||||
const regionFor = (id) =>
|
||||
REGION_OF[id]
|
||||
|| (String(id).startsWith('asia-india-') ? 'India'
|
||||
: String(id).startsWith('asia-china-') ? 'China + Mongolia'
|
||||
: 'Other');
|
||||
|
||||
// Every leaf the tree can paint, flattened out of its source block. India and
|
||||
// China leaves are indiaChildren / chinaChildren; the rest are already flat.
|
||||
const allLeaves = [
|
||||
...regionLeaves,
|
||||
deafLeaf,
|
||||
...indiaChildren,
|
||||
...chinaChildren,
|
||||
...uncoveredChildren,
|
||||
].filter(Boolean);
|
||||
|
||||
const byRegion = new Map();
|
||||
for (const leaf of allLeaves) {
|
||||
const r = regionFor(leaf.id || leaf.zoneKey);
|
||||
if (!byRegion.has(r)) byRegion.set(r, []);
|
||||
byRegion.get(r).push(leaf);
|
||||
}
|
||||
|
||||
// Sections ordered by TOTAL people groups (largest first), leaves within a
|
||||
// section by their own count — the densest prayer need leads at both levels,
|
||||
// matching the poster page exactly.
|
||||
const tops = [...byRegion.entries()]
|
||||
.map(([label, children]) => {
|
||||
children.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
const node = {
|
||||
// ★ FOLD DIACRITICS BEFORE STRIPPING, or the mark is DELETED and the id silently becomes a
|
||||
// different id: Gujarāt -> 'region-gujar-t', not 'region-gujarat'. Today's labels are the
|
||||
// fixed ASCII set from regionFor(), so nothing is broken — but the whole point is that
|
||||
// a fork brings its OWN people-group set and names its own regions, and the first accented
|
||||
// label would split one region into two with no error and no empty state. Same class as a measured
|
||||
// join of admin-to-list.json: a naive fold matched 514 of 1009 rows and lost THREE
|
||||
// whole zones, while every surviving zone still looked populated. Fix the class, not the six
|
||||
// spellings we happen to know.
|
||||
id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
label: regionTitleOf(label),
|
||||
count: children.reduce((n, c) => n + (c.count || 0), 0),
|
||||
children,
|
||||
};
|
||||
if (records) node.pop = sumPop(children);
|
||||
return node;
|
||||
})
|
||||
.sort((a, b) => (b.count || 0) - (a.count || 0));
|
||||
|
||||
// ── L15 consumer-shape assertion (09b-6 §1b) ───────────────────────────────
|
||||
// Every RENDERED LEAF — regardless of source block (lists / asia_lists /
|
||||
// uncovered_lists) — must carry the same required fields so zone-geometry and
|
||||
// the paint match expression read a uniform shape. Fail loud (throw) rather
|
||||
// than silently paint a malformed zone.
|
||||
assertUniformLeafShape(leafZones(tops), { withPop: !!records });
|
||||
|
||||
return tops;
|
||||
}
|
||||
|
||||
// L15 (09b-6 §1b): assert every leaf zone has the uniform consumer shape.
|
||||
// Required on ALL leaves: id, zoneKey, label, color (string), count (number).
|
||||
// When records were supplied, `pop` must also be a number on every leaf.
|
||||
// Parents (asia/india/china/frontier-asia) are intentionally NOT leaves and are
|
||||
// excluded by leafZones() — they carry roll-ups, not a zoneKey/selector.
|
||||
export function assertUniformLeafShape(leaves, { withPop = false } = {}) {
|
||||
const bad = [];
|
||||
for (const z of leaves || []) {
|
||||
const errs = [];
|
||||
if (typeof z.id !== 'string' || !z.id) errs.push('id');
|
||||
if (typeof z.zoneKey !== 'string' || !z.zoneKey) errs.push('zoneKey');
|
||||
if (typeof z.label !== 'string' || !z.label) errs.push('label');
|
||||
if (typeof z.color !== 'string' || !z.color) errs.push('color');
|
||||
if (typeof z.count !== 'number') errs.push('count');
|
||||
if (withPop && typeof z.pop !== 'number') errs.push('pop');
|
||||
if (errs.length) bad.push({ id: z.id || '(no-id)', errs });
|
||||
}
|
||||
if (bad.length) {
|
||||
// A developer assertion: the text is composed here, inside the throw, so it is plainly not reader copy.
|
||||
throw new Error(
|
||||
`legend-tree L15: ${bad.length} leaf node(s) violate the uniform consumer shape:\n ` +
|
||||
bad.map((b) => `${b.id}: missing/!type [${b.errs.join(', ')}]`).join('\n '),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Flatten a nodes tree to its LEAF nodes (rows that own a zoneKey/paint zone).
|
||||
export function leafZones(nodes) {
|
||||
const out = [];
|
||||
const walk = (arr) => {
|
||||
for (const n of arr) {
|
||||
if (n.children && n.children.length) walk(n.children);
|
||||
else out.push(n);
|
||||
}
|
||||
};
|
||||
walk(nodes || []);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every printable zone — the legend's leaves — each carrying the selector that resolves its groups.
|
||||
*
|
||||
* ★ THE ENUMERATION IS THE LEGEND'S, NOT clusters.json's BUCKETS. Spreading lists/asia_lists/
|
||||
* uncovered_lists gives 27 zones and silently drops `deaf`, which the legend shows as one row; a
|
||||
* packet lookup built that way cannot print Deaf, and a count built that way prints "one of 27" on
|
||||
* page 1 and at the triangle's tip while 28 packets exist. Leaves do not carry selectors, so they are
|
||||
* attached here by id, the unified deaf leaf taking DEAF_SELECTOR.
|
||||
* Returns COPIES; the legend's own node objects are left untouched.
|
||||
*
|
||||
* @param {object} clusters the zone catalogue (clusters.json shape)
|
||||
* @param {object} [opts] passed through to buildLegendNodes
|
||||
* @returns {Array<{id:string, zoneKey:string, label:string, selector:object}>}
|
||||
*/
|
||||
export function zonesWithSelectors(clusters, opts = {}) {
|
||||
const byId = {};
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
for (const z of (clusters && clusters[k]) || []) byId[z.id] = z.selector;
|
||||
}
|
||||
byId.deaf = DEAF_SELECTOR;
|
||||
return leafZones(buildLegendNodes(clusters, opts))
|
||||
.map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] }));
|
||||
}
|
||||
|
||||
export default buildLegendNodes;
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
// selector-evaluator.js — slice 01 (my-upg-100-list)
|
||||
//
|
||||
// Pure function: evaluate(selector, records) -> Set<slug>
|
||||
//
|
||||
// `records` are assumed ALREADY NORMALIZED by normalize.js, i.e. the nested
|
||||
// { value, label } fields (country_code, wagf_region, wagf_block, primary_language)
|
||||
// have been flattened to flat `.value` strings. The evaluator therefore compares
|
||||
// flat record fields against flat selector values — no .value access here.
|
||||
//
|
||||
// Handles the 6 selector types documented in data/clusters.source.md + the qa file:
|
||||
// 1. { type: "country_code", value: "SDN" }
|
||||
// -> records whose country_code === value
|
||||
// 2. { type: "country_codes", values: [...] } (note plural `values`)
|
||||
// -> records whose country_code ∈ values
|
||||
// 3. { type: "wagf_block", value: "west_africa" }
|
||||
// -> records whose wagf_block === value
|
||||
// 4. { type: "wagf_region", value: "europe" }
|
||||
// -> records whose wagf_region === value
|
||||
// 5. { type: "union", selectors: [ <sel>, <sel>, ... ] }
|
||||
// -> union of evaluating each sub-selector
|
||||
// 6. { type: "deaf_predicate", regions: [...] }
|
||||
// -> records where (slug OR name contains "deaf", case-insensitive)
|
||||
// AND wagf_region ∈ regions
|
||||
// 7. { type: "affinity_bloc", rop1: "A017" } (slice 04t §1g)
|
||||
// -> records where r.rop1.value === rop1
|
||||
// NOTE: rop1 is NOT in normalize.js NESTED_FIELDS, so it stays a nested
|
||||
// { value, label } dict on the record. This selector reads `.value`
|
||||
// directly (and tolerates an already-flat rop1 string). The Deaf row's
|
||||
// bloc code is "A017" (123 PGs, == the old deaf_predicate slug set, zero
|
||||
// delta but field-authoritative). 📁 affinity-block.js:36, sources.json:68
|
||||
//
|
||||
// Design contract:
|
||||
// - returns a Set<slug> (always; never null/undefined)
|
||||
// - a bogus VALUE (e.g. country_code "ZZZ") -> empty Set, never a crash
|
||||
// - an UNKNOWN selector type -> throw a clear Error (never silently empty)
|
||||
|
||||
// Collect the slug of a record. Records without a slug are skipped (cannot key a Set entry).
|
||||
function slugOf(record) {
|
||||
if (record == null || typeof record !== 'object') return null;
|
||||
return record.slug ?? null;
|
||||
}
|
||||
|
||||
// True if the haystack string contains "deaf" (case-insensitive).
|
||||
function containsDeaf(s) {
|
||||
return typeof s === 'string' && s.toLowerCase().includes('deaf');
|
||||
}
|
||||
|
||||
// Read the affinity-bloc code off a record's `rop1` field. rop1 is intentionally
|
||||
// NOT flattened by normalize.js, so it normally arrives as { value, label }; this
|
||||
// also tolerates an already-flat string (defensive).
|
||||
function rop1Code(record) {
|
||||
const f = record == null ? null : record.rop1;
|
||||
if (f == null) return null;
|
||||
if (typeof f === 'object') return f.value ?? null;
|
||||
return f; // already-flat string
|
||||
}
|
||||
|
||||
/**
|
||||
* evaluate(selector, records) -> Set<slug>
|
||||
* @param {object} selector one of the 6 documented selector shapes
|
||||
* @param {Array<object>} records ALREADY-normalized UPG records
|
||||
* @returns {Set<string>} set of matching slugs
|
||||
* @throws {Error} on an unknown / malformed selector type
|
||||
*/
|
||||
export function evaluate(selector, records) {
|
||||
const out = new Set();
|
||||
|
||||
if (selector == null || typeof selector !== 'object' || typeof selector.type !== 'string') {
|
||||
throw new Error(
|
||||
`selector-evaluator: malformed selector (expected an object with a string \`type\`), got: ${JSON.stringify(selector)}`,
|
||||
);
|
||||
}
|
||||
const rows = Array.isArray(records) ? records : [];
|
||||
|
||||
switch (selector.type) {
|
||||
case 'country_code': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.country_code === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'country_codes': {
|
||||
// plural: match against the `values` array
|
||||
const wanted = new Set(Array.isArray(selector.values) ? selector.values : []);
|
||||
for (const r of rows) {
|
||||
if (r && wanted.has(r.country_code)) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'wagf_block': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.wagf_block === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'wagf_region': {
|
||||
const want = selector.value;
|
||||
for (const r of rows) {
|
||||
if (r && r.wagf_region === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'union': {
|
||||
const subs = Array.isArray(selector.selectors) ? selector.selectors : [];
|
||||
for (const sub of subs) {
|
||||
// recurse; sub-selector errors (unknown type) propagate up by design
|
||||
const subSet = evaluate(sub, rows);
|
||||
for (const slug of subSet) out.add(slug);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'deaf_predicate': {
|
||||
const regions = new Set(Array.isArray(selector.regions) ? selector.regions : []);
|
||||
for (const r of rows) {
|
||||
if (!r) continue;
|
||||
const isDeaf = containsDeaf(r.slug) || containsDeaf(r.name);
|
||||
if (isDeaf && regions.has(r.wagf_region)) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'affinity_bloc': {
|
||||
// slice 04t §1g — Deaf row: records whose rop1.value === selector.rop1 (e.g. "A017").
|
||||
const want = selector.rop1;
|
||||
for (const r of rows) {
|
||||
if (!r) continue;
|
||||
if (rop1Code(r) === want) {
|
||||
const slug = slugOf(r);
|
||||
if (slug != null) out.add(slug);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`selector-evaluator: unknown selector type "${selector.type}" ` +
|
||||
`(supported: country_code, country_codes, wagf_block, wagf_region, union, deaf_predicate, affinity_bloc)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default evaluate;
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
// zone-geometry.js — slice 04t §1(b)
|
||||
//
|
||||
// resolveZoneGeometry(zone, deps) -> FeatureCollection
|
||||
//
|
||||
// Per legend row ("zone"), returns the polygons it needs, each feature stamped
|
||||
// with the row's `zoneKey` + `zoneColor` so the tapestry's single Mapbox
|
||||
// ['match', ['get','zoneKey'], …] fill expression can paint all 26 at once.
|
||||
//
|
||||
// Resolution rules (S04t-D1/D2/D3):
|
||||
// - REGIONAL (13) + DEAF (1): ADM0 country polygons.
|
||||
// member countries = evaluate(zone.selector, records) -> slugs
|
||||
// -> distinct country_code over those member records
|
||||
// -> ADM0 features whose props.iso3 ∈ that set.
|
||||
// (deaf's selector is affinity_bloc/A017 → its 123 members' country_codes.)
|
||||
// - ASIA (12): ADM1 features. Asia rows are state-CLUSTERS, so SELECT-MANY:
|
||||
// every ADM1 feature whose props.listId === zone.id (the on-disk
|
||||
// gb-IND-ADM1 / gb-CHN-ADM1 files are pre-tagged with listId).
|
||||
//
|
||||
// Design contract:
|
||||
// - always returns a FeatureCollection (never null); empty features ⇒ a zone
|
||||
// the proof will FLAG (every zone must resolve ≥1 polygon).
|
||||
// - separate polygons sharing one zone color (S04t-D3); no dissolve here.
|
||||
// - pure: no Mapbox / DOM; testable in plain Node.
|
||||
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
const EMPTY_FC = () => ({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
// Distinct country_code (ISO3) set for a zone's member UPGs.
|
||||
// `records` are normalized (country_code flat); rop1 stays nested (selector reads it).
|
||||
export function memberCountryCodes(zone, records) {
|
||||
const slugs = evaluate(zone.selector, records);
|
||||
const iso = new Set();
|
||||
for (const r of records) {
|
||||
if (r && slugs.has(r.slug) && r.country_code) iso.add(r.country_code);
|
||||
}
|
||||
return iso;
|
||||
}
|
||||
|
||||
// True for the 12 Asia rows (admin-1 resolution).
|
||||
export function isAsiaZone(zone) {
|
||||
return typeof zone.id === 'string' && zone.id.startsWith('asia-');
|
||||
}
|
||||
|
||||
function stamp(feature, zone) {
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
...(feature.properties || {}),
|
||||
zoneKey: zone.zoneKey ?? zone.id,
|
||||
zoneColor: zone.color ?? zone.zoneColor ?? '#888',
|
||||
zoneLabel: zone.label,
|
||||
...(zone.id === 'deaf' ? { hatch: true } : {}),
|
||||
},
|
||||
geometry: feature.geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveZoneGeometry(zone, deps) -> FeatureCollection
|
||||
* @param {object} zone a leaf zone { id, zoneKey, label, color, selector? }
|
||||
* @param {object} deps
|
||||
* @param {Array} deps.records normalized UPG records (rop1-bearing for deaf)
|
||||
* @param {object} deps.adm0 FeatureCollection of ADM0 (features props.iso3)
|
||||
* @param {object} deps.adm1Ind FeatureCollection gb-IND-ADM1 (props.listId)
|
||||
* @param {object} deps.adm1Chn FeatureCollection gb-CHN-ADM1 (props.listId)
|
||||
*/
|
||||
export function resolveZoneGeometry(zone, deps) {
|
||||
const { records = [], adm0, adm1Ind, adm1Chn } = deps || {};
|
||||
const fc = EMPTY_FC();
|
||||
|
||||
if (isAsiaZone(zone)) {
|
||||
// ADM1 select-many: every feature pre-tagged with this row's listId.
|
||||
const pools = [adm1Ind, adm1Chn].filter(Boolean);
|
||||
for (const pool of pools) {
|
||||
for (const f of pool.features || []) {
|
||||
if (f.properties && f.properties.listId === zone.id) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
// slice 09b-8 §1(c): the MIXED-GEOMETRY hook. An Asia zone may ALSO carry an
|
||||
// optional `adm0` array of ISO3 codes (the Nepal-frontier cluster: ['NPL']).
|
||||
// When present, union the matching ADM0 country polygons into the SAME zone so
|
||||
// a single legend row paints both its India states (ADM1) and Nepal (ADM0) as
|
||||
// one zone. Additive + opt-in: every other Asia zone lacks `adm0`, so this loop
|
||||
// is skipped and their resolution is byte-identical to 09b-7.
|
||||
if (Array.isArray(zone.adm0) && zone.adm0.length && adm0) {
|
||||
const want = new Set(zone.adm0);
|
||||
for (const f of adm0.features || []) {
|
||||
if (f.properties && want.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
return fc;
|
||||
}
|
||||
|
||||
// Regional / deaf: ADM0 by member country_codes.
|
||||
if (!zone.selector) return fc; // a parent (asia/india/china) — no geometry of its own
|
||||
const iso = memberCountryCodes(zone, records);
|
||||
if (adm0 && iso.size) {
|
||||
for (const f of adm0.features || []) {
|
||||
if (f.properties && iso.has(f.properties.iso3)) fc.features.push(stamp(f, zone));
|
||||
}
|
||||
}
|
||||
return fc;
|
||||
}
|
||||
|
||||
/**
|
||||
* resolveAllZones(zones, deps) -> { tapestry: FeatureCollection, perZone: Map }
|
||||
* Builds ONE FeatureCollection (all zones, each feature carrying its zoneKey) for
|
||||
* the single-source tapestry paint, plus a per-zone resolved-count map for the proof.
|
||||
*/
|
||||
export function resolveAllZones(zones, deps) {
|
||||
const tapestry = EMPTY_FC();
|
||||
const perZone = new Map();
|
||||
// deaf-polygon removal (design fix, an earlier fix): the `deaf` affinity overlay is an
|
||||
// ADM0 double-stamp — its A017 selector re-paints land already owned by the 13 geographic
|
||||
// zones (16 of oceania-indonesia's 18 Pacific ISO3 alone). That double-stamp showed deaf
|
||||
// TWICE and underlapped every other region in blue (#18ffff), and the overlapping deaf land
|
||||
// didn't color correctly until click-isolate re-filtered the source to one zone. The earlier
|
||||
// 09b draw-order mitigation (resolve deaf FIRST) only re-stacked the same overlapping geometry;
|
||||
// the directive is to REMOVE the deaf polygon/region layer entirely. So we EXCLUDE
|
||||
// the deaf zone from the tapestry (region-fill) FeatureCollection here. deaf stays fully in
|
||||
// the LIST/legend (built by legend-tree.js buildLegendNodes — untouched) and in the PINS
|
||||
// (built from slugToZone selectors in upg-100-list.vue — untouched). Only the polygon/region
|
||||
// fill loses deaf, which removes the overlap and the until-click coloring bug.
|
||||
for (const zone of zones) {
|
||||
if (zone.id === 'deaf') {
|
||||
// record 0 polygons for the proof's per-zone map; deaf intentionally has no region fill now.
|
||||
perZone.set(zone.zoneKey ?? zone.id, 0);
|
||||
continue;
|
||||
}
|
||||
const fc = resolveZoneGeometry(zone, deps);
|
||||
perZone.set(zone.zoneKey ?? zone.id, fc.features.length);
|
||||
for (const f of fc.features) tapestry.features.push(f);
|
||||
}
|
||||
return { tapestry, perZone };
|
||||
}
|
||||
|
||||
export default resolveZoneGeometry;
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
/**
|
||||
* zone-sheet-print.js — a UPG-100 zone's map sheet, GENERATED in the browser at the click (the print
|
||||
* standard: generate, store only previews). One helper for every surface that prints a zone (the map's
|
||||
* legend and packets, the poster wall), so they cannot draw the "same" sheet two ways.
|
||||
*
|
||||
* The zone and its pins come from zone-sheet.js — the SAME assignment generate.mjs makes — the geometry
|
||||
* from the full-resolution boundaries generate.mjs draws, and the drawing from the library's generator.
|
||||
*
|
||||
* const zoneSheet = makeZoneSheet({ allRecords, assetsBase: () => base, profileConfig })
|
||||
* usePosterPrints({ …, sheet: zoneSheet }) // (item, format) -> html
|
||||
*
|
||||
* Library imports are RELATIVE, not `@map/…`: the same file runs under the bundler, Node and the
|
||||
* browser-equivalence check (bun), and only a relative path resolves identically in all three.
|
||||
*/
|
||||
import { upg100Zones, assignPins, seriesFacts } from './zone-sheet.js'
|
||||
import { resolveZoneGeometry } from './zone-geometry.js'
|
||||
import { zonesForList } from './zones-for-list.js'
|
||||
import { resolvePeopleGroupList } from '../../lists/peopleGroupList.js'
|
||||
|
||||
// `date` pins the stamp printed on the sheet (default: today) — how a check reproduces a sheet exactly.
|
||||
export function makeZoneSheet({ allRecords, assetsBase, profileConfig, date = null }) {
|
||||
return async function zoneSheet(item, format) {
|
||||
const { generateSheet, loadBoundaries } = await import('../../packet/posterSheets.js')
|
||||
const [{ ISO3_NAME }, records, bounds] = await Promise.all([
|
||||
import('../../data/iso3-name.js'), allRecords(), loadBoundaries(assetsBase()),
|
||||
])
|
||||
const zones = upg100Zones(zonesForList(resolvePeopleGroupList(profileConfig)), records)
|
||||
const zone = zones.find((z) => z.id === (item && item.id))
|
||||
if (!zone) return '' // a selection or an unknown id has no zone sheet; the caller falls through
|
||||
const pins = assignPins(zones, records).get(zone.zoneKey) || []
|
||||
const zoneFC = resolveZoneGeometry(zone, { records, ...bounds })
|
||||
// ★ THE PACKET'S PAGE 1 IS THE FULL UPG-100 POSTER (2026-10-02), not the map-only sheet: the letter-size
|
||||
// poster with its title, the scripture banner, the three-column header and the footer. The packet asks
|
||||
// for 'packet'; this series answers with the full letter sheet, which is the same 1500x1941 page.
|
||||
const page1 = format === 'packet'
|
||||
return generateSheet({
|
||||
zone, pins, zoneFC, format: page1 ? 'letter' : format, ...(page1 ? { sparse: false } : {}), seriesId: 'upg100', ISO3_NAME, adm0: bounds.adm0, adm1Ind: bounds.adm1Ind, adm1Chn: bounds.adm1Chn,
|
||||
series: { ...seriesFacts(zones), index: zones.indexOf(zone) + 1 }, date, assetsBase: assetsBase(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* zone-sheet.js — which people groups a map sheet pins, decided ONCE for the Node generator and the
|
||||
* browser alike.
|
||||
*
|
||||
* generate.mjs used to hold this in its main routine: attach each leaf zone's selector, then give every
|
||||
* people group to the FIRST zone that claims it and pin it there. A browser that draws the same sheet
|
||||
* must make exactly the same assignment, or the "same" poster pins a group in a different zone
|
||||
* depending on where it was printed. So both import this module; neither keeps its own copy.
|
||||
*
|
||||
* Pure: no fs, no fetch, no DOM. The caller supplies the records (with latitude/longitude).
|
||||
*/
|
||||
import { buildLegendNodes, leafZones, DEAF_SELECTOR } from './legend-tree.js';
|
||||
import { evaluate } from './selector-evaluator.js';
|
||||
|
||||
/** The UPG-100 leaf zones, each carrying its selector (leaves do not carry it; the catalogue does). */
|
||||
export function upg100Zones(clusters, records) {
|
||||
const zones = leafZones(buildLegendNodes(clusters, { records }));
|
||||
const byId = {};
|
||||
for (const l of clusters.lists || []) byId[l.id] = l.selector;
|
||||
for (const a of clusters.asia_lists || []) byId[a.id] = a.selector;
|
||||
for (const u of clusters.uncovered_lists || []) byId[u.id] = u.selector;
|
||||
byId['deaf'] = DEAF_SELECTOR; // defined once in legend-tree.js
|
||||
for (const z of zones) z.selector = byId[z.id];
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* zoneKey -> the pins of that zone. A group belongs to the FIRST zone (in `zones` order) whose selector
|
||||
* claims it; groups without coordinates are not pinned. Pins keep the records' own order.
|
||||
* `onSkip(zone, error)` hears about a selector that could not be evaluated (the zone then pins nothing).
|
||||
*/
|
||||
export function assignPins(zones, records, { onSkip = null } = {}) {
|
||||
const slugToZone = {};
|
||||
for (const z of zones) {
|
||||
if (z.selector && z.selector.type === 'pg_slugs') {
|
||||
for (const s of (z.selector.values || [])) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey;
|
||||
} else if (z.selector) {
|
||||
try { for (const s of evaluate(z.selector, records)) if (!(s in slugToZone)) slugToZone[s] = z.zoneKey; }
|
||||
catch (e) { if (onSkip) onSkip(z, e); }
|
||||
}
|
||||
}
|
||||
const pinsByZone = new Map(zones.map((z) => [z.zoneKey, []]));
|
||||
for (const r of records) {
|
||||
const lat = Number(r.latitude), lng = Number(r.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lng)) continue;
|
||||
const zk = slugToZone[r.slug];
|
||||
if (zk && pinsByZone.has(zk)) pinsByZone.get(zk).push({
|
||||
name: r.name || r.slug || '', lat, lng, slug: r.slug, country_code: r.country_code,
|
||||
population: r.population,
|
||||
});
|
||||
}
|
||||
return pinsByZone;
|
||||
}
|
||||
|
||||
/** "Map N of M" and the total the poster states, from the zones being generated. */
|
||||
export function seriesFacts(zones) {
|
||||
return { totalGroups: zones.reduce((a, z) => a + (z.count || 0), 0), totalLists: zones.length, index: 1 };
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* zones-for-list — which ZONES a project list is clustered into.
|
||||
*
|
||||
* THE GAP THIS CLOSES. Membership is already parameterised by list: library/lists/lists.json registers
|
||||
* five (doxa = Project 2000, unreached = 7000, frontier = 4700, frontier-500k = 700, unengaged), and
|
||||
* resolvePeopleGroupList(profileConfig) already picks one from `config.list` / `?list=` / window.MAP_LIST
|
||||
* with a default. What NO list has is ZONES: a list file says WHICH people groups, and nothing says how
|
||||
* to cluster them into the ~100-group printable zones a poster needs. clusters.json is UPG-100's, and it
|
||||
* was the only answer available.
|
||||
*
|
||||
* ★ WHY THIS IS A RESOLVER AND NOT A DYNAMIC IMPORT. The two consumers do:
|
||||
* import { lists, asia_lists, uncovered_lists } from './data/clusters.json'
|
||||
* and that NAMED import is load-bearing — its own comment says so: only the zone arrays ship, while
|
||||
* clusters.json's eight bookkeeping fields (version, generated, source_total, validation_run, …) are
|
||||
* tree-shaken out. Swapping it for `await import(path)` would ship the whole file in every bundle, and
|
||||
* would make the zone list asynchronous in code that reads it synchronously at setup. So the default
|
||||
* stays a static named import, and alternates are REGISTERED rather than computed from a path.
|
||||
*
|
||||
* ⚠ AND THE DEFAULT MUST BE THE OLD BEHAVIOUR EXACTLY. A list with no zones of its own gets UPG-100's,
|
||||
* which is what every caller gets today — so this file is inert until someone registers a second set.
|
||||
* That is deliberate: the pattern has to be landable before any second list exists, or it cannot be the
|
||||
* thing the second list is built against.
|
||||
*
|
||||
* ★ WHAT A NEW LIST ACTUALLY SHIPS, and this is the pattern for another agent to follow:
|
||||
* 1. data/zones/<list-id>.json the four zone-bearing keys, same shape as clusters.json:
|
||||
* lists / deaf_lists / asia_lists / uncovered_lists
|
||||
* 2. data/zoning-declarations.<id>.json the facts a gate cannot infer — bloc zones, expected orphans,
|
||||
* known geographic overlaps, page budget
|
||||
* 3. one line in ZONES_BY_LIST below
|
||||
* Then `npm run check:zoning-declared -- --list <id>` grades the zoning, and the legend, poster wall and
|
||||
* packet all follow, because all three already enumerate through leafZones(buildLegendNodes(zones)).
|
||||
*
|
||||
* ZONING RULE (2026-09-27), recorded here because it is the judgement the format cannot
|
||||
* capture: cluster by GEOGRAPHY FIRST so the poster maps make sense, balanced with LANGUAGE — languages
|
||||
* usually cluster geographically, so the two mostly agree, and where they disagree geography wins for a
|
||||
* map's sake. UPG-100's own 28 already mix the two: the country_codes zones are geographic, and the 12
|
||||
* India/China pg_slugs zones are hand groupings where language and geography were traded off.
|
||||
*
|
||||
* ⚠ THE DEAF ZONE IS THE KNOWN EXCEPTION AND MUST NOT BE FORCED. `deaf` is affinity bloc A017: 123 groups
|
||||
* spanning every region, global by construction, and the one poster whose zone cannot be a map extent. It
|
||||
* breaks the geography-first rule the rest of the pattern rests on, and it is recorded as not yet
|
||||
* solved. A list whose clusters include an affinity or language bloc inherits the same open question —
|
||||
* declare it in bloc_zones so the gate treats it as a second partition, and do NOT invent a geography for
|
||||
* it to make a validator happy.
|
||||
*
|
||||
* ⚠ THIS MODULE IS BROWSER/VITE-ONLY, BY CONSTRUCTION. The static JSON import below is what keeps the
|
||||
* tree-shaking (see above), and bare `import … from './x.json'` is a bundler feature: plain Node throws
|
||||
* ERR_IMPORT_ASSERTION_TYPE_MISSING without an `with { type: 'json' }` attribute. That is fine and is the
|
||||
* existing convention here — NO Node-side file imports clusters.json as a module; packet.mjs and every
|
||||
* gate read it with readFileSync + JSON.parse (packet.mjs:125). So a Node consumer that wants the same
|
||||
* resolution should read the JSON itself and pass the object on, rather than importing this file.
|
||||
*/
|
||||
import { lists, deaf_lists, asia_lists, uncovered_lists } from './clusters.json'
|
||||
|
||||
/** UPG-100's zones — the default for every list until one registers its own. */
|
||||
const UPG_100_ZONES = { lists, deaf_lists, asia_lists, uncovered_lists }
|
||||
|
||||
/**
|
||||
* list id → its zone set.
|
||||
* ★ A REGISTRY, NOT A DIRECTORY SCAN: each entry is a static import somewhere, so the bundler can see
|
||||
* which zone files a bundle actually reaches and drop the rest. A scan would defeat that and would also
|
||||
* make a missing file a runtime error instead of a build one.
|
||||
*/
|
||||
const ZONES_BY_LIST = {
|
||||
// 'project-3000': PROJECT_3000_ZONES, ← the shape a second list adds
|
||||
}
|
||||
|
||||
/**
|
||||
* The zones for a list id.
|
||||
*
|
||||
* @param {string} [listId] as returned by resolvePeopleGroupList(profileConfig)
|
||||
* @returns {{lists:Array, deaf_lists?:Array, asia_lists:Array, uncovered_lists:Array}}
|
||||
*
|
||||
* ⚠ FALLS BACK RATHER THAN THROWING, and says so once. A list without registered zones is the NORMAL
|
||||
* state right now (five lists, one zone set), so throwing would break every caller for a condition that
|
||||
* is expected. But it is silent-wrong-data territory — a reader would see UPG-100's zones under another
|
||||
* list's name and have no way to tell — so it warns exactly once per id, naming what it did.
|
||||
*/
|
||||
const warned = new Set()
|
||||
export function zonesForList(listId) {
|
||||
const id = String(listId || '')
|
||||
const own = ZONES_BY_LIST[id]
|
||||
if (own) return own
|
||||
if (id && !warned.has(id) && typeof console !== 'undefined') {
|
||||
warned.add(id)
|
||||
console.warn(`[zones] list "${id}" has no zones of its own; showing UPG-100's ${countZones(UPG_100_ZONES)} `
|
||||
+ 'zones. Register a set in data/zones-for-list.js to give it its own poster clusters.')
|
||||
}
|
||||
return UPG_100_ZONES
|
||||
}
|
||||
|
||||
/** Zone count across the zone-bearing keys. NOT a leaf count — buildLegendNodes collapses deaf_lists. */
|
||||
export function countZones(zones) {
|
||||
let n = 0
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) n += (zones?.[k] || []).length
|
||||
// ★ deaf_lists is counted as ONE, not as its member count: legend-tree collapses its two entries into a
|
||||
// single unified leaf (legend-tree.js:106, "13 + 12 + 2 + 1 deaf = 28"). Counting its members would
|
||||
// report 29 for a 28-leaf tree — a number that disagrees with every other count in the app.
|
||||
if ((zones?.deaf_lists || []).length) n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
/** Every list id that has its own zones — for a gate or a diagnostic to enumerate. */
|
||||
export function listsWithOwnZones() { return Object.keys(ZONES_BY_LIST) }
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"n": 1,
|
||||
"rhythm": "praise",
|
||||
"sub": "Two-Cross Gospel",
|
||||
"text": "Praise Jesus for taking up a cross for you and being humbled. Crown Him as your King, and pray for these nations (people groups) that they will do the same. Devote yourself to Jesus to take up your own cross and follow Him, even through suffering, and death. Praise Him for He is worthy of these nations (people groups) taking up their own cross, willing to follow Jesus no matter what the cost. May the Lamb receive the reward of His suffering, starting with me.",
|
||||
"text": "That they accept Christ as King, forgiven by His cross — and take up their own cross, willing to suffer through persecution. May the Lamb receive the reward of His suffering, starting with me.",
|
||||
"refs": [
|
||||
"Matthew 27:27",
|
||||
"Matthew 16:24–25"
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
"n": 2,
|
||||
"rhythm": "wait",
|
||||
"sub": "Holy Spirit & deliverance",
|
||||
"text": "Spend time waiting on the Holy Spirit to fill you and empower you to pray as a bold prayer warrior for them. Focus in on God's presence, and wait on the Lord!",
|
||||
"text": "Spend time waiting on the Holy Spirit to tell you and empower you to pray as a bold prayer warrior for them: that they are filled with the Holy Spirit and made bold witnesses, not isolated; baptized in water and Spirit; every evil grip broken; and that believers in neighboring people groups are mobilized as laborers.",
|
||||
"refs": [
|
||||
"Acts 1:8",
|
||||
"John 14:26"
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
"n": 3,
|
||||
"rhythm": "confess",
|
||||
"sub": "",
|
||||
"text": "Be open and honest with God about your fears, lies, and unbelief surrounding unreached people groups (Example lie: It is too big an ask for God to reach all of these unreached people groups in our lifetime). Receive His love, truth, and voice surrounding unreached people groups and your role in helping Him reach these people groups. Ask God to revive your love and faith for the people groups on your prayer list.",
|
||||
"text": "We confess the unbelief that it is too big an ask for God to reach all of these unreached people groups in our lifetime. We receive the truth that He is reviving our love and faith for the global unreached.",
|
||||
"refs": [
|
||||
"John 3:16",
|
||||
"1 John 1:9"
|
||||
|
|
@ -81,8 +81,8 @@
|
|||
{
|
||||
"n": 4,
|
||||
"rhythm": "read",
|
||||
"sub": "the heavenly Father's love",
|
||||
"text": "That they would be SAVED and KNOW their heavenly Father loves them — He wants to save them, and through them He wants to bring salvation to their family, friends, neighbors, coworkers, classmates, everyone they know personally.",
|
||||
"sub": "the Father's love",
|
||||
"text": "That they would be SAVED and KNOW their heavenly Father loves them — and that through them He would bring salvation to their family, friends, neighbours, coworkers, classmates, everyone they know personally.",
|
||||
"refs": [
|
||||
"Mark 5:18–20",
|
||||
"Acts 16:31"
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
"n": 5,
|
||||
"rhythm": "ask",
|
||||
"sub": "baptisms",
|
||||
"text": "New baptisms among the indigenous people groups, and every baptized disciple is trained to baptize others — multiplying baptisms.",
|
||||
"text": "New baptisms among the indigenous people groups, and every baptized disciple trained to baptize others — multiplying baptisms.",
|
||||
"refs": [
|
||||
"Acts 16",
|
||||
"Acts 10"
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
"n": 6,
|
||||
"rhythm": "intercede",
|
||||
"sub": "laborers & shepherds",
|
||||
"text": "For laborers sent to deeply love a people they have never met; and indigenous good shepherds raised up from within the people groups.",
|
||||
"text": "Laborers sent to deeply love a people they have never met; good shepherds raised up from within.",
|
||||
"refs": [
|
||||
"Luke 10:2",
|
||||
"John 10:11–15"
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
"n": 7,
|
||||
"rhythm": "pray_word",
|
||||
"sub": "Persons & Houses of Peace",
|
||||
"text": "That laborers and indigenous disciples find the receptive person and the house of peace, deeply love them, disciple them well, baptize them, and train them to reach their friends and family with their newfound love for Jesus.",
|
||||
"text": "That laborers and indigenous leaders find the receptive person and the house of peace, deeply love them, disciple them well, baptize them, and train them to reach their friends and family with their newfound love for Jesus.",
|
||||
"refs": [
|
||||
"Luke 10:5–7",
|
||||
"Matthew 10:5–14"
|
||||
|
|
@ -133,17 +133,17 @@
|
|||
"n": 9,
|
||||
"rhythm": "sing",
|
||||
"sub": "",
|
||||
"text": "Imagine you are with Jesus worshipping Him, and you are surrounded by all the people groups on your prayer list worshipping Him too. Spend some time to praise Jesus.",
|
||||
"text": "That the people group is flooded with worshippers — worship everywhere, in freedom and in chains.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 10,
|
||||
"rhythm": "meditate",
|
||||
"sub": "communion",
|
||||
"text": "Focus on Jesus. Take communion if possible. Imagine being together with Jesus at a table with one person from each people group on your list. What is it going to take for this to become a reality?",
|
||||
"text": "That they take communion together and grasp the freedom they have in Christ through His death, burial and resurrection.",
|
||||
"refs": [
|
||||
"Matthew 26:26–30",
|
||||
"1 Corinthians 11:23–26"
|
||||
|
|
@ -163,10 +163,11 @@
|
|||
"n": 12,
|
||||
"rhythm": "praise_end",
|
||||
"sub": "blessing · great · greater · greatest",
|
||||
"text": "Short reading: It is a blessing from God for people in these people groups to be saved through Jesus and have eternal life (John 17:3). It is a great blessing from God for indigenous disciples to lead their friends, family, coworkers, classmates, neighbors, and people they don't know, to Jesus through their witness (John 17:20). It is a greater blessing from God when indigenous leaders in these people groups start new churches that are unified in Jesus (John 17:21). It is one of the greatest blessings from God, when indigenous leaders train other indigenous leaders to start more churches unified in Christ. They begin to worship Jesus and spread throughout neighboring unreached people groups bringing the life of Jesus to the ends of the earth. Spend time singing praise to God. He is worthy of worshippers in all these people groups!",
|
||||
"text": "Pray Jesus’ prayer in John 17 over them. The blessing: eternal life, knowing God and Jesus, whom He sent. The great blessing: their own believers leading others to faith. The greater blessing: new spiritual families of their own, one in Christ. One of the greatest: leaders from among them who train others to start new spiritual families, again and again. Ask all this and more, beyond all we ask or think. Ask, as Jesus did, that they be one and kept from the evil one. None of it is possible unless Jesus is who He says He is.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -174,8 +175,8 @@
|
|||
"point4_passage_credit": "Mark 5:18–20 · World English Bible (public domain)",
|
||||
"point4_passage_bold": "He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.”",
|
||||
"point4_heart_title": "THE HEAVENLY FATHER'S HEART",
|
||||
"point4_heart_lede": "They need to know that their heavenly Father loves them, He wants to save them, and through them He wants their unreached people group neighbors to be saved.",
|
||||
"point4_heart_leadin": "More scriptures about the heavenly Father's heart (pick one scripture to read)",
|
||||
"point4_heart_lede": "They need to know that their Heavenly Father loves them, He wants to save them, and through them He wants their unreached people group neighbors to be saved.",
|
||||
"point4_heart_leadin": "More scriptures about the heavenly Father's heart",
|
||||
"point4_heart": [
|
||||
{
|
||||
"who": "Noah",
|
||||
|
|
@ -262,7 +263,6 @@
|
|||
"sets_bounded_title": "Reached, and not",
|
||||
"sets_centered_title": "Distance from a neighbour",
|
||||
"sets_bounded_tally": "{inside} inside · {outside} outside · {unknown} unknown",
|
||||
"sets_no_data": "{n} without data",
|
||||
"sets_centered_tally": "{placed} placed · {centre} at the centre · {unplaced} unplaced",
|
||||
"ring_band_dialect": "Same dialect",
|
||||
"ring_band_language": "Same language",
|
||||
|
|
@ -297,8 +297,7 @@
|
|||
"Haggai": "Haggai",
|
||||
"Isaiah": "Isaiah",
|
||||
"Habakkuk": "Habakkuk",
|
||||
"Ephesians": "Ephesians",
|
||||
"Revelation": "Revelation"
|
||||
"Ephesians": "Ephesians"
|
||||
},
|
||||
"christian_per_cell": "Each box ≈ {n} people",
|
||||
"howto_title": "How to pray with this list",
|
||||
|
|
@ -489,7 +488,7 @@
|
|||
"text": "Que el grupo étnico sea inundado de adoradores — adoración en todas partes, en libertad y en cadenas.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -518,8 +517,9 @@
|
|||
"sub": "bendición · grande · mayor · de las mayores",
|
||||
"text": "Ora sobre ellos la oración de Jesús en Juan 17. La bendición: la vida eterna, conocer a Dios y a Jesús, a quien él envió. La gran bendición: que sus propios creyentes lleven a otros a la fe. La bendición mayor: nuevas familias espirituales propias, unidas en Cristo. Una de las mayores: líderes de entre ellos que formen a otros para iniciar nuevas familias espirituales, una y otra vez. Pide todo esto y más, mucho más de lo que pedimos o pensamos. Pide, como Jesús, que sean uno y que sean guardados del maligno. Nada de esto es posible si Jesús no es quien dice ser.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -608,7 +608,6 @@
|
|||
"sets_bounded_title": "Alcanzados y no alcanzados",
|
||||
"sets_centered_title": "Distancia a un vecino",
|
||||
"sets_bounded_tally": "{inside} dentro · {outside} fuera · {unknown} desconocido",
|
||||
"sets_no_data": "{n} sin datos",
|
||||
"sets_centered_tally": "{placed} ubicados · {centre} en el centro · {unplaced} sin ubicar",
|
||||
"ring_band_dialect": "Mismo dialecto",
|
||||
"ring_band_language": "Misma lengua",
|
||||
|
|
@ -643,8 +642,7 @@
|
|||
"Haggai": "Hageo",
|
||||
"Isaiah": "Isaías",
|
||||
"Habakkuk": "Habacuc",
|
||||
"Ephesians": "Efesios",
|
||||
"Revelation": "Apocalipsis"
|
||||
"Ephesians": "Efesios"
|
||||
},
|
||||
"christian_per_cell": "Cada casilla ≈ {n} personas",
|
||||
"howto_title": "Cómo orar con esta lista",
|
||||
|
|
@ -821,7 +819,7 @@
|
|||
"text": "Que le groupe ethnique soit inondé d'adorateurs — l'adoration partout, dans la liberté comme dans les chaînes.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -850,8 +848,9 @@
|
|||
"sub": "bénédiction · grande · plus grande · parmi les plus grandes",
|
||||
"text": "Priez sur eux la prière de Jésus en Jean 17. La bénédiction : la vie éternelle, connaître Dieu et Jésus, celui qu’il a envoyé. La grande bénédiction : que leurs propres croyants amènent d’autres à la foi. Une bénédiction plus grande : de nouvelles familles spirituelles bien à eux, unies en Christ. L’une des plus grandes : des responsables issus de leur peuple qui forment d’autres personnes à fonder de nouvelles familles spirituelles, encore et encore. Demandez tout cela et plus encore, au-delà de tout ce que nous demandons ou pensons. Demandez, comme Jésus, qu’ils soient un et gardés du malin. Rien de cela n’est possible si Jésus n’est pas celui qu’il dit être.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -940,7 +939,6 @@
|
|||
"sets_bounded_title": "Atteints, ou non",
|
||||
"sets_centered_title": "Distance d’un voisin",
|
||||
"sets_bounded_tally": "{inside} à l’intérieur · {outside} à l’extérieur · {unknown} inconnu",
|
||||
"sets_no_data": "{n} sans données",
|
||||
"sets_centered_tally": "{placed} placés · {centre} au centre · {unplaced} non placés",
|
||||
"ring_band_dialect": "Même dialecte",
|
||||
"ring_band_language": "Même langue",
|
||||
|
|
@ -975,8 +973,7 @@
|
|||
"Haggai": "Aggée",
|
||||
"Isaiah": "Ésaïe",
|
||||
"Habakkuk": "Habacuc",
|
||||
"Ephesians": "Éphésiens",
|
||||
"Revelation": "Apocalypse"
|
||||
"Ephesians": "Éphésiens"
|
||||
},
|
||||
"christian_per_cell": "Chaque case ≈ {n} personnes",
|
||||
"howto_title": "Comment prier avec cette liste",
|
||||
|
|
@ -1153,7 +1150,7 @@
|
|||
"text": "Que o grupo étnico seja inundado de adoradores — adoração em toda parte, em liberdade e em correntes.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1182,8 +1179,9 @@
|
|||
"sub": "bênção · grande · maior · das maiores",
|
||||
"text": "Ore sobre eles a oração de Jesus em João 17. A bênção: a vida eterna, conhecer a Deus e a Jesus, a quem ele enviou. A grande bênção: que os seus próprios crentes levem outros à fé. A bênção maior: novas famílias espirituais deles mesmos, unidas em Cristo. Uma das maiores: líderes do meio deles que preparem outros para começar novas famílias espirituais, vez após vez. Peça tudo isso e mais, muito além do que pedimos ou pensamos. Peça, como Jesus pediu, que sejam um e guardados do maligno. Nada disso é possível se Jesus não for quem ele diz ser.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -1269,7 +1267,6 @@
|
|||
"sets_bounded_title": "Alcançados e não alcançados",
|
||||
"sets_centered_title": "Distância de um vizinho",
|
||||
"sets_bounded_tally": "{inside} dentro · {outside} fora · {unknown} desconhecido",
|
||||
"sets_no_data": "{n} sem dados",
|
||||
"sets_centered_tally": "{placed} posicionados · {centre} no centro · {unplaced} sem posição",
|
||||
"ring_band_dialect": "Mesmo dialeto",
|
||||
"ring_band_language": "Mesma língua",
|
||||
|
|
@ -1304,8 +1301,7 @@
|
|||
"Haggai": "Ageu",
|
||||
"Isaiah": "Isaías",
|
||||
"Habakkuk": "Habacuque",
|
||||
"Ephesians": "Efésios",
|
||||
"Revelation": "Apocalipse"
|
||||
"Ephesians": "Efésios"
|
||||
},
|
||||
"christian_per_cell": "Cada caixa ≈ {n} pessoas",
|
||||
"howto_title": "Como orar com esta lista",
|
||||
|
|
@ -1482,7 +1478,7 @@
|
|||
"text": "Dass die Volksgruppe von Anbetern überflutet wird — Anbetung überall, in Freiheit und in Ketten.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1511,8 +1507,9 @@
|
|||
"sub": "Segen · groß · größer · einer der größten",
|
||||
"text": "Beten Sie Jesu Gebet aus Johannes 17 über ihnen. Der Segen: ewiges Leben, Gott zu kennen und Jesus, den er gesandt hat. Der große Segen: dass ihre eigenen Gläubigen andere zum Glauben führen. Der größere Segen: neue geistliche Familien, ihre eigenen, eins in Christus. Einer der größten: Leiter aus ihrer Mitte, die andere anleiten, neue geistliche Familien zu gründen, immer wieder. Bitten Sie um all das und mehr, über alles hinaus, was wir bitten oder verstehen. Bitten Sie wie Jesus, dass sie eins seien und vor dem Bösen bewahrt werden. Nichts davon ist möglich, wenn Jesus nicht der ist, der er zu sein sagt.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -1601,7 +1598,6 @@
|
|||
"sets_bounded_title": "Erreicht und nicht erreicht",
|
||||
"sets_centered_title": "Abstand zu einem Nachbarn",
|
||||
"sets_bounded_tally": "{inside} innen · {outside} außen · {unknown} unbekannt",
|
||||
"sets_no_data": "{n} ohne Daten",
|
||||
"sets_centered_tally": "{placed} platziert · {centre} in der Mitte · {unplaced} ohne Platz",
|
||||
"ring_band_dialect": "Gleicher Dialekt",
|
||||
"ring_band_language": "Gleiche Sprache",
|
||||
|
|
@ -1636,8 +1632,7 @@
|
|||
"Haggai": "Haggai",
|
||||
"Isaiah": "Jesaja",
|
||||
"Habakkuk": "Habakuk",
|
||||
"Ephesians": "Epheser",
|
||||
"Revelation": "Offenbarung"
|
||||
"Ephesians": "Epheser"
|
||||
},
|
||||
"christian_per_cell": "Jedes Kästchen ≈ {n} Menschen",
|
||||
"howto_title": "So beten Sie mit dieser Liste",
|
||||
|
|
@ -1814,7 +1809,7 @@
|
|||
"text": "Che il gruppo etnico sia inondato di adoratori — adorazione ovunque, nella libertà e nelle catene.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -1843,8 +1838,9 @@
|
|||
"sub": "benedizione · grande · più grande · tra le più grandi",
|
||||
"text": "Prega su di loro la preghiera di Gesù in Giovanni 17. La benedizione: la vita eterna, conoscere Dio e Gesù, colui che egli ha mandato. La grande benedizione: che i loro stessi credenti portino altri alla fede. La benedizione più grande: nuove famiglie spirituali tutte loro, unite in Cristo. Una delle più grandi: responsabili del loro popolo che formano altri ad avviare nuove famiglie spirituali, ancora e ancora. Chiedi tutto questo e di più, oltre tutto ciò che chiediamo o pensiamo. Chiedi, come Gesù, che siano uno e custoditi dal maligno. Niente di tutto questo è possibile se Gesù non è chi dice di essere.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -1930,7 +1926,6 @@
|
|||
"sets_bounded_title": "Raggiunti e non raggiunti",
|
||||
"sets_centered_title": "Distanza da un vicino",
|
||||
"sets_bounded_tally": "{inside} dentro · {outside} fuori · {unknown} sconosciuto",
|
||||
"sets_no_data": "{n} senza dati",
|
||||
"sets_centered_tally": "{placed} collocati · {centre} al centro · {unplaced} non collocati",
|
||||
"ring_band_dialect": "Stesso dialetto",
|
||||
"ring_band_language": "Stessa lingua",
|
||||
|
|
@ -1965,8 +1960,7 @@
|
|||
"Haggai": "Aggeo",
|
||||
"Isaiah": "Isaia",
|
||||
"Habakkuk": "Abacuc",
|
||||
"Ephesians": "Efesini",
|
||||
"Revelation": "Apocalisse"
|
||||
"Ephesians": "Efesini"
|
||||
},
|
||||
"christian_per_cell": "Ogni casella ≈ {n} persone",
|
||||
"howto_title": "Come pregare con questa lista",
|
||||
|
|
@ -2143,7 +2137,7 @@
|
|||
"text": "Grupul etnic să fie inundat de închinători — închinare pretutindeni, în libertate și în lanțuri.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -2172,8 +2166,9 @@
|
|||
"sub": "binecuvântare · mare · mai mare · printre cele mai mari",
|
||||
"text": "Roagă-te peste ei cu rugăciunea lui Isus din Ioan 17. Binecuvântarea: viața veșnică, să-L cunoască pe Dumnezeu și pe Isus, pe care L-a trimis. Marea binecuvântare: ca propriii lor credincioși să-i aducă pe alții la credință. O binecuvântare și mai mare: familii spirituale noi, ale lor, una în Hristos. Una dintre cele mai mari: lideri dintre ei care îi pregătesc pe alții să înceapă noi familii spirituale, iar și iar. Cere toate acestea și mai mult, dincolo de tot ce cerem sau gândim. Cere, ca Isus, să fie una și păziți de cel rău. Nimic din toate acestea nu este posibil dacă Isus nu este Cel care spune că este.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -2259,7 +2254,6 @@
|
|||
"sets_bounded_title": "Atinse și neatinse",
|
||||
"sets_centered_title": "Distanța față de un vecin",
|
||||
"sets_bounded_tally": "{inside} înăuntru · {outside} în afară · {unknown} necunoscut",
|
||||
"sets_no_data": "{n} fără date",
|
||||
"sets_centered_tally": "{placed} plasate · {centre} în centru · {unplaced} neplasate",
|
||||
"ring_band_dialect": "Același dialect",
|
||||
"ring_band_language": "Aceeași limbă",
|
||||
|
|
@ -2294,8 +2288,7 @@
|
|||
"Haggai": "Hagai",
|
||||
"Isaiah": "Isaia",
|
||||
"Habakkuk": "Habacuc",
|
||||
"Ephesians": "Efeseni",
|
||||
"Revelation": "Apocalipsa"
|
||||
"Ephesians": "Efeseni"
|
||||
},
|
||||
"christian_per_cell": "Fiecare căsuță ≈ {n} de oameni",
|
||||
"howto_title": "Cum să te rogi cu această listă",
|
||||
|
|
@ -2470,7 +2463,7 @@
|
|||
"text": "Чтобы народ наполнился поклонниками — поклонение повсюду, в свободе и в цепях.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -2499,8 +2492,9 @@
|
|||
"sub": "благословение · великое · большее · одно из величайших",
|
||||
"text": "Молитесь над ними молитвой Иисуса из Иоанна 17. Благословение: вечная жизнь — знать Бога и Иисуса, Которого Он послал. Великое благословение: чтобы их собственные верующие приводили других к вере. Ещё большее благословение: новые духовные семьи, свои собственные, единые во Христе. Одно из величайших: лидеры из их среды, которые учат других начинать новые духовные семьи — снова и снова. Просите обо всём этом и о большем — превыше всего, чего мы просим или о чём помышляем. Просите, как Иисус, чтобы они были едины и сохранены от лукавого. Ничто из этого невозможно, если Иисус не Тот, Кем Он Себя называет.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -2589,7 +2583,6 @@
|
|||
"sets_bounded_title": "Достигнутые и нет",
|
||||
"sets_centered_title": "Расстояние до соседа",
|
||||
"sets_bounded_tally": "{inside} внутри · {outside} снаружи · {unknown} неизвестно",
|
||||
"sets_no_data": "{n} без данных",
|
||||
"sets_centered_tally": "{placed} размещено · {centre} в центре · {unplaced} без места",
|
||||
"ring_band_dialect": "Тот же диалект",
|
||||
"ring_band_language": "Тот же язык",
|
||||
|
|
@ -2624,8 +2617,7 @@
|
|||
"Haggai": "Агг.",
|
||||
"Isaiah": "Ис.",
|
||||
"Habakkuk": "Авв.",
|
||||
"Ephesians": "Еф.",
|
||||
"Revelation": "Откр."
|
||||
"Ephesians": "Еф."
|
||||
},
|
||||
"christian_per_cell": "Каждая клетка ≈ {n} чел.",
|
||||
"howto_title": "Как молиться по этому списку",
|
||||
|
|
@ -2809,7 +2801,7 @@
|
|||
"text": "أن يفيض الشعب بالساجدين — عبادة في كل مكان، في الحرية وفي القيود.",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -2838,8 +2830,9 @@
|
|||
"sub": "البركة · العظيمة · الأعظم · من أعظمها",
|
||||
"text": "صلِّ لأجلهم بصلاة يسوع في يوحنا ١٧. البركة: الحياة الأبدية، أن يعرفوا الله ويسوع الذي أرسله. البركة العظيمة: أن يقود المؤمنون منهم غيرهم إلى الإيمان. البركة الأعظم: عائلات روحية جديدة خاصة بهم، واحدة في المسيح. ومن أعظم البركات: قادة من بينهم يدرّبون آخرين ليبدؤوا عائلات روحية جديدة، مرة بعد مرة. اطلب كل هذا وأكثر، أكثر جدًا مما نطلب أو نفتكر. واطلب، كما طلب يسوع، أن يكونوا واحدًا وأن يُحفظوا من الشرير. لا شيء من هذا ممكن إن لم يكن يسوع هو مَن يقول إنه هو.",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -2928,7 +2921,6 @@
|
|||
"sets_bounded_title": "وصلتهم البشارة ومن لم تصلهم",
|
||||
"sets_centered_title": "المسافة إلى جار",
|
||||
"sets_bounded_tally": "{inside} في الداخل · {outside} في الخارج · {unknown} غير معروف",
|
||||
"sets_no_data": "{n} بلا بيانات",
|
||||
"sets_centered_tally": "{placed} موضوعون · {centre} في المركز · {unplaced} بلا موضع",
|
||||
"ring_band_dialect": "اللهجة نفسها",
|
||||
"ring_band_language": "اللغة نفسها",
|
||||
|
|
@ -2963,8 +2955,7 @@
|
|||
"Haggai": "حجي",
|
||||
"Isaiah": "إشعياء",
|
||||
"Habakkuk": "حبقوق",
|
||||
"Ephesians": "أفسس",
|
||||
"Revelation": "رؤيا"
|
||||
"Ephesians": "أفسس"
|
||||
},
|
||||
"christian_per_cell": "كل مربّع ≈ {n} شخص",
|
||||
"howto_title": "كيف تصلّي بهذه القائمة",
|
||||
|
|
@ -3141,7 +3132,7 @@
|
|||
"text": "कि वह जनसमूह आराधकों से भर जाए — हर जगह आराधना, स्वतंत्रता में और बेड़ियों में भी।",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -3170,8 +3161,9 @@
|
|||
"sub": "आशीष · बड़ी · और बड़ी · सबसे बड़ी में से एक",
|
||||
"text": "यूहन्ना 17 में यीशु ने जो प्रार्थना की, वही उनके लिए करें। आशीष: अनन्त जीवन — परमेश्वर को और यीशु को जानना, जिन्हें उन्होंने भेजा। बड़ी आशीष: उन्हीं के विश्वासी दूसरों को विश्वास में लाएँ। उससे बड़ी आशीष: उनके अपने नए आत्मिक परिवार, मसीह में एक। सबसे बड़ी आशीषों में से एक: उनके बीच से अगुवे, जो दूसरों को नए आत्मिक परिवार शुरू करने के लिए तैयार करें — बार-बार। यह सब और इससे भी अधिक माँगें, जितना हम माँगते या सोचते हैं उससे कहीं बढ़कर। यीशु की तरह माँगें कि वे एक हों और उस दुष्ट से बचे रहें। इनमें से कुछ भी संभव नहीं, यदि यीशु वह नहीं हैं जो वह कहते हैं कि वह हैं।",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -3257,7 +3249,6 @@
|
|||
"sets_bounded_title": "पहुँचे और नहीं पहुँचे",
|
||||
"sets_centered_title": "पड़ोसी से दूरी",
|
||||
"sets_bounded_tally": "{inside} भीतर · {outside} बाहर · {unknown} अज्ञात",
|
||||
"sets_no_data": "{n} बिना आँकड़ों के",
|
||||
"sets_centered_tally": "{placed} स्थित · {centre} केंद्र में · {unplaced} अस्थित",
|
||||
"ring_band_dialect": "वही बोली",
|
||||
"ring_band_language": "वही भाषा",
|
||||
|
|
@ -3292,8 +3283,7 @@
|
|||
"Haggai": "हाग्गै",
|
||||
"Isaiah": "यशायाह",
|
||||
"Habakkuk": "हबक्कूक",
|
||||
"Ephesians": "इफिसियों",
|
||||
"Revelation": "प्रकाशितवाक्य"
|
||||
"Ephesians": "इफिसियों"
|
||||
},
|
||||
"christian_per_cell": "हर खाना ≈ {n} लोग",
|
||||
"howto_title": "इस सूची के साथ प्रार्थना कैसे करें",
|
||||
|
|
@ -3468,7 +3458,7 @@
|
|||
"text": "愿这族群充满敬拜者——处处敬拜,在自由中,也在捆锁中。",
|
||||
"refs": [
|
||||
"Acts 16:25–26",
|
||||
"Revelation 7:9–10"
|
||||
"Colossians 3:16"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -3497,8 +3487,9 @@
|
|||
"sub": "祝福 · 大 · 更大 · 最大之一",
|
||||
"text": "用耶稣在约翰福音17章的祷告为他们祷告。祝福:永生,认识神和他所差来的耶稣。大祝福:他们自己的信徒带领别人信主。更大的祝福:属于他们自己的新属灵家庭,在基督里合而为一。最大的祝福之一:从他们中间兴起领袖,装备别人建立新的属灵家庭,一代又一代。求这一切,还有更多,超过我们所求所想的。也像耶稣一样,求神使他们合而为一,保守他们脱离那恶者。若耶稣不是他自己所说的那一位,这一切都不可能。",
|
||||
"refs": [
|
||||
"John 17:3,11–23",
|
||||
"Ephesians 3:20–21"
|
||||
"John 17:3",
|
||||
"John 17:11–23",
|
||||
"Ephesians 3:20"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
@ -3584,7 +3575,6 @@
|
|||
"sets_bounded_title": "已触及与未触及",
|
||||
"sets_centered_title": "与邻近族群的距离",
|
||||
"sets_bounded_tally": "{inside} 圈内 · {outside} 圈外 · {unknown} 未知",
|
||||
"sets_no_data": "{n} 无数据",
|
||||
"sets_centered_tally": "{placed} 已定位 · {centre} 在中心 · {unplaced} 未定位",
|
||||
"ring_band_dialect": "同一方言",
|
||||
"ring_band_language": "同一语言",
|
||||
|
|
@ -3619,8 +3609,7 @@
|
|||
"Haggai": "哈该书",
|
||||
"Isaiah": "以赛亚书",
|
||||
"Habakkuk": "哈巴谷书",
|
||||
"Ephesians": "以弗所书",
|
||||
"Revelation": "启示录"
|
||||
"Ephesians": "以弗所书"
|
||||
},
|
||||
"christian_per_cell": "每格约 {n} 人",
|
||||
"howto_title": "如何使用这份祷告名单",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"titleQualifier": "the coloured words before the region name, e.g. 'Unengaged of' (:2427)",
|
||||
"seriesName": "the name in quotes on the Series line and in the side box (:1877, :1907, :2115)",
|
||||
"sideBoxHeading": "the right-hand explainer's heading (:2097)",
|
||||
"sideBoxBody": "that explainer's first paragraph (:2115); in the head-columns band **...** prints a bold run",
|
||||
"sideBoxBody": "that explainer's first paragraph (:2115)",
|
||||
"strategyBody": "the Jesus column's text: one paragraph, or a list of them; absent = poster-render's STRATEGY_DEFAULT",
|
||||
"sideBoxMore": "the explainer's further paragraphs: one, or a list; [] prints none; absent = poster-render's WHAT_IS_MORE"
|
||||
},
|
||||
|
|
@ -68,8 +68,8 @@
|
|||
"titleQualifier": "Unengaged of",
|
||||
"seriesName": "UPG100 List",
|
||||
"sideBoxHeading": "What is a UPG100 List?",
|
||||
"sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists. Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise **informed intercession.**",
|
||||
"strategyBody": "2000 People Groups? Jesus’ disciples were faced with a similar problem, “How can we feed so many with only five loaves and two fish?” Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).",
|
||||
"sideBoxBody": "This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.",
|
||||
"strategyBody": "Jesus fed the 5,000 by multiplying a small offering. He had the crowd sit in groups of hundreds and fifties (Mark 6:40). So we pray by name, a few groups at a time.",
|
||||
"sideBoxMore": [],
|
||||
"headColumns": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -212,24 +212,6 @@ export function absoluteEmbedUrl(leaf, profileConfig = null) {
|
|||
return rel
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree's PUBLIC address, for the Share menu's Embed tab (no trailing slash). Its URL, Iframe and JavaScript codes
|
||||
* point here, so an embed follows the stable releases served there, never a dev server or the page's own host.
|
||||
* Ladder: profileConfig.shareBaseUrl → window.MAP_SHARE_BASE_URL → the build's shareBaseUrl (vite.doxa-maps-build-
|
||||
* config.json, baked as __DOXA_SHARE_BASE__) → '' (unset: the Embed tab keeps the tree the bundle was loaded from).
|
||||
* @param {object|null} [profileConfig] the parsed profile-config (already unref'd)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolvePublicBase(profileConfig) {
|
||||
// https only (an http base would give mixed-content codes on an https partner page), plus a local test server
|
||||
const ok = (v) => typeof v === 'string' && /^(https:\/\/|http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/)/i.test(v.trim())
|
||||
const fromConfig = profileConfig?.shareBaseUrl
|
||||
if (ok(fromConfig)) return stripSlashes(fromConfig)
|
||||
if (typeof window !== 'undefined' && ok(window.MAP_SHARE_BASE_URL)) return stripSlashes(window.MAP_SHARE_BASE_URL)
|
||||
// typeof-guarded: a build or a test that defines no __DOXA_SHARE_BASE__ reads '' instead of throwing
|
||||
return stripSlashes(typeof __DOXA_SHARE_BASE__ === 'string' ? __DOXA_SHARE_BASE__ : '')
|
||||
}
|
||||
|
||||
export function resolveShareUrl({ shareUrl, embedPath, profileConfig } = {}) {
|
||||
const cfg = profileConfig || null
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
/**
|
||||
* publicEmbed.js: the Share menu's Embed codes for THIS plugin, from the tree's PUBLIC address.
|
||||
*
|
||||
* Three codes, all for the plugin itself (its own leaf page, its own bundle + element), never for the app it is
|
||||
* nested in (Plugin Studio, Deep Research, Posterverse):
|
||||
* URL <base>/<bundle>/<profile>/index.html, the plugin's generated page;
|
||||
* Iframe <iframe src="that URL">;
|
||||
* JavaScript the plugin's own embed.script from the PUBLIC manifest (<base>/manifest.json), made absolute.
|
||||
*
|
||||
* ★ THE SCRIPT IS READ FROM THE PUBLIC MANIFEST, NOT BUILT HERE. The manifest's per-profile embed.script is the one
|
||||
* source of truth for the element tag, its profile-config and the page globals a plugin needs; the PUBLIC copy is the
|
||||
* release partners will load, so its tag and config match what they get. It is fetched once per base and cached
|
||||
* (the public host serves it with Access-Control-Allow-Origin: *).
|
||||
*
|
||||
* ★ NO INTEGRITY ON OUR BUNDLE. The public bundle URL is not versioned and is rebuilt in place at each release, so a
|
||||
* pinned integrity="sha384-…" would break every partner's embed at the next release. It is dropped from the copied
|
||||
* code for scripts served from the base; a versioned third-party URL (the Mapbox preload) keeps its own. Versioned
|
||||
* release URLs + SRI are a later improvement.
|
||||
*/
|
||||
|
||||
const cache = new Map() // base → Promise<manifest|null>
|
||||
|
||||
/** The tree-relative leaf path of an embedPath ('./x/y/index.html' → 'x/y/index.html'); '' for absolute or root paths. */
|
||||
export function treeRelative(embedPath) {
|
||||
const p = String(embedPath || '').trim()
|
||||
if (!p || /^https?:\/\//i.test(p) || p.startsWith('/')) return ''
|
||||
return p.replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
/** The plugin's public page: <base>/<leaf>. '' when either is unknown. */
|
||||
export const publicLeafUrl = (base, embedPath) => {
|
||||
const rel = treeRelative(embedPath)
|
||||
return base && rel ? base + '/' + rel : ''
|
||||
}
|
||||
|
||||
function loadManifest(base) {
|
||||
if (!cache.has(base)) {
|
||||
// the default credentials ('same-origin'): nothing is sent to a cross-origin public host, and behind a login gate
|
||||
// a same-origin base keeps its session
|
||||
const p = fetch(base + '/manifest.json')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.catch(() => { cache.delete(base); return undefined }) // a network failure may be retried later
|
||||
cache.set(base, p)
|
||||
}
|
||||
return cache.get(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's JavaScript embed code from the PUBLIC manifest.
|
||||
* Resolves { code } | { missing: true } (the public site does not carry this plugin yet) | { failed: true }.
|
||||
*/
|
||||
export async function publicScriptCode(base, embedPath) {
|
||||
const rel = treeRelative(embedPath)
|
||||
if (!base || !rel) return { missing: true }
|
||||
const m = await loadManifest(base)
|
||||
if (m === undefined) return { failed: true }
|
||||
if (!m || !Array.isArray(m.bundles)) return { missing: true }
|
||||
const norm = (u) => String(u || '').replace(/^\.\//, '')
|
||||
const [bundle, profile] = rel.split('/')
|
||||
let hit = null
|
||||
for (const b of m.bundles) for (const pr of (b.profiles || [])) {
|
||||
if (norm(pr.iframeUrl) === rel) hit = pr
|
||||
else if (!hit && b.bundle === bundle && pr.profile === profile) hit = pr // an instance path: its profile's code
|
||||
}
|
||||
const raw = hit && hit.embed && hit.embed.script
|
||||
if (!raw) return { missing: true }
|
||||
const esc = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const code = raw
|
||||
.replace(/(\s(?:src|href)=")\.\//g, '$1' + base + '/')
|
||||
.replace(/YOUR_TREE_URL/g, base)
|
||||
.replace(new RegExp('(<script\\b[^>]*\\ssrc="' + esc + '/[^"]*")\\s+integrity="[^"]*"', 'g'), '$1')
|
||||
return { code }
|
||||
}
|
||||
20
package.json
20
package.json
|
|
@ -69,8 +69,6 @@
|
|||
"check:describe-group": "node internal/check-describe-group.mjs",
|
||||
"check:descriptions": "node internal/check-profile-descriptions.mjs",
|
||||
"check:dev-assets": "node internal/check-dev-assets.mjs",
|
||||
"check:dev-leaf-escape": "node internal/check-dev-leaf-escape.mjs",
|
||||
"check:dev-leaf-escape:self-test": "node internal/check-dev-leaf-escape.mjs --self-test",
|
||||
"check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
|
||||
"check:readme-size": "node internal/check-readme-web-folder-size.mjs",
|
||||
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
|
||||
|
|
@ -118,8 +116,6 @@
|
|||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:gospel-internet": "node plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-i18n.mjs && node plugin-designs/gospel-internet/scripts/check-language-tree.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs",
|
||||
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
|
||||
"check:gospel-clear-filter-url": "node plugin-designs/gospel-resources/scripts/check-clear-filter-url.mjs && node plugin-designs/gospel-resources/scripts/check-clear-filter-url.mjs --poison",
|
||||
"check:gospel-template-translators": "node plugin-designs/gospel-resources/scripts/check-template-translators.mjs && node plugin-designs/gospel-resources/scripts/check-template-translators.mjs --poison",
|
||||
"check:gospel-whiteboard": "node internal/check-gospel-whiteboard.mjs",
|
||||
"check:hardcoded-english": "node internal/check-hardcoded-english.mjs",
|
||||
"check:hardcoded-english:self-test": "node internal/check-hardcoded-english.mjs --self-test",
|
||||
|
|
@ -266,8 +262,6 @@
|
|||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:poster-design-catalogue": "node plugin-designs/poster-design/scripts/check-catalogue.mjs",
|
||||
"check:poster-design-catalogue:self-test": "node plugin-designs/poster-design/scripts/check-catalogue.mjs --self-test",
|
||||
"check:upg100-zone-copy": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs",
|
||||
"check:upg100-zone-copy:self-test": "node plugin-designs/poster-design/scripts/check-upg100-zone-copy.mjs --self-test",
|
||||
"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",
|
||||
|
|
@ -363,7 +357,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test check:shell-header:self-test check:preview-flag-dev-only:self-test check:poster-type-registry:self-test check:host-floor:self-test check:phone-print-keep:self-test check:pinned-fetch-fresh:self-test",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:upg100-zone-copy check:upg100-zone-copy:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh check:view-address check:view-address:self-test check:dev-leaf-escape check:dev-leaf-escape:self-test check:gospel-clear-filter-url check:gospel-template-translators",
|
||||
"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:pins-in-state check:fork-leaders check:circular-layout check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:poster-section check:poster-section:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-sections check:poster-sections:self-test check:poster-design-catalogue check:poster-design-catalogue:self-test check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:upg-count check:shell-header check:pg-name-gate check:preview-flag-dev-only check:poster-type-registry check:host-floor check:phone-print-keep check:pinned-fetch-fresh",
|
||||
"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",
|
||||
|
|
@ -432,8 +426,6 @@
|
|||
"check:side-lanes:self-test": "node plugin-designs/country-posters/scripts/check-side-lanes.mjs --self-test",
|
||||
"check:poster-section": "node plugin-designs/country-posters/scripts/check-poster-section.mjs",
|
||||
"check:poster-section:self-test": "node plugin-designs/country-posters/scripts/check-poster-section.mjs --self-test",
|
||||
"check:view-address": "node internal/check-view-address.mjs",
|
||||
"check:view-address:self-test": "node internal/check-view-address.mjs --self-test",
|
||||
"check:poster-sections": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs",
|
||||
"check:poster-sections:self-test": "node plugin-designs/poster-sections/scripts/check-poster-sections.mjs --self-test",
|
||||
"gen:poster-sections-presets": "node plugin-designs/poster-sections/scripts/gen-presets.mjs",
|
||||
|
|
@ -449,7 +441,7 @@
|
|||
"check:pinned-fetch-fresh:self-test": "bun internal/check-pinned-fetch-fresh.mjs --self-test"
|
||||
},
|
||||
"//scripts": {
|
||||
"check:pinned-fetch-fresh": "a STALE cached copy of a pinned print-time file never decides a print: library/packet/sha384.js fetchPinned fetches force-cache, and on a copy that is not served or fails its pin, ONCE more with cache:'reload'; every pinned loader (posterSheets poster-render.js + ne-places.json, packetStrings, indiaStates) calls it, and the classic-script path refreshes the cache and retries a refused tag once. With a modelled HTTP cache holding stale copies: the current file after one fresh refetch; a matching cached copy used with one force-cache request; wrong bytes refused; page 1 still draws (generateSheet), the packet keeps every language, India keeps its 36 states. Self-test poisons: no fresh refetch, never the cache, a mismatch accepted, an unguarded crypto.subtle, a loader fetching its pinned file directly. On a page that is not secure (crypto.subtle missing: plain http by LAN address) the refusal says so in words (arm D2).",
|
||||
"check:pinned-fetch-fresh": "a STALE cached copy of a pinned print-time file never decides a print: library/packet/sha384.js fetchPinned fetches force-cache, and on a copy that is not served or fails its pin, ONCE more with cache:'reload'; every pinned loader (posterSheets poster-render.js + ne-places.json, packetStrings, indiaStates) calls it, and the classic-script path refreshes the cache and retries a refused tag once. With a modelled HTTP cache holding stale copies: the current file after one fresh refetch; a matching cached copy used with one force-cache request; wrong bytes refused; page 1 still draws (generateSheet), the packet keeps every language, India keeps its 36 states. Self-test poisons: no fresh refetch, never the cache, a mismatch accepted, a loader fetching its pinned file directly.",
|
||||
"check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl=<path> runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.",
|
||||
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
|
||||
"browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run <name>.",
|
||||
|
|
@ -509,8 +501,6 @@
|
|||
"check:window-globals-released": "a component that puts something on window must take it back. Heap snapshots (leak-722ac7789) measured SR+APG mounted twice at 54.2 -> 65.1 MB with live Vue apps 6 -> 8: every unmounted app stayed in memory, each pinned by one window key holding a closure, which keeps the component, its shadow root and the whole app. Flags only values that RETAIN (closures and object references), not warn-once latches or page-wide caches keyed off themselves. NOT in gates:pre: 19 sites remain across other owners' plugins. Run it directly.",
|
||||
"check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.",
|
||||
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
|
||||
"check:dev-leaf-escape": "a dev leaf URL cannot write markup into the page it opens. The dev server's /__leaf/<bundle>/<profile>[/<instance>] route (and a production embed URL rewritten onto it) writes the URL's parts into the page's <title> and the element's profile-config attribute; it refused only .. and /, so a quote or a tag broke out of the attribute on a publicly reachable dev server. internal/plugin-gallery/dev-plugin.mjs refuses any part outside LEAF_PART and escapes the <title>. Drives the plugin's own middleware (no server): every leaf the dev manifest advertises is served in both URL forms; 12 hostile parts are refused in every position; escHtml escapes & < > \" '; and behind the OLD rule the <title> still escapes. Bodies are never printed (a leaf page carries the token). Sub-5s.",
|
||||
"check:dev-leaf-escape:self-test": "4 poisons must each make check:dev-leaf-escape fail: the old guard (.. and / only), a part rule too strict for a real profile, an unescaped <title>, an escHtml that misses the quote.",
|
||||
"check:element-census-vs-live": "what is on ONE side and not the other, ours vs the live doxa.life page we clone: harvests every visible TEXT NODE through every shadow root on both sides and diffs the SETS. OURS-ONLY unaccounted = a scope violation (FAIL), THEIRS-ONLY = a clone omission (FAIL), SANCTIONED = an allowlist entry carrying the ruling + date + card (refused without them), DATA DRIFT = same label, different digits (reported). The negative control is MANDATORY: a known ours-only string must land in the ours-only set or it prints CENSUS IS BLIND and exits 75; an unreachable live page is also 75. Presets: internal/element-census/<name>.json (--preset <name>, default all); ad hoc: --leaf <bundle/profile/index.html[#hash]> --live <url> --neg <text> [--sanctioned <file>]. Promoted from regions' probe; regions passes (list 229 vs 228, the only difference its sanctioned poster toggle). Through browser-slot.sh.",
|
||||
"check:embed-base": "the shared URL resolver, pinned by ARITHMETIC — no browser, no network, no served tree, so it cannot SKIP itself into irrelevance. It computes what a leaf's own \"../../assets\" means and asserts the resolver agrees. Caught nothing for fifteen ticks because it did not exist: resolveAssetsBase computed one segment too high, every local gate passed (the dev web-root has assets BOTH inside and beside the tree), and the byte-level publish gates went green on the broken publish.",
|
||||
"check:embed-basemap": "the gospel map's BASEMAP must paint inside a genuinely cross-origin iframe on the token the page BAKES, not one a probe forces on it. Every other browser gate here forces a restricted token (u=doxalife) that 403s on the basemap, then counts PINS — which come from our own GeoJSON and paint with no token at all — so 'the map renders' had never been measured. Runs a negative control every time by rewriting the token on the wire; if the control passes it reports PROVED NOTHING rather than a pass. ~60s, needs a browser.",
|
||||
|
|
@ -537,8 +527,6 @@
|
|||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on the live test host): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:gated-not-in-tree": "a GATED profile (the build config's gatedProfiles, today key-cities) never reaches the public tree: no public/<tree>/<name>/ folder, no manifest.json entry, no page referencing <name>/<name>.js. vite.config.js builds a gated bundle into .gated/<tree>/ (ignored by git). Data tables are out of scope on purpose: key-cities.json also feeds the production Cities with Internet map. Two-sided: three planted leaks in a temporary tree, each caught. Node only.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:gospel-clear-filter-url": "the gospel tables' Show all drops ONLY its own filter keys (iso, country, label, province): the page's ?tab=, a host's or partner's keys, the #hash and the history state stay. It used to replaceState the bare pathname, wiping a partner's query. Proves the pure helper (filter-url.js) and that data-tables.vue calls it; --poison runs the old wipe and must fail.",
|
||||
"check:gospel-template-translators": "every translator a gospel-resources template calls (t, tr, T, tShared, tStatic) is a TOP-LEVEL binding of its <script setup>. data-tables.vue called t(...) while its translator is tr, so the tables page threw _ctx.t is not a function on every load and its loading line never drew. Static, node only; --poison restores the old call and must fail.",
|
||||
"check:gospel-internet": "the Gospel x Internet board, headless, against the REAL table and the REAL kit bus: the join table is what its builder makes from main (2,107 / 2,107, province placement agreeing with gospel-by-province), every facet count equals the rows the bus then keeps (the five-numbers-agree invariant), D1 OR-within-key, the country->province cascade, the 369 zero-access headline by band 220/45/45/59, URL state round-trip, CSV header-first/provenance-last with injection neutralised, no engagement column; then every catalogue key the bundle names exists in all 11 locales and is translated. Each half has a --poison arm that must fail. Plugin-local because check:i18n-keys-exist cannot see a useOptionalT-based alias.",
|
||||
"check:gospel-whiteboard": "the gospel set charts' geometry once drawn on a map: the bounded set's gap survives projection, the drawing stays inside the Mercator clamp, and three more properties, each with the arm that would catch its own defect. By the gospel owner. Headless.",
|
||||
"check:hardcoded-english": "English a reader sees that no locale can reach: template text, static title=, script-block sentences, template literals, and quoted literals inside BOUND attributes, across the production plugins. Quiet on translator calls (including discovered aliases and proxy-t misses), te()-guarded fallbacks, css, urls and developer error text. Deliberately NOT in gates:post yet: it reports 179 today (gospel-resources 148, my-upg-100-list 25, cities-internet 6 incl. one cross-area key), with English inside dir=ltr islands listed as deliberate and not counted; it joins when that reaches 0 or every hit is carded. The :self-test (28 assertions, fixtures validated first so a malformed sample cannot pass as silence) runs in gates:pre.",
|
||||
|
|
@ -632,8 +620,6 @@
|
|||
"check:poster-degrades": "with NO stored poster, NO source and NO generator (forced in the browser: every posters/*.webp and *.html answers 404, lib/poster-render.js is aborted), nothing must print and the reader must be told why. Measures any served tree; NOT MEASURED without a wall url.",
|
||||
"check:poster-design-catalogue": "Poster Design's configuration catalogue (plugin-designs/poster-design/src/templates.js) says only true things about the library: its sheet formats are exactly poster-render.js FORMATS at the same pixel sizes (the generator is a pinned asset, so the table cannot be imported); every configuration name is unique and well formed; the card entries are exactly card-render STOCK; every catalogue key it names exists in en/posterDesign.json; the sample country can be drawn. Six mutants (px drift, a missing format, a duplicate name, a bad key, an undrawable sample, a dropped stock) each FAIL. It also validates every plugin's posterDesign block (plugin-designs/<plugin>/<profile>.meta.json: family, name, size, mobileFriendly, optional labelKey and instance), keeps names unique across templates.js and all blocks, and fails when a name in the names ledger (src/catalogue-names.json) was renamed or removed.",
|
||||
"check:poster-design-catalogue:self-test": "an invalid posterDesign block, a name that clashes with templates.js, a renamed ledger name a block naming a preset that does not exist, and a sample that exists but cannot be drawn (India with withholding off) must each make check:poster-design-catalogue fail, and the documented example block must pass.",
|
||||
"check:upg100-zone-copy": "library/packet/upg100Zones/ is UPG100's zone-poster code copied into the library (so Poster Design draws UPG100 zone posters). COPY mode while UPG100's originals exist: each file equals its original beyond its import specifiers, clusters.json byte for byte. ONE-PATH mode once UPG100 switches and deletes its originals: no plugin file defines the zone code again. Half switched fails.",
|
||||
"check:upg100-zone-copy:self-test": "a one-character edit in a function body, a changed clusters.json, a missing copy, a half switch, and (one-path) a plugin re-defining makeZoneSheet must each fail; an import-specifier-only change and a clean one-path tree must pass.",
|
||||
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
|
||||
"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/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; NOT MEASURED otherwise.",
|
||||
|
|
@ -729,8 +715,6 @@
|
|||
"check:side-lanes:self-test": "a poster-render.js copy WITHOUT the lane clamp must make check:side-lanes' crossing arm FAIL on the same seeded data (it does: 36 crossings), so the arm is shown able to fail.",
|
||||
"check:poster-section": "the reusable poster section (<dxm-poster-section>): the type registry has 'country'; DX Life's /regions slugs (and every other spelling of a region) each show their countries, every country once; a withheld whole-country poster stays withheld through the type; the element's attributes map to its config; PosterSection.vue injects every name its template uses.",
|
||||
"check:poster-section:self-test": "a registry without 'country', a region key without \"&\" → \"and\", and a section that uses a name it does not inject must each make check:poster-section fail.",
|
||||
"check:view-address": "the page's address (library/composables/useViewAddress.js: ?tab= &pane= &place=) and its ONE history write, on a simulated browser history: the id whitelist + 64 cap and the level rule; an app owner's tab change is one push that keeps history.state with current (Vue Router's field) and the hash; nothing is written when unchanged, on a partner page (no urlOwner), or by a host owner (it hands the host one composed dxm-address). Coexistence with the filters (useShareableView writes through the same commitUrl): a filter write keeps tab/pane/place, a tab/pane/place write keeps the filters, both survive Back and Forward, and re-applying the tab after Back writes nothing. Nested choices (P2): claimView writes for the owner and announces for anyone else; ownAddress records announcements only while it owns, so a pane and its selection are one Back step; pane-wrapper's opt-in ?pane= (a link opens it, a click pushes, a settled scroll replaces, Back never writes; the default pane is never written); the Regions wiring (?pane= on regions-mfe, ?place= on region-dashboards instead of its #slug). Plus the census: every history.pushState/replaceState in library/ and plugin-designs/ source is the helper or a listed writer. Pure node, no build.",
|
||||
"check:view-address:self-test": "29 poisons must each make check:view-address fail. P1: a write without history.state.current, a write when not the owner, a tab change that keeps a stale place, no length cap, a write when unchanged, a host owner writing history, a dxm-address that cannot leave a shadow root, a filter write that drops tab/pane/place, the old replaceState(null) filter write, a tab write that drops the filters, a tab change written as a replace, a popstate handler that writes, a new history writer, useShareableView or region-dashboards writing history itself again. P2: a pane change that drops place, a non-owner claim that writes, an announcement carrying any key, ownAddress writing with no owner or letting a recorded announcement bubble on, pane-wrapper writing the default pane / a click's selection separately / a push where a scroll settles / on Back / ignoring a link / without the opt-in, and regions-mfe or region-dashboards wired without the address.",
|
||||
"check:poster-sections": "the Poster Sections page (plugin-designs/poster-sections): every panel choice (each region or All regions, each language, open or collapsed, 1-5 per row) builds <dxm-poster-section> tags that the element's own attribute reader turns back into exactly those choices; All regions is one section per region; Copy embed is the bundle script plus those same tags. The presets file holds, per poster type, exactly an all-regions preset plus one per region its adapter's scopes() returns, so a region the data drops (or gains) cannot drift from the presets; a poster type not on the tree has no presets (its pages could show no posters); and the built preset pages are exactly the presets (no stale page).",
|
||||
"check:poster-sections:self-test": "a tag builder that drops columns, one that drops collapsed, one that repeats a region under All regions, a preset for a region the type does not have, and a built page for a preset that no longer exists must each make check:poster-sections fail.",
|
||||
"gen:poster-sections-presets": "writes plugin-designs/poster-sections/poster-sections.instances.json from each poster type's own scopes() (all regions + one preset per region, titled by the type's own section title, then configure); the list check:poster-sections holds the file to.",
|
||||
|
|
|
|||
|
|
@ -3,12 +3,5 @@
|
|||
"type": "single",
|
||||
"label": "Bible Posters",
|
||||
"targets": ["web"],
|
||||
"posterDesign": {
|
||||
"family": "biblePoster",
|
||||
"name": "bible-poster.36x60",
|
||||
"size": "36x60",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "biblePosters.posterDesign.label"
|
||||
},
|
||||
"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. posterDesign: the whole-book poster is a large-format print (even standard paper is too small for a whole book), so mobileFriendly is false; the card/preview still renders on a phone."
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import { ISO3_NAME } from '@map/data/iso3-name.js'
|
|||
import seriesJson from '@map/scripts/posters/series.json'
|
||||
import { generatePoster, BLESSING } from './src/generate.js'
|
||||
import { loadBoundaries } from './src/boundaries.js'
|
||||
import FOOTER_VERSES from './src/footer-verses.json'
|
||||
|
||||
// The host (a shell, or a plain embed) passes profile-config; the shells (Posterverse, the Research
|
||||
// Gallery) write an ABSOLUTE assetsBase + treeBase into it. resolveAssetsBase reads that, else falls
|
||||
|
|
@ -97,6 +98,12 @@ const lang = String(parsedConfig.locale || parsedConfig.lang || urlLang || docLa
|
|||
const EN = (LOCALE_MSGS[msgPath('en')] || {}).default || {}
|
||||
const MSGS = (LOCALE_MSGS[msgPath(lang)] || {}).default || EN
|
||||
const dir = ['ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi'].includes(lang) ? 'rtl' : 'ltr'
|
||||
// The footer verse (Numbers 6:24-26) in the reader's language, STRICT public domain only (bible.helloao.org
|
||||
// / ebible.org PD translations). Falls back to the English PD blessing (WEB British) where there's no
|
||||
// strict-PD text yet (en, and ro/hi until sourced). The header is a phrase; this is scripture — hence PD-gated.
|
||||
const localFooter = FOOTER_VERSES[lang]
|
||||
? { text: FOOTER_VERSES[lang].text, ref: FOOTER_VERSES[lang].ref, dir: FOOTER_VERSES[lang].dir || 'ltr' }
|
||||
: BLESSING.footer
|
||||
const nf = (() => { try { return new Intl.NumberFormat(lang) } catch { return new Intl.NumberFormat('en') } })()
|
||||
const n = (x) => nf.format(Number(x) || 0)
|
||||
const t = (key, vars) => {
|
||||
|
|
@ -128,9 +135,9 @@ async function build() {
|
|||
])
|
||||
const { html, svg, stats: s, count } = await generatePoster({
|
||||
records, ...boundaries, seriesDef, iso3Name: ISO3_NAME, format: '60x96c4b',
|
||||
// The poster's own header is a phrase (not scripture), so localize it to the reader's language;
|
||||
// the footer verse stays the English PD blessing until the per-locale strict-PD sourcing lands.
|
||||
blessing: { ...BLESSING, header: t('posterHeader') },
|
||||
// The poster's own header is a phrase, localized to the reader's language; the footer is scripture,
|
||||
// localized only to a STRICT public-domain translation (else the English PD blessing). See localFooter.
|
||||
blessing: { ...BLESSING, header: t('posterHeader'), footer: localFooter },
|
||||
})
|
||||
posterHtml.value = html
|
||||
stats.value = s
|
||||
|
|
|
|||
71
plugin-designs/bless-india-posters/src/footer-verses.json
Normal file
71
plugin-designs/bless-india-posters/src/footer-verses.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"_meta": {
|
||||
"reference": "Numbers 6:24-26",
|
||||
"source": "verbatim strict public-domain translations (ebible.org via bible.helloao.org)",
|
||||
"display_normalizations": "display-only, no word or vowel change: ru sentence-initial capital; zh remove the ASCII space after the full stop between verses; pt drop mismatched outer quotes; ar alef-wasla to plain alef; ar ref uses Arabic-Indic digits + RLM after colon + en dash (matches the UPG-100 packet); ar verse quotes are « » guillemets"
|
||||
},
|
||||
"es": {
|
||||
"text": "Jehová te bendiga, y te guarde: Haga resplandecer Jehová su rostro sobre ti, y haya de ti misericordia: Jehová alce á ti su rostro, y ponga en ti paz.",
|
||||
"ref": "Números 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "Reina-Valera 1909",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"fr": {
|
||||
"text": "L'Éternel te bénisse et te garde! L'Éternel fasse luire sa face sur toi et te fasse grâce! L'Éternel tourne sa face vers toi et te donne la paix!",
|
||||
"ref": "Nombres 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "French Ostervald Bible",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"pt": {
|
||||
"text": "Yahweh te abençoe, e te guarde. Yahweh faz o seu rosto para brilhar em você, e seja gracioso com você. Yahweh levanta o rosto na sua direção, e lhe dar paz.",
|
||||
"ref": "Números 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "World Portuguese Bible",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"de": {
|
||||
"text": "Der HERR segne dich und behüte dich; der HERR lasse sein Angesicht leuchten über dir und sei dir gnädig; der HERR hebe sein Angesicht über dich und gebe dir Frieden.",
|
||||
"ref": "4. Mose 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "German Luther Bible 1912",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"it": {
|
||||
"text": "Il Signore ti benedica e ti guardi. Il Signore faccia risplendere le sua faccia verso te, e ti sia propizio. Alzi il Signore la sua faccia verso te, e ti stabilisca la pace.",
|
||||
"ref": "Numeri 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "Italian Diodati Bible",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"ru": {
|
||||
"text": "Да благословит тебя Господь и сохранит тебя! да призрит на тебя Господь светлым лицем Своим и помилует тебя! да обратит Господь лице Свое на тебя и даст тебе мир!",
|
||||
"ref": "Числа 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "Russian Synodal Bible",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"ar": {
|
||||
"text": "يُبَارِكُكَ الرَّبُّ وَيَحْرُسُكَ. يُضِيءُ الرَّبُّ بِوَجْهِهِ عَلَيْكَ وَيَرْحَمُكَ. يَرْفَعُ الرَّبُّ وَجْهَهُ عَلَيْكَ وَيَمْنَحُكَ سَلَامًا.",
|
||||
"ref": "العدد ٦:٢٤–٢٦",
|
||||
"dir": "rtl",
|
||||
"translation": "Arabic Van Dyck Bible",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
},
|
||||
"zh": {
|
||||
"text": "『愿耶和华赐福给你,保护你。愿耶和华使他的脸光照你,赐恩给你。愿耶和华向你仰脸,赐你平安。』",
|
||||
"ref": "民数记 6:24-26",
|
||||
"dir": "ltr",
|
||||
"translation": "Chinese Union Version (Simplified)",
|
||||
"licence": "public domain",
|
||||
"source": "ebible.org (via bible.helloao.org)"
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ const INDIA = {
|
|||
export const BLESSING = {
|
||||
header: "Bless India in Jesus' name",
|
||||
footer: {
|
||||
text: 'The LORD bless you, and keep you. The LORD make his face to shine on you, and be gracious to you. The LORD lift up his face toward you, and give you peace.',
|
||||
text: 'The LORD bless you, and keep you. The LORD make his face to shine on you, and be gracious to you. The LORD lift up his face towards you, and give you peace.',
|
||||
ref: 'Numbers 6:24-26',
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2554,6 +2554,19 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
while (H1_SIZE > H1_MIN && textW(h1Text, H1_SIZE) * H1_SERIF_BOLD > h1Room) H1_SIZE -= 0.5;
|
||||
|
||||
const verse = verseFor(zone);
|
||||
// Footer verse direction + quoting. Honour an RTL verse (footer-verses.json "dir":"rtl") with
|
||||
// direction=rtl + unicode-bidi so the Arabic reads right→left (verse 24 first), and isolate the
|
||||
// reference in FSI…PDI (U+2068…U+2069) so its "6:24-26" stays left-to-right. Skip our own curly
|
||||
// quotes when the text already opens with a quote/bracket (e.g. the Chinese Union Version's 『…』),
|
||||
// so it is not double-quoted.
|
||||
const vRtl = !!(verse && verse.dir === 'rtl');
|
||||
const vBracketed = /^[『「«“‘"']/.test(String((verse && verse.text) || ''));
|
||||
// Quote marks: Latin curly “ ” for LTR; Arabic guillemets « » for RTL verses (the Arabic convention,
|
||||
// and it matches the UPG-100 packet). None when the text already opens with its own quote/bracket.
|
||||
const vqOpen = vBracketed ? '' : (vRtl ? '«' : '“');
|
||||
const vqClose = vBracketed ? '' : (vRtl ? '»' : '”');
|
||||
const vTextAttrs = vRtl ? ' direction="rtl" style="unicode-bidi:embed;"' : '';
|
||||
const vRef = vRtl ? `${esc(verse.ref)}` : esc(verse.ref);
|
||||
// ── OSM STREET BASEMAP (city sheets only) ────────────────────────────────
|
||||
// A city sheet at ~9km needs STREETS; the ADM0/ADM1 polygon fill this template
|
||||
// draws has none, which is why the first 176 city posters shipped as blank
|
||||
|
|
@ -2695,7 +2708,7 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
whole thing reads as ONE centred line. It was left-aligned with the ref
|
||||
pushed to the right margin, which split it into two objects at opposite
|
||||
ends of the sheet rather than one closing statement. -->
|
||||
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">“${esc(verse.text)}”<tspan class="vref"> ${esc(verse.ref)}</tspan></text>`}
|
||||
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle"${vTextAttrs} class="verse">${vqOpen}${esc(verse.text)}${vqClose}<tspan class="vref"> ${vRef}</tspan></text>`}
|
||||
<!-- GENERATION DATE, bottom-left. Deliberately NO hard counts of the global
|
||||
unengaged total on this poster: that number changes as research updates, and
|
||||
a printed figure would silently go stale on a wall. What CAN be stated
|
||||
|
|
@ -2946,7 +2959,12 @@ async function renderPoster(zone, pins, zoneFC) {
|
|||
/* "Start with Prayer!" — the call to action above the three networks. */
|
||||
.hcta{font-family:Georgia,'Times New Roman',serif;font-size:21px;font-weight:700;
|
||||
fill:#3f3a2e;letter-spacing:.06em;font-variant:small-caps}
|
||||
.verse{font-family:Georgia,'Times New Roman',serif;font-style:italic;font-size:23px;font-weight:700;fill:#2A2721}
|
||||
/* 21px, not 23: the footer verse is one centred line and the blessing is now
|
||||
localized to each reader's language. The longest strict-PD translations (ru/de/it)
|
||||
run wider than the English; measured against the 2500px sheet (budget ~2408px
|
||||
inside the edge), 23px overflowed ru (2488px). 21px fits every locale with margin
|
||||
(widest ru 2284px) and still carries display weight at wall scale. */
|
||||
.verse{font-family:Georgia,'Times New Roman',serif;font-style:italic;font-size:21px;font-weight:700;fill:#2A2721}
|
||||
.vref{font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;font-weight:700;fill:#6B4A78;letter-spacing:.14em;text-transform:uppercase}
|
||||
.attrib{font-family:'Segoe UI',system-ui,sans-serif;font-size:9px;fill:#8d857a;letter-spacing:.02em}
|
||||
/* Haloed so the credits stay legible wherever they land on the map. */
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
{
|
||||
"type": "single",
|
||||
"label": "Country Posters — poster wall",
|
||||
"note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged.",
|
||||
"posterDesign": {
|
||||
"family": "countryPoster",
|
||||
"name": "country-poster.letter",
|
||||
"size": "letter",
|
||||
"mobileFriendly": true,
|
||||
"labelKey": "tabs.countryPosters"
|
||||
}
|
||||
"note": "The printable poster wall pane of the Country Posters element (country-posters-mfe composes it); also embeddable alone. profile-config `countries` (ISO-3 list or {code,name,count} records) or `region` (a WAGF region label) shows only those, flat, under a host's own region heading (DX Life's /regions, 2026-09-30); without them the wall is unchanged."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
*/
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ViewSelect from '@map/components/ViewSelect.vue'
|
||||
import { countryName } from '@map/utils/countryName.js'
|
||||
|
|
@ -105,11 +104,6 @@ const view = computed({
|
|||
})
|
||||
let column = null
|
||||
let offScroll = null
|
||||
let offOwn = null
|
||||
// The page address (useViewAddress): ?pane= is the open view ('map'; the first pane is the default and never written).
|
||||
// On this composite's own leaf it owns the address (ownAddress); nested in a shell it announces and the shell writes.
|
||||
// The India pane exists only in an opted-in preview, so ?pane=india names nothing anywhere else.
|
||||
const owner = () => urlOwnerOf(cfg.value)
|
||||
|
||||
// PAGE-LEVEL config keys a pane INHERITS from the host page. A cap-4 composite
|
||||
// nests children as custom elements in THIS document, so anything the generator
|
||||
|
|
@ -215,9 +209,8 @@ function onShowOnMap(e) {
|
|||
|
||||
onMounted(() => {
|
||||
if (!stage.value) return
|
||||
column = buildPaneColumn(spec.value, { host: stage.value, address: { owner } })
|
||||
column = buildPaneColumn(spec.value, { host: stage.value })
|
||||
if (!column) return
|
||||
offOwn = ownAddress(stage.value, owner)
|
||||
for (const pane of column.panes) {
|
||||
if (!pane.config) continue
|
||||
// ★ KEEP THE ELEMENT. It used to be created and dropped on the same line, so there was no
|
||||
|
|
@ -243,7 +236,6 @@ onMounted(() => {
|
|||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(SHOW_ON_MAP, onShowOnMap)
|
||||
offScroll?.()
|
||||
offOwn?.()
|
||||
column?.destroy()
|
||||
column = null
|
||||
})
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
:header="showHeader"
|
||||
:groups="navGroups"
|
||||
:active="activeKey"
|
||||
@update:active="onRailSelect">
|
||||
@update:active="onSelect">
|
||||
|
||||
<!-- #brand: the DX-Maps logo mark, left of the title (a compass-star glyph, self-drawn SVG — no
|
||||
external asset, offline/F-Droid safe; the accent green matches the admin shell). -->
|
||||
|
|
@ -216,7 +216,6 @@ import { helpFor } from './src/experimentalHelp.js'
|
|||
import { TAB_INFO } from './src/tabInfo.js'
|
||||
import MapInfo from '@map/components/info/MapInfo.vue'
|
||||
import { loadScriptOnce, whenTagDefined } from '@map/utils/embedScript.js'
|
||||
import { readViewKeys, writeViewKeys, onAddressChange, urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
|
||||
|
||||
// THE HOST PAGE'S LANGUAGE, by the same ladder every nested map already uses (library/i18n
|
||||
// detectLocale): ?lang= / ?locale= on the page URL, then <html lang>. '' when the page says nothing.
|
||||
|
|
@ -1131,14 +1130,6 @@ export default defineComponent({
|
|||
// A map's own "?" asks for its help with a composed doxa:help event (library HelpButton).
|
||||
this._onHostedHelp = (e) => this.onHostedHelp(e)
|
||||
this.$el.addEventListener('doxa:help', this._onHostedHelp)
|
||||
// Back / Forward re-open the tab the address names (useViewAddress), and never write. Only where a page OWNS
|
||||
// its address: on a partner's page a popstate is the partner's own navigation, and re-reading the URL there
|
||||
// would snap a host's fixed `tab` attribute back to the default. The owner is read per event: a host may set
|
||||
// profile-config after mount.
|
||||
this._offAddress = onAddressChange(({ tab }) => { if (urlOwnerOf(this.cfg)) this.openFromAddress(tab) })
|
||||
// A nested composite's choice (its open pane, a selected place) reaches the address through this shell, the
|
||||
// page's one writer, while the page owns its address; a nested element can never switch this shell's tab.
|
||||
this._offOwn = ownAddress(this.$el, () => urlOwnerOf(this.cfg))
|
||||
},
|
||||
// Release the media-query listener. This element is a custom element a host can add and REMOVE at
|
||||
// will (a cap-5 SPA is mounted and unmounted by shells, tabs and the studio), and a live listener
|
||||
|
|
@ -1146,8 +1137,6 @@ export default defineComponent({
|
|||
beforeUnmount() {
|
||||
if (this._langObs) { this._langObs.disconnect(); this._langObs = null }
|
||||
if (this._onHostedHelp) { this.$el.removeEventListener('doxa:help', this._onHostedHelp); this._onHostedHelp = null }
|
||||
if (this._offAddress) { this._offAddress(); this._offAddress = null }
|
||||
if (this._offOwn) { this._offOwn(); this._offOwn = null }
|
||||
if (!this._mql || !this._onMedia) return
|
||||
if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMedia)
|
||||
else if (this._mql.removeListener) this._mql.removeListener(this._onMedia)
|
||||
|
|
@ -1354,13 +1343,8 @@ export default defineComponent({
|
|||
const candidates = crossOrigin ? [copy, root] : [root, copy]
|
||||
// The tree's name comes from the build config (vite define), never a literal here: this was the
|
||||
// one tree path compiled into any bundle, so a renamed tree would have needed a gallery edit.
|
||||
// ★ THE DEV SERVER SERVES public/ AT THE ROOT. Reached as /public/<tree>/…, Vite treats a built bundle as SOURCE
|
||||
// and injects an import into its IIFE, so a child loaded as a classic script throws "Cannot use import statement
|
||||
// outside a module" (measured on the staging page, 2026-10-03). So the root path comes first, served as the raw
|
||||
// build; /public/<tree>/ stays last, for a plain static server of the repository, which transforms nothing.
|
||||
for (const devFallback of ['/' + __DOXA_TREE_NAME__ + '/manifest.json', '/public/' + __DOXA_TREE_NAME__ + '/manifest.json']) {
|
||||
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
|
||||
}
|
||||
const devFallback = '/public/' + __DOXA_TREE_NAME__ + '/manifest.json'
|
||||
if (!candidates.some(c => c.url === devFallback)) candidates.push({ url: devFallback, root: dirOf(devFallback) })
|
||||
const tried = []
|
||||
// Every mount waits for this gate (see FRESH BUNDLES above): it opens once the manifest's bundles are known
|
||||
// to be current, when no manifest can be read (fail-soft, as before), or after 8 s whatever happens.
|
||||
|
|
@ -1456,11 +1440,10 @@ export default defineComponent({
|
|||
this._honouredTab = true
|
||||
},
|
||||
// The tab a HOST asked to open first: the `tab` attribute, then profile-config.tab, then ?tab= on
|
||||
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked. The URL is read
|
||||
// through useViewAddress: the first ?tab= wins, and a value that is not a plain id reads as absent.
|
||||
// the page URL (so an iframe src or a shared link can deep-link). '' when nobody asked.
|
||||
requestedTab() {
|
||||
let q = ''
|
||||
try { q = readViewKeys(location.search).tab } catch { /* non-browser */ }
|
||||
try { q = new URLSearchParams(location.search).get('tab') || '' } catch { /* non-browser */ }
|
||||
return String(this.tab || this.cfg.tab || q || '').trim()
|
||||
},
|
||||
// The index to open on mount: the requested tab when it exists and is enabled at this width,
|
||||
|
|
@ -1513,26 +1496,6 @@ export default defineComponent({
|
|||
const i = this.tabs.findIndex(t => t.id === key)
|
||||
if (i !== -1) this.select(i)
|
||||
},
|
||||
// ★ THE PAGE ADDRESS (shareable pages, useViewAddress). A READER's pick in the rail is the one switch that
|
||||
// writes ?tab=, as a push, so a reload keeps the tab, a sent link opens it, and Back returns to the previous one.
|
||||
// Who writes is the page's urlOwner: 'app' (our own leaf page) writes history, 'host' (DX Life) gets a
|
||||
// dxm-address event and its router writes, and a partner's page is never written. Every other switch is not a
|
||||
// choice and writes nothing: the manifest's re-select, the device fallback, a host's `tab` attribute, Back and
|
||||
// Forward. Nor is there a first-paint write: a bare URL means the device's default tab, and pinning a phone's
|
||||
// default into the address would send a desktop reader to it.
|
||||
onRailSelect(key) {
|
||||
const before = this.activeKey
|
||||
this.onSelect(key)
|
||||
if (this.activeKey !== before) writeViewKeys({ tab: this.activeKey }, { owner: urlOwnerOf(this.cfg), push: true, el: this.$el })
|
||||
},
|
||||
// Back / Forward: the tab the address names, else the page's configured first tab (profile-config.tab), else the
|
||||
// device default. Not the `tab` attribute: a host with a router re-points it from this same popstate, and until
|
||||
// it does, it still names the tab being left.
|
||||
openFromAddress(tab) {
|
||||
const want = tab || String(this.cfg.tab || '').trim()
|
||||
const i = want ? this.tabs.findIndex(t => t.id === want) : -1
|
||||
this.select(i >= 0 && !this.isDesktopOnly(want) ? i : this.firstVisibleIndex())
|
||||
},
|
||||
// Resolve a tree-relative ref ("./x" / "x") against the tree base. If base is '' we keep it
|
||||
// relative to THIS document — but this bundle's own folder is <base>/doxa-maps-spa/, so a
|
||||
// co-located leaf at <base>/big-picture/... is one level UP: prefix '../'.
|
||||
|
|
|
|||
|
|
@ -35,9 +35,7 @@
|
|||
with no page reload; each map tab loads lazily on first open.</p>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<!-- urlOwner 'app': this page is ours (the dev server serves it), so a tab pick writes ?tab= and a reload or a
|
||||
sent link opens that tab (useViewAddress). A partner's page leaves it out and is never written. -->
|
||||
<doxa-maps-spa id="dms" heading="Deep Research" profile-config='{"urlOwner":"app"}'></doxa-maps-spa>
|
||||
<doxa-maps-spa id="dms" heading="Deep Research"></doxa-maps-spa>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ import { EN_MESSAGES } from '@map/i18n/index.js'
|
|||
import DataTable from '@map/components/tables/DataTable.vue'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
||||
import { commitUrl, urlOwnerOf } from '@map/composables/useViewAddress.js'
|
||||
import { searchWithout, FILTER_KEYS } from './filter-url.js'
|
||||
|
||||
|
||||
const profileConfig = inject('profileConfig', ref(null))
|
||||
|
|
@ -150,12 +148,9 @@ const fLabel = ref((params.get('label') || '').trim())
|
|||
const fProvince = ref((params.get('province') || '').trim())
|
||||
const fold = (v) => String(v || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
|
||||
const hasFilter = computed(() => !!(fIso.value || fCountry.value || fProvince.value))
|
||||
// "Show all" drops only the filter's own keys (filter-url.js) through the one history writer, and only on a page
|
||||
// this element owns (urlOwner 'app'); a partner's or a host's address is never written. The tab, every other key,
|
||||
// the hash and the history state stay.
|
||||
function clearFilter() {
|
||||
fIso.value = ''; fCountry.value = ''; fLabel.value = ''; fProvince.value = ''
|
||||
if (urlOwnerOf(profileConfig.value) === 'app') commitUrl({ search: searchWithout(window.location.search, FILTER_KEYS) })
|
||||
try { window.history.replaceState({}, '', window.location.pathname) } catch (_) {}
|
||||
}
|
||||
|
||||
const active = ref(TABS.some((t) => t.id === params.get('tab')) ? params.get('tab') : 'links')
|
||||
|
|
@ -458,7 +453,7 @@ useShadowStyles(
|
|||
</div>
|
||||
|
||||
<div v-if="loadErr" class="gt-err">{{ loadErr }}</div>
|
||||
<div v-else-if="!table" class="gt-load">{{ tr('gospel.sheet.loadingTable', { table: tabLabel(current) }) }}</div>
|
||||
<div v-else-if="!table" class="gt-load">{{ t('gospel.sheet.loadingTable', { table: current.label }) }}</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:key="current.id + ':' + fIso + ':' + fCountry + ':' + fProvince"
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* filter-url.js — the query after this page drops its OWN filter keys.
|
||||
*
|
||||
* The tables page reads a deep-link filter from the query (?iso= / ?country= / ?label= / ?province=).
|
||||
* "Show all" must remove exactly those keys: the page's ?tab=, any key a host or a partner put on the
|
||||
* address, and the #hash are someone else's and stay as they were. The result is in commitUrl's `search`
|
||||
* shape ('' or '?…'), and every other parameter is kept as written, byte for byte. Pure (no DOM), so a node
|
||||
* check can prove it without a browser.
|
||||
*/
|
||||
export const FILTER_KEYS = ['iso', 'country', 'label', 'province']
|
||||
|
||||
/** `search` ('' or '?…') without the parameters named in `keys`, in the same shape. */
|
||||
export function searchWithout(search, keys = FILTER_KEYS) {
|
||||
const kept = String(search || '').replace(/^\?/, '').split('&').filter((p) => p && !keys.includes(p.split('=')[0]))
|
||||
return kept.length ? '?' + kept.join('&') : ''
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-clear-filter-url.mjs — "Show all" on the gospel tables drops ONLY the filter's own keys.
|
||||
*
|
||||
* WHY: clearFilter used to replaceState({}, '', pathname), which wiped the whole query and the hash —
|
||||
* the page's own ?tab=, and any key a host or a partner had put on the address — and the history
|
||||
* state with them. The address after "Show all" must differ from the one before by the filter keys
|
||||
* alone.
|
||||
*
|
||||
* Proves it two ways: the pure helper (filter-url.js) on real-shaped addresses, and the component —
|
||||
* data-tables.vue's clearFilter writes through commitUrl (the one history writer, which keeps the hash and the
|
||||
* state) only on a page it owns, with no direct history write left.
|
||||
* `--poison` runs the old behaviour through the same assertions and requires them to FAIL.
|
||||
* Exit 0 pass · 1 fail.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { searchWithout, FILTER_KEYS } from '../filter-url.js'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const POISON = process.argv.includes('--poison')
|
||||
// The address after "Show all", as commitUrl writes it ({ search } given, the current hash kept). Poison: the old wipe.
|
||||
const strip = POISON ? (href) => new URL(href).pathname : (href) => { const u = new URL(href); return u.pathname + searchWithout(u.search, FILTER_KEYS) + u.hash }
|
||||
|
||||
let fails = 0
|
||||
const ok = (c, m) => { if (c) console.log('ok ' + m); else { fails++; console.log('FAIL ' + m) } }
|
||||
const base = 'https://example.org/dx-maps/gospel-resources/data-tables/index.html'
|
||||
|
||||
const a = strip(`${base}?tab=links&iso=hin&country=IND&label=Hindi&province=Bihar&partner=abc#tables`)
|
||||
ok(!/[?&](iso|country|label|province)=/.test(a), `the filter keys are gone (${a})`)
|
||||
ok(/[?&]tab=links\b/.test(a), 'the page\'s own ?tab= stays')
|
||||
ok(/[?&]partner=abc\b/.test(a), 'a host\'s or partner\'s key stays')
|
||||
ok(a.endsWith('#tables'), 'the #hash stays')
|
||||
const b = strip(`${base}?iso=hin`)
|
||||
ok(b === new URL(base).pathname, `only filter keys: no dangling "?" (${b})`)
|
||||
const c = strip(`${base}?tab=countries`)
|
||||
ok(c === new URL(base).pathname + '?tab=countries', 'no filter: the address is unchanged')
|
||||
const d = strip(`${base}?tab=links&q=a%20b+c&iso=hin`)
|
||||
ok(d.endsWith('?tab=links&q=a%20b+c'), `other parameters are kept as written, byte for byte (${d})`)
|
||||
|
||||
if (!POISON) {
|
||||
const vue = readFileSync(resolve(HERE, '../data-tables.vue'), 'utf8')
|
||||
const fn = (vue.match(/function clearFilter\(\)\s*\{[\s\S]*?\n\}/) || [''])[0]
|
||||
ok(/import \{[^}]*\bcommitUrl\b[^}]*\burlOwnerOf\b[^}]*\} from '@map\/composables\/useViewAddress\.js'/.test(vue), 'data-tables.vue imports the one history writer (commitUrl) and urlOwnerOf')
|
||||
ok(/commitUrl\(\{\s*search:\s*searchWithout\(window\.location\.search,\s*FILTER_KEYS\)\s*\}\)/.test(fn), 'clearFilter writes through commitUrl with only the filter keys dropped (the hash and state kept by commitUrl)')
|
||||
ok(/urlOwnerOf\(profileConfig\.value\)\s*===\s*'app'/.test(fn), 'and only on a page it owns (urlOwner app): a partner\'s address is never written')
|
||||
ok(!/\.(push|replace)State\s*\(/.test(vue), 'no direct history write is left in data-tables.vue')
|
||||
}
|
||||
|
||||
if (POISON) { console.log(fails ? `POISON OK: ${fails} caught` : 'POISON FAILED'); process.exit(fails ? 0 : 1) }
|
||||
console.log(fails ? `FAIL check-clear-filter-url: ${fails}` : 'PASS check-clear-filter-url')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-template-translators.mjs — every translator a gospel-resources template calls is bound in its <script setup>.
|
||||
*
|
||||
* WHY: data-tables.vue's template called t(...) while its translator is `tr`, and no top-level `t` existed. A
|
||||
* <script setup> template resolves an unbound name through the component proxy (_ctx.t), so the tables page threw
|
||||
* "_ctx.t is not a function" (minified "I.t") on every load, the loading line never drew, and nothing failed a gate.
|
||||
*
|
||||
* For each .vue in plugin-designs/gospel-resources: the translator-shaped calls in its template (t, tr, T, tShared,
|
||||
* tStatic) must each be a TOP-LEVEL binding of its <script setup> (const / let / function / import), not a name
|
||||
* that only appears inside a function. `--poison` runs the same check on the old data-tables.vue line and must fail.
|
||||
* Exit 0 pass · 1 fail.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const POISON = process.argv.includes('--poison')
|
||||
const NAMES = ['t', 'tr', 'T', 'tShared', 'tStatic']
|
||||
|
||||
function unbound(src) {
|
||||
const tpl = (src.match(/^<template>([\s\S]*)^<\/template>/m) || [])[1] || ''
|
||||
const script = (src.match(/^<script setup[^>]*>([\s\S]*?)^<\/script>/m) || [])[1] || ''
|
||||
// TOP-LEVEL bindings: declarations at column 0 of the script block, and imported names.
|
||||
const top = new Set()
|
||||
for (const m of script.matchAll(/^(?:const|let|var|function)\s+([A-Za-z_$][\w$]*)/gm)) top.add(m[1])
|
||||
for (const m of script.matchAll(/^(?:const|let|var)\s+\{([^}]*)\}\s*=/gm)) for (const n of m[1].split(',')) top.add(n.split(':').pop().trim())
|
||||
for (const m of script.matchAll(/^import\s+\{([^}]*)\}/gm)) for (const n of m[1].split(',')) top.add(n.split(/\s+as\s+/).pop().trim())
|
||||
const called = new Set([...tpl.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*)\(/g)].map((m) => m[1]).filter((n) => NAMES.includes(n)))
|
||||
return [...called].filter((n) => !top.has(n))
|
||||
}
|
||||
|
||||
let fails = 0
|
||||
if (POISON) {
|
||||
const src = readFileSync(join(DIR, 'data-tables.vue'), 'utf8').replace("{{ tr('gospel.sheet.loadingTable'", "{{ t('gospel.sheet.loadingTable'")
|
||||
const u = unbound(src)
|
||||
console.log(u.includes('t') ? 'POISON OK: the old data-tables.vue call t(...) is caught as unbound' : 'POISON FAILED')
|
||||
process.exit(u.includes('t') ? 0 : 1)
|
||||
}
|
||||
for (const f of readdirSync(DIR).filter((f) => f.endsWith('.vue'))) {
|
||||
const u = unbound(readFileSync(join(DIR, f), 'utf8'))
|
||||
if (u.length) { fails++; console.log(`FAIL ${f}: the template calls ${u.map((n) => n + '(…)').join(', ')} but its <script setup> binds no such name at the top level`) }
|
||||
else console.log(`ok ${f}`)
|
||||
}
|
||||
console.log(fails ? `FAIL check-template-translators: ${fails}` : 'PASS check-template-translators')
|
||||
process.exit(fails ? 1 : 0)
|
||||
|
|
@ -1,27 +1,4 @@
|
|||
{
|
||||
"_comment": "The printable half of UPG-100: a wall of prayer-card posters, one per 100-list entry, with the three print paths. Declared so the Studio rail names it rather than falling back to a humanized id (check:studio-rail).",
|
||||
"label": "UPG100 Posters — Poster Wall",
|
||||
"posterDesign": [
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.letter",
|
||||
"size": "letter",
|
||||
"mobileFriendly": true,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
},
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.3x3",
|
||||
"size": "3x3",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
},
|
||||
{
|
||||
"family": "upgList",
|
||||
"name": "upg100-territory-poster.3x4",
|
||||
"size": "3x4",
|
||||
"mobileFriendly": false,
|
||||
"labelKey": "upgMap.posterDesignTerritory"
|
||||
}
|
||||
]
|
||||
"label": "UPG100 Posters — Poster Wall"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@
|
|||
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useOptionalT } from '@map/composables/useOptionalT.js'
|
||||
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
|
||||
import { urlOwnerOf, ownAddress } from '@map/composables/useViewAddress.js'
|
||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||
import ViewSelect from '@map/components/ViewSelect.vue'
|
||||
import ShareButton from '@map/components/map-controls/ShareButton.vue'
|
||||
|
|
@ -72,8 +71,6 @@ import MapInfoButton from '@map/components/map-controls/MapInfoButton.vue'
|
|||
const t = useOptionalT('')
|
||||
|
||||
const cfg = inject('profileConfig', ref(null))
|
||||
// P2: the pane in view is part of the address (?pane=), written by whoever owns this page (urlOwnerOf).
|
||||
const owner = () => urlOwnerOf(cfg.value)
|
||||
const tk = inject('mapboxToken', ref(''))
|
||||
const uiStore = inject('uiStore', null)
|
||||
const isDark = computed(() => !!uiStore?.isDark)
|
||||
|
|
@ -153,7 +150,6 @@ const view = computed({
|
|||
})
|
||||
let column = null
|
||||
let offScroll = null
|
||||
let offOwn = null // P2: ownAddress's undo, called on unmount
|
||||
// The mounted custom element per pane id. Kept because a cross-pane action has to reach the
|
||||
// TARGET PANE'S ELEMENT: upg-100-list listens for 'doxa:select-zone' on itself rather than on
|
||||
// window, precisely so two embeds on one page do not cross-talk — which means the shell has to
|
||||
|
|
@ -304,9 +300,8 @@ function onShowOnMap(e) {
|
|||
|
||||
onMounted(() => {
|
||||
if (!stage.value) return
|
||||
column = buildPaneColumn(spec.value, { host: stage.value, address: { owner } })
|
||||
column = buildPaneColumn(spec.value, { host: stage.value })
|
||||
if (!column) return
|
||||
offOwn = ownAddress(stage.value, owner)
|
||||
for (const pane of column.panes) {
|
||||
if (!pane.config) continue
|
||||
const el = paneElement(pane)
|
||||
|
|
@ -335,8 +330,6 @@ onMounted(() => {
|
|||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('doxa-upg-100-list:show-on-map', onShowOnMap)
|
||||
offScroll?.()
|
||||
offOwn?.()
|
||||
offOwn = null
|
||||
column?.destroy()
|
||||
column = null
|
||||
// Drop the element references with the column that owned them — holding a detached custom
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue