merge main (studio landing 17 carried the Mark 5 sources)

This commit is contained in:
Doxa Maps 2026-09-27 09:19:23 +00:00
commit 082dbb8c18
108 changed files with 3317 additions and 485 deletions

3
.gitignore vendored
View file

@ -145,3 +145,6 @@ data-private/
/public/assets/
/public/__gallery/
/public/index.html
# Local plugin backends (backend/<name>/) — the compiled binaries are rebuilt from source, never committed.
/backend/bin/

View file

@ -0,0 +1,456 @@
// devices.go — microphone discovery, hot-plug, and the sound-server environment.
//
// The Jitsi Meet pattern (lib-jitsi-meet modules/RTC/RTCUtils.js), done natively because Rudder records
// outside the browser:
//
// Jitsi (browser) Rudder (native, PipeWire/PulseAudio)
// navigator.mediaDevices.enumerateDevices() → pactl -f json list sources
// 'devicechange' event + polling fallback → pactl subscribe (+ re-list on every source/card event)
// compareAvailableMediaDevices(new) → sameDevices(old, new) — only emit on a real change
// getUserMedia({audio:{deviceId:{exact:id}}}) → parec --device=<id>
//
// Jitsi also reports the device list to analytics (sendDeviceListToAnalytics). Rudder never does:
// the list goes to the local plugin over SSE and nowhere else.
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"math"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
)
// audioEnv gives child processes a path to the user's sound server even when the engine was launched
// from a context without a desktop session (a detached shell, a service, an agent's terminal). Measured
// 2026-09-27: started with no XDG_RUNTIME_DIR, parec exited instantly with "Connection refused" and every
// recording captured nothing, although the socket sat at /run/user/<uid>/pulse/native all along.
func audioEnv() []string {
env := os.Environ()
rt := os.Getenv("XDG_RUNTIME_DIR")
if rt == "" {
rt = fmt.Sprintf("/run/user/%d", os.Getuid())
if st, err := os.Stat(rt); err == nil && st.IsDir() {
env = append(env, "XDG_RUNTIME_DIR="+rt)
}
}
// systemctl --user (a headset-reset command may restart PipeWire) needs the session bus.
if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
if _, err := os.Stat(rt + "/bus"); err == nil {
env = append(env, "DBUS_SESSION_BUS_ADDRESS=unix:path="+rt+"/bus")
}
}
if os.Getenv("PULSE_SERVER") == "" {
if _, err := os.Stat(rt + "/pulse/native"); err == nil {
env = append(env, "PULSE_SERVER=unix:"+rt+"/pulse/native")
}
}
return env
}
// Device is one microphone as the plugin shows it.
type Device struct {
ID string `json:"id"` // the sound server's source name: what parec --device takes
Label string `json:"label"` // human name ("My Headset", "Built-in Audio Analog Stereo")
Bus string `json:"bus"` // bluetooth | usb | pci | ...
Default bool `json:"default"` // the system's default input
Muted bool `json:"muted"`
Volume string `json:"volume"` // e.g. "52%"
State string `json:"state"` // RUNNING | IDLE | SUSPENDED
}
// DeviceList is the whole picture: the mics, plus sound cards that have a mic but are switched off
// (the laptop's built-in card, profile "off", is the common case — its mic simply doesn't exist until
// the card is turned on in Sound settings).
type DeviceList struct {
Devices []Device `json:"devices"`
Default string `json:"default"`
OffCards []string `json:"offCards"`
Error string `json:"error,omitempty"`
}
func runPactl(args ...string) ([]byte, error) {
cmd := exec.Command("pactl", args...)
cmd.Env = audioEnv()
return cmd.Output()
}
func listDevices() DeviceList {
out := DeviceList{Devices: []Device{}, OffCards: []string{}}
if _, err := exec.LookPath("pactl"); err != nil {
out.Error = "pactl not found: install pipewire-pulse or pulseaudio-utils to list microphones"
return out
}
if b, err := runPactl("get-default-source"); err == nil {
out.Default = strings.TrimSpace(string(b))
}
b, err := runPactl("-f", "json", "list", "sources")
if err != nil {
out.Error = "cannot reach the sound server (pactl list sources failed: " + err.Error() + ")"
return out
}
var raw []struct {
Name string `json:"name"`
Description string `json:"description"`
State string `json:"state"`
Mute bool `json:"mute"`
Volume map[string]any `json:"volume"`
Properties map[string]any `json:"properties"`
}
if err := json.Unmarshal(b, &raw); err != nil {
out.Error = "unexpected pactl output: " + err.Error()
return out
}
for _, s := range raw {
// A ".monitor" source is the loopback of a SPEAKER, not a microphone: listing it would let the
// user "record" their own music. Hidden, as Jitsi lists only audioinput kinds.
if strings.HasSuffix(s.Name, ".monitor") || s.Properties["device.class"] == "monitor" {
continue
}
d := Device{ID: s.Name, Label: s.Description, State: s.State, Muted: s.Mute, Default: s.Name == out.Default}
if bus, ok := s.Properties["device.bus"].(string); ok {
d.Bus = bus
}
for _, ch := range s.Volume { // first channel is representative
if m, ok := ch.(map[string]any); ok {
if p, ok := m["value_percent"].(string); ok {
d.Volume = p
break
}
}
}
out.Devices = append(out.Devices, d)
}
// Cards whose active profile is "off" but which offer an input profile.
if b, err := runPactl("-f", "json", "list", "cards"); err == nil {
var cards []struct {
Name string `json:"name"`
ActiveProfile string `json:"active_profile"`
Properties map[string]any `json:"properties"`
Profiles map[string]struct {
Sources int `json:"sources"`
Available bool `json:"available"`
} `json:"profiles"`
}
if json.Unmarshal(b, &cards) == nil {
for _, c := range cards {
if c.ActiveProfile != "off" {
continue
}
for _, p := range c.Profiles {
if p.Sources > 0 && p.Available {
label, _ := c.Properties["device.description"].(string)
if label == "" {
label = c.Name
}
out.OffCards = append(out.OffCards, label)
break
}
}
}
}
}
return out
}
// sameDevices mirrors Jitsi's compareAvailableMediaDevices: emit only when something a user would
// notice changed (a mic came, went, was muted, or the default moved), not on every state flicker.
func sameDevices(a, b DeviceList) bool {
if a.Default != b.Default || len(a.Devices) != len(b.Devices) || a.Error != b.Error || len(a.OffCards) != len(b.OffCards) {
return false
}
for i := range a.Devices {
x, y := a.Devices[i], b.Devices[i]
if x.ID != y.ID || x.Label != y.Label || x.Muted != y.Muted || x.Volume != y.Volume {
return false
}
}
return true
}
// deviceWatcher keeps the current list and pushes a "devices" event on every real change. It listens
// to `pactl subscribe` (the native 'devicechange') and, like Jitsi, also polls as a fallback in case
// the subscription dies.
type deviceWatcher struct {
mu sync.Mutex
last DeviceList
hub *hub
logs *ring
}
func (w *deviceWatcher) current() DeviceList {
w.mu.Lock()
defer w.mu.Unlock()
return w.last
}
func (w *deviceWatcher) refresh(reason string) {
now := listDevices()
w.mu.Lock()
changed := !sameDevices(w.last, now)
w.last = now
w.mu.Unlock()
if changed {
w.logs.add("microphones (%s): %d found, default=%s %s", reason, len(now.Devices), now.Default, now.Error)
w.hub.send(event{"type": "devices", "devices": now})
}
}
func (w *deviceWatcher) run() {
w.refresh("startup")
// Poll fallback (Jitsi polls enumerateDevices when devicechange is unavailable).
go func() {
for range time.Tick(5 * time.Second) {
w.refresh("poll")
}
}()
go func() {
for {
cmd := exec.Command("pactl", "subscribe")
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
out, err := cmd.StdoutPipe()
if err == nil && cmd.Start() == nil {
sc := bufio.NewScanner(out)
var debounce *time.Timer
for sc.Scan() {
l := sc.Text()
if strings.Contains(l, " card ") || (strings.Contains(l, " source ") && !strings.Contains(l, "source-output")) {
w.logs.add("sound-server event: %s", l)
}
if strings.Contains(l, " source ") || strings.Contains(l, " card ") || strings.Contains(l, " server ") {
if debounce != nil {
debounce.Stop()
}
debounce = time.AfterFunc(300*time.Millisecond, func() { w.refresh("hot-plug") })
}
}
_ = cmd.Wait()
}
time.Sleep(3 * time.Second) // sound server restarted or unreachable: retry
}
}()
}
// micTest records ~3 s from one device WITHOUT transcribing and says, in plain words, whether the mic
// works — Rudder's version of Jitsi's pre-join audio preview. Level events stream to the plugin while
// it runs so the meter moves.
func (e *engine) micTest(device string) event {
cmds := recorderCmds(device)
var cmd *exec.Cmd
for _, c := range cmds {
if _, err := exec.LookPath(c[0]); err == nil {
cmd = exec.Command(c[0], c[1:]...)
break
}
}
if cmd == nil {
return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."}
}
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
var errBuf strings.Builder
cmd.Stderr = &errBuf
out, err := cmd.StdoutPipe()
if err != nil || cmd.Start() != nil {
return event{"ok": false, "verdict": "Could not start the recorder."}
}
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
frame := make([]byte, 3200) // 100 ms
peak, frames, zeroFrames := 0, 0, 0
var sumSq float64
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
n, err := io.ReadFull(out, frame)
if err != nil {
why := strings.TrimSpace(errBuf.String())
e.logs.add("mic test on %q failed after %d frames: %v | %s", device, frames, err, why)
return event{"ok": false, "verdict": "The microphone produced no audio. " + why, "recorder": cmd.Path}
}
fp := 0
for i := 0; i+1 < n; i += 2 {
v := int(int16(uint16(frame[i]) | uint16(frame[i+1])<<8))
sumSq += float64(v * v)
if v < 0 {
v = -v
}
if v > fp {
fp = v
}
}
if fp == 0 {
zeroFrames++
}
if fp > peak {
peak = fp
}
frames++
e.hub.send(event{"type": "level", "peak": float64(fp) / 32768, "test": true})
}
rms := 0.0
if frames > 0 {
rms = math.Sqrt(sumSq/float64(frames*1600)) / 32768
}
res := event{"ok": true, "peak": float64(peak) / 32768, "rms": rms, "frames": frames}
switch {
case zeroFrames == frames:
res["ok"] = false
res["verdict"] = "Pure silence — the mic is connected but sends nothing. It may be muted, or a Bluetooth headset's mic link may have dropped (try reconnecting the headset or pick another mic)."
case peak < 330: // below about -40 dBFS
res["verdict"] = "Very quiet — Rudder can hear the mic, but barely. Speak closer or raise its input volume."
default:
res["verdict"] = "Working — Rudder can hear you."
}
e.logs.add("mic test on %q: peak=%d rms=%.4f frames=%d verdict=%s", device, peak, rms, frames, res["verdict"])
return res
}
// ─── Optional Bluetooth headset reset ─────────────────────────────────────────────────────────────
//
// Some Bluetooth headsets drop the mic side of the HFP link (exact-zero audio while everything looks
// connected; the kernel logs `hci0: corrupted SCO packet`). Re-opening the recorder does not revive
// it; a full reset (disconnect → restart PipeWire/WirePlumber → reconnect) can. That is machine-specific,
// so Rudder only runs one if RUDDER_BT_RESET names an executable that does it (exit 0 = headset works
// again). Rate-limited: never more than once per 20 s.
func btResetPath() string {
p := os.Getenv("RUDDER_BT_RESET")
if p == "" {
return ""
}
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Mode()&0o111 != 0 {
return p
}
return ""
}
var (
resetMu sync.Mutex
lastReset time.Time
)
// runHeadsetReset runs the RUDDER_BT_RESET command and reports whether it said the headset works again.
func runHeadsetReset(logs *ring) (ok bool, ran bool) {
path := btResetPath()
if path == "" {
return false, false
}
resetMu.Lock()
if time.Since(lastReset) < 20*time.Second {
resetMu.Unlock()
logs.add("headset reset skipped: last one was %.0fs ago (rate limit 20s)", time.Since(lastReset).Seconds())
return false, false
}
lastReset = time.Now()
resetMu.Unlock()
logs.add("running %s (headset reset + CVSD)", path)
cmd := exec.Command(path)
cmd.Env = audioEnv()
done := make(chan struct{})
var out []byte
var err error
go func() { out, err = cmd.CombinedOutput(); close(done) }()
select {
case <-done:
case <-time.After(60 * time.Second):
_ = cmd.Process.Kill()
logs.add("headset reset timed out after 60s")
return false, true
}
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if l != "" {
logs.add("[headset reset] %s", l)
}
}
if err != nil {
logs.add("headset reset failed: %v", err)
return false, true
}
return true, true
}
// ─── Bluetooth voice-profile helpers ────────────────────────────────────────────────────────────
// btCard maps a Bluetooth mic (bluez_input.<MAC>.<n>, or "" = the default input) to its card.
func btCard(device string) string {
if device == "" {
if b, err := runPactl("get-default-source"); err == nil {
device = strings.TrimSpace(string(b))
}
}
if !strings.HasPrefix(device, "bluez_input.") {
return ""
}
mac := strings.TrimPrefix(device, "bluez_input.")
if i := strings.LastIndex(mac, "."); i > 0 {
mac = mac[:i]
}
return "bluez_card." + mac
}
func currentProfile(card string) string {
if card == "" {
return "(not bluetooth)"
}
b, err := runPactl("-f", "json", "list", "cards")
if err != nil {
return "?"
}
var cards []struct {
Name string `json:"name"`
ActiveProfile string `json:"active_profile"`
}
if json.Unmarshal(b, &cards) != nil {
return "?"
}
for _, c := range cards {
if c.Name == card {
return c.ActiveProfile
}
}
return "?"
}
// switchProfile forces a fresh voice link: CVSD if the card is on anything else, otherwise a bounce
// through mSBC and back to CVSD (a same-profile set is a no-op and would not renegotiate the link).
func switchProfile(card string, logs *ring) error {
before := currentProfile(card)
target := "headset-head-unit-cvsd"
if before == target {
if _, err := runPactl("set-card-profile", card, "headset-head-unit-msbc"); err != nil {
logs.add("profile bounce via msbc failed: %v", err)
}
time.Sleep(700 * time.Millisecond)
}
if out, err := runPactl("set-card-profile", card, target); err != nil {
return fmt.Errorf("pactl set-card-profile %s %s: %v %s", card, target, err, strings.TrimSpace(string(out)))
}
time.Sleep(1500 * time.Millisecond) // let the new SCO link and the source come up
logs.add("voice profile %s → %s", before, currentProfile(card))
return nil
}
// watchKernelBluetooth copies kernel Bluetooth messages into the log while a session runs
// (`journalctl -k` is readable without root on this system).
func watchKernelBluetooth(ctx context.Context, logs *ring) {
cmd := exec.CommandContext(ctx, "journalctl", "-k", "-f", "-n", "0", "-o", "cat")
out, err := cmd.StdoutPipe()
if err != nil || cmd.Start() != nil {
return
}
sc := bufio.NewScanner(out)
for sc.Scan() {
if l := sc.Text(); strings.Contains(strings.ToLower(l), "bluetooth") {
logs.add("kernel: %s", l)
}
}
_ = cmd.Wait()
}

View file

@ -0,0 +1,5 @@
module dx-maps/backend/rudder-engine
go 1.22.2
require github.com/gorilla/websocket v1.5.3

View file

@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

View file

@ -0,0 +1,797 @@
// rudder-engine — the local speech backend for the rudder-voice plugin.
//
// WHY THIS EXISTS: rudder-voice is a DESKTOP-target plugin. Speech-to-text runs on the user's own
// machine, never in the browser and never on a server. In dev, `bun run dev` starts this process and
// Vite forwards /api/rudder/* to it; in the desktop app, Tauri runs it as a sidecar. Same binary,
// same API, so "works in the Plugin Studio" means "works on desktop".
//
// browser / webview ──HTTP+SSE──► rudder-engine (127.0.0.1:RUDDER_PORT)
// ├─ recorder: parec → pw-record → arecord (mic open ONLY while recording)
// └─ WebSocket ──► nemo-speech serve (127.0.0.1:RUDDER_NEMO_PORT)
// NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF
//
// NO TELEMETRY, NO NETWORK: the runtime is NVIDIA NeMo-Speech.cpp (ggml). ONNX-based runtimes were
// REJECTED: measured 2026-09-26, the tested ONNX GenAI runtime looked up a vendor telemetry host even
// with telemetry "disabled". NeMo-Speech.cpp was traced with strace: zero sockets, zero DNS.
// Its only network path is an optional model download that shells out to `curl` when the model is not
// a local file — so this engine REFUSES to start unless RUDDER_MODEL is an existing local file.
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/gorilla/websocket"
)
// ─── Config (env, with local defaults) ──────────────────────────────────────────────────────────
type config struct {
Port string // this engine's HTTP port
NemoPort string // the warm NeMo-Speech server's port (loopback only)
NemoBin string // path to the nemo-speech executable
Model string // local GGUF path — MUST exist (see the no-network note above)
Language string // default language code
EndpointMs int // silence that ends an utterance
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func loadConfig() config {
home, _ := os.UserHomeDir()
base := filepath.Join(home, ".local", "share", "rudder")
return config{
Port: envOr("RUDDER_PORT", "5193"),
NemoPort: envOr("RUDDER_NEMO_PORT", "5194"),
NemoBin: envOr("RUDDER_NEMO_BIN", filepath.Join(base, "bin", "nemo-speech-0.1.0-linux-x86_64-cpu", "bin", "nemo-speech")),
Model: envOr("RUDDER_MODEL", filepath.Join(base, "models", "nemotron-3.5-asr-streaming-0.6b.q8_0.gguf")),
Language: envOr("RUDDER_LANG", "en-US"),
EndpointMs: 800,
}
}
// ─── Log ring: every line is also served at /api/rudder/log so the Debug panel can copy it ──────
type ring struct {
mu sync.Mutex
lines []string
}
// ★ PRIVACY: the log is what people paste to an agent (🐞 Debug → copy report), so it must never carry
// anything that identifies them. Every line is redacted as it is written: device MAC addresses (inside
// Bluetooth device names like bluez_input.AA_BB_…) and the home folder (which contains the username).
// Transcribed words are never logged at all.
var (
macRe = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:_]){5}[0-9a-f]{2}\b`)
homeDir = func() string { h, _ := os.UserHomeDir(); return h }()
)
func redact(s string) string {
s = macRe.ReplaceAllString(s, "XX:XX:XX:XX:XX:XX")
if homeDir != "" && homeDir != "/" {
s = strings.ReplaceAll(s, homeDir, "~")
}
return s
}
func (r *ring) add(format string, a ...any) {
line := redact(time.Now().Format("15:04:05.000") + " " + fmt.Sprintf(format, a...))
log.Println(line)
r.mu.Lock()
r.lines = append(r.lines, line)
if len(r.lines) > 3000 {
r.lines = r.lines[len(r.lines)-3000:]
}
r.mu.Unlock()
}
func (r *ring) text() string {
r.mu.Lock()
defer r.mu.Unlock()
return strings.Join(r.lines, "\n")
}
// ─── Event hub: Server-Sent Events to every open plugin ─────────────────────────────────────────
type event map[string]any
type hub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
}
func (h *hub) subscribe() chan []byte {
c := make(chan []byte, 64)
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
return c
}
func (h *hub) unsubscribe(c chan []byte) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
func (h *hub) send(e event) {
b, _ := json.Marshal(e)
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
select {
case c <- b:
default: // a slow client drops an event rather than stalling the audio path
}
}
}
// ─── Engine: supervises nemo-speech serve and one recording session at a time ───────────────────
type engine struct {
cfg config
logs *ring
hub *hub
mu sync.Mutex
state string // starting | ready | error
stateErr string
nemo *exec.Cmd
recording bool
recorder string
language string
stopSession func()
device string
devs *deviceWatcher
nemoKey string
}
func (e *engine) setState(s, errText string) {
e.mu.Lock()
e.state, e.stateErr = s, errText
e.mu.Unlock()
e.hub.send(event{"type": "status", "engine": s, "error": errText})
}
func (e *engine) status() event {
e.mu.Lock()
defer e.mu.Unlock()
return event{
"engine": e.state, "error": e.stateErr, "recording": e.recording, "recorder": e.recorder,
"language": e.language, "defaultLanguage": e.cfg.Language, "device": e.device,
"runtime": "NVIDIA NeMo-Speech.cpp (ggml, CPU)", "model": filepath.Base(e.cfg.Model),
}
}
// startNemo launches the warm speech server. The model loads and warms up once (~20 s on CPU),
// so starting and stopping a recording is instant afterwards.
func (e *engine) startNemo() {
if st, err := os.Stat(e.cfg.Model); err != nil || st.IsDir() {
e.setState("error", "model file not found: "+e.cfg.Model+" (refusing to start: a non-local model would make the runtime download it)")
return
}
if _, err := os.Stat(e.cfg.NemoBin); err != nil {
e.setState("error", "nemo-speech runtime not found: "+e.cfg.NemoBin)
return
}
e.setState("starting", "")
// A fresh random key each start: the speech server answers only this engine, not any other local
// program or web page that finds its port.
cmd := exec.Command(e.cfg.NemoBin, "serve",
"--host", "127.0.0.1", "--port", e.cfg.NemoPort,
"--asr-model", e.cfg.Model, "--device", "cpu", "--no-ui", "--api-key", e.nemoKey)
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} // dies with us
out, _ := cmd.StdoutPipe()
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
e.setState("error", "could not start nemo-speech: "+err.Error())
return
}
e.mu.Lock()
e.nemo = cmd
e.mu.Unlock()
e.logs.add("nemo-speech serve started (pid %d) on 127.0.0.1:%s", cmd.Process.Pid, e.cfg.NemoPort)
go func() {
buf := make([]byte, 4096)
for {
n, err := out.Read(buf)
for _, l := range strings.Split(strings.TrimSpace(string(buf[:n])), "\n") {
if l != "" {
e.logs.add("[nemo] %s", l)
}
}
if err != nil {
return
}
}
}()
go func() {
err := cmd.Wait()
e.setState("error", fmt.Sprintf("nemo-speech exited: %v", err))
}()
// Ready = the model list answers.
go func() {
url := "http://127.0.0.1:" + e.cfg.NemoPort + "/v1/models"
for i := 0; i < 600; i++ {
time.Sleep(500 * time.Millisecond)
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+e.nemoKey)
resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
e.logs.add("nemo-speech ready after %.1fs", float64(i+1)*0.5)
e.setState("ready", "")
return
}
}
e.mu.Lock()
s := e.state
e.mu.Unlock()
if s == "error" {
return
}
}
e.setState("error", "nemo-speech did not become ready within 5 minutes")
}()
}
// recorderCmds: raw s16le 16 kHz mono PCM on stdout. parec first — on PipeWire/Bluetooth desktops it
// talks to the sound server directly (the old Rudder's hardest-won lesson: PortAudio/ALSA-only
// capture stalls on Bluetooth headsets).
// RUDDER_RECORDER overrides the list with one space-separated command (e.g. a test that replays a
// PCM file), so the engine can be tested end to end without a microphone.
// device is a sound-server source name from /api/rudder/devices; empty = the system default input.
func recorderCmds(device string) [][]string {
if v := os.Getenv("RUDDER_RECORDER"); v != "" {
return [][]string{strings.Fields(v)}
}
parec := []string{"parec", "--format=s16le", "--rate=16000", "--channels=1", "--raw", "--latency-msec=100"}
pw := []string{"pw-record", "--format=s16", "--rate=16000", "--channels=1"}
if device != "" {
parec = append(parec, "--device="+device)
pw = append(pw, "--target="+device)
}
pw = append(pw, "-")
cmds := [][]string{parec, pw}
if device == "" { // arecord can't address a sound-server source by name
cmds = append(cmds, []string{"arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"})
}
return cmds
}
func (e *engine) start(lang, device string) error {
e.mu.Lock()
if e.state != "ready" {
e.mu.Unlock()
return fmt.Errorf("engine is %s — wait for it to be ready", e.state)
}
if e.recording {
e.mu.Unlock()
return nil
}
if lang == "" {
lang = e.cfg.Language
}
e.mu.Unlock()
ws, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:"+e.cfg.NemoPort+"/v1/audio/transcriptions/realtime",
http.Header{"Authorization": {"Bearer " + e.nemoKey}})
if err != nil {
return fmt.Errorf("connect to speech server: %w", err)
}
upd, _ := json.Marshal(event{"type": "session.update", "session": event{
"sample_rate": 16000, "language": lang, "automatic_punctuation": true, "endpointing_ms": e.cfg.EndpointMs,
}})
if err := ws.WriteMessage(websocket.TextMessage, upd); err != nil {
ws.Close()
return fmt.Errorf("session.update: %w", err)
}
// Bluetooth headsets start on CVSD, not mSBC. Measured 2026-09-27 on a Bluetooth headset: on mSBC the
// mic link died 3-5 s into EVERY session that had speech (kernel: corrupted SCO packets); after
// switching to CVSD it ran 9.5 minutes without a drop. CVSD is 8 kHz, and Nemotron's transcript of an
// 8 kHz copy of the test clip differed by one word. RUDDER_KEEP_MSBC=1 opts out.
if card := btCard(device); card != "" && os.Getenv("RUDDER_KEEP_MSBC") == "" {
if p := currentProfile(card); p == "headset-head-unit-msbc" {
e.logs.add("bluetooth headset on mSBC — switching to CVSD before recording (stable voice link)")
e.hub.send(event{"type": "recovering", "message": "Switching your Bluetooth headset to its stable voice mode…"})
if err := switchProfile(card, e.logs); err != nil {
e.logs.add("pre-switch to CVSD failed: %v", err)
} else {
e.hub.send(event{"type": "recovered", "message": "Headset is in its stable voice mode — go ahead and talk."})
}
}
}
first, err := spawnRecorder(device, e.logs)
if err != nil {
ws.Close()
return err
}
recName := first.name
var recMu sync.Mutex
cur := first // the live recorder; replaced when a dead mic link is recovered
ctx, cancel := context.WithCancel(context.Background())
var writeMu sync.Mutex
done := make(chan struct{})
e.mu.Lock()
e.recording, e.recorder, e.language, e.device = true, recName, lang, device
e.stopSession = func() {
cancel()
recMu.Lock()
cur.kill()
recMu.Unlock()
writeMu.Lock()
_ = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"input_audio_buffer.commit"}`))
writeMu.Unlock()
select { // let the final words arrive
case <-done:
case <-time.After(4 * time.Second):
}
ws.Close()
}
e.mu.Unlock()
e.logs.add("recording started: recorder=%s device=%q language=%s profile=%s", recName, device, lang, currentProfile(btCard(device)))
go watchKernelBluetooth(ctx, e.logs)
e.hub.send(event{"type": "recording", "recording": true, "recorder": recName, "language": lang, "device": device})
for _, d := range e.devs.current().Devices {
if (d.ID == device || (device == "" && d.Default)) && d.Muted {
e.hub.send(event{"type": "warning", "message": "Your microphone \"" + d.Label + "\" is muted in the system sound settings — unmute it or nothing will be heard."})
}
}
// Audio pump: 100 ms frames → speech server, plus a level meter and dead-mic RECOVERY.
go func() {
frame := make([]byte, 3200)
var silentSince time.Time
recoveries := 0
var recovering atomic.Bool
var resetting atomic.Bool
tracePeak, lastTrace := 0, time.Now()
var healthySince time.Time // a mic that stays healthy 30 s earns back its 3 reconnects
gaveUp := false
lastLevel := time.Time{}
for {
recMu.Lock()
r := cur
recMu.Unlock()
if _, err := io.ReadFull(r.out, frame); err != nil {
if ctx.Err() != nil {
return
}
recMu.Lock()
swapped := cur != r
recMu.Unlock()
if swapped { // we killed it ourselves during a recovery: read from the new one
continue
}
// A headset reset restarts PipeWire, which kills parec mid-read. That is expected:
// wait for the reset to hand over a fresh recorder instead of ending the session.
if resetting.Load() {
for resetting.Load() && ctx.Err() == nil {
time.Sleep(200 * time.Millisecond)
}
continue
}
// The recorder died on its own (bad device, sound server unreachable, device unplugged).
// End the session so the UI stops showing "Listening", and say WHY in plain words.
_ = r.cmd.Wait()
why := strings.TrimSpace(r.errBuf.String())
e.logs.add("recorder %s ended unexpectedly: %v | stderr: %s", r.name, err, why)
msg := "The microphone stopped delivering audio."
switch {
case strings.Contains(why, "Connection refused") || strings.Contains(why, "pa_context_connect"):
msg = "Can't reach the computer's sound system (PipeWire/PulseAudio). Is it running?"
case strings.Contains(why, "No such entity") || strings.Contains(why, "not found"):
msg = "That microphone isn't available any more — it may have been unplugged or disconnected. Pick another one."
}
if why != "" {
msg += " (" + r.name + ": " + why + ")"
}
e.hub.send(event{"type": "error", "message": msg})
go e.stop()
return
}
peak := 0
for i := 0; i+1 < len(frame); i += 2 {
v := int(int16(binary.LittleEndian.Uint16(frame[i:])))
if v < 0 {
v = -v
}
if v > peak {
peak = v
}
}
// ★ DEAD MIC LINK. Exact-zero samples are not quiet speech (a live mic always has a noise
// floor). Measured 2026-09-27 on a Bluetooth headset (HFP, mSBC): four words
// transcribed, then literal zeros while parec stayed "healthy" — the old Rudder's BUG 1,
// the headset losing the mic side of the link. Re-opening the source restarts the audio
// stream; the speech session and the transcript carry on untouched.
if peak == 0 {
if silentSince.IsZero() {
silentSince = time.Now()
} else if !gaveUp && !resetting.Load() && time.Since(silentSince) > 1500*time.Millisecond {
recoveries++
switch {
case recoveries == 1:
// Step 1: reopen the recorder (cheap; fixes a stalled stream).
e.logs.add("mic link dead (exact zeros 1.5s) — step 1: reopening the microphone")
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone went silent — reconnecting it…"})
if next, err := spawnRecorder(device, e.logs); err == nil {
recMu.Lock()
old := cur
cur = next
recMu.Unlock()
old.kill()
recovering.Store(true)
}
case recoveries == 2 && btCard(device) != "":
// Step 2: force a NEW Bluetooth voice link by switching the headset's voice profile to
// CVSD (the old Rudder's proven-stabler choice). Unlike a detect-first reset script this does not
// "detect first", so it can't decide a dead link is fine.
card := btCard(device)
e.logs.add("mic still dead — step 2: forcing a new voice link: %s → headset-head-unit-cvsd", card)
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Microphone still silent — switching your Bluetooth headset to its more stable voice mode…"})
resetting.Store(true)
go func() {
defer resetting.Store(false)
if err := switchProfile(card, e.logs); err != nil {
e.hub.send(event{"type": "warning", "message": "Couldn't switch the headset's voice mode: " + err.Error()})
return
}
if next, err := spawnRecorder(device, e.logs); err == nil {
recMu.Lock()
old := cur
cur = next
recMu.Unlock()
old.kill()
recovering.Store(true)
}
}()
case recoveries == 3 && btResetPath() != "":
// Step 3: full headset reset (disconnect → restart PipeWire → reconnect).
e.logs.add("mic still dead — step 3: full headset reset via RUDDER_BT_RESET")
e.hub.send(event{"type": "recovering", "attempt": recoveries, "message": "Still silent — fully resetting your Bluetooth headset (about 15 seconds)…"})
resetting.Store(true)
go func() {
defer resetting.Store(false)
ok, ran := runHeadsetReset(e.logs)
if !ran {
return
}
if !ok {
e.hub.send(event{"type": "warning", "message": "The headset reset didn't bring the mic back — is the headset on and in range?"})
}
if next, err := spawnRecorder(device, e.logs); err == nil {
recMu.Lock()
old := cur
cur = next
recMu.Unlock()
old.kill()
recovering.Store(true)
}
}()
default:
gaveUp = true
e.logs.add("mic still dead after reopen + headset resets — giving up for this session")
msg := "Your microphone keeps going silent. This is the Bluetooth headset dropping its mic link (the kernel reports corrupted voice packets), not Rudder. Try a wired or built-in mic, or turn off the headset's noise cancellation / adaptive audio in its app."
if btResetPath() == "" {
msg += " (No headset-reset command is configured — see RUDDER_BT_RESET.)"
}
e.hub.send(event{"type": "warning", "message": msg})
}
silentSince = time.Now()
}
} else {
if recovering.Load() {
recovering.Store(false)
healthySince = time.Now()
e.logs.add("mic recovered after %d reconnect(s)", recoveries)
e.hub.send(event{"type": "recovered", "message": "Microphone is back — keep talking."})
}
if recoveries > 0 && !healthySince.IsZero() && time.Since(healthySince) > 30*time.Second {
recoveries, gaveUp, healthySince = 0, false, time.Time{}
}
silentSince = time.Time{}
}
// Flight recorder: the mic level twice a second, so a drop can be lined up against kernel and
// sound-server events in the same log (/api/rudder/log).
tracePeak = max(tracePeak, peak)
if time.Since(lastTrace) >= 500*time.Millisecond {
e.logs.add("trace level peak=%d", tracePeak)
tracePeak, lastTrace = 0, time.Now()
}
if time.Since(lastLevel) > 150*time.Millisecond {
lastLevel = time.Now()
e.hub.send(event{"type": "level", "peak": float64(peak) / 32768})
}
writeMu.Lock()
err := ws.WriteMessage(websocket.BinaryMessage, frame)
writeMu.Unlock()
if err != nil {
if ctx.Err() == nil {
e.hub.send(event{"type": "error", "message": "speech server connection lost: " + err.Error()})
}
return
}
}
}()
// Result reader: map the server's events onto the plugin's small vocabulary.
go func() {
defer close(done)
for {
_, msg, err := ws.ReadMessage()
if err != nil {
return
}
var m map[string]any
if json.Unmarshal(msg, &m) != nil {
continue
}
t, _ := m["type"].(string)
text := firstString(m, "transcript", "delta", "text")
switch {
case strings.HasSuffix(t, "input_audio_transcription.delta"):
// Deltas APPEND to the utterance in progress; the server sends many empty ones.
if text != "" {
e.hub.send(event{"type": "partial", "text": text})
}
case strings.HasSuffix(t, "input_audio_transcription.completed"):
e.hub.send(event{"type": "final", "text": text, "at": time.Now().Format(time.RFC3339)})
case t == "error":
e.logs.add("speech server error: %s", string(msg))
e.hub.send(event{"type": "error", "message": string(msg)})
case t == "input_audio_buffer.committed":
return
default:
e.logs.add("speech event: %s", t)
}
}
}()
return nil
}
// recorderProc is one running recorder process.
type recorderProc struct {
cmd *exec.Cmd
out io.ReadCloser
name string
errBuf *bytes.Buffer
}
func (r *recorderProc) kill() {
if r != nil && r.cmd != nil && r.cmd.Process != nil {
_ = r.cmd.Process.Kill()
go func() { _ = r.cmd.Wait() }()
}
}
// spawnRecorder starts the first recorder that works for this device.
func spawnRecorder(device string, logs *ring) (*recorderProc, error) {
for _, c := range recorderCmds(device) {
if _, err := exec.LookPath(c[0]); err != nil {
continue
}
cmd := exec.Command(c[0], c[1:]...)
cmd.Env = audioEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
errBuf := &bytes.Buffer{}
cmd.Stderr = errBuf
out, err := cmd.StdoutPipe()
if err != nil {
continue
}
if err := cmd.Start(); err != nil {
logs.add("recorder %s failed to start: %v", c[0], err)
continue
}
return &recorderProc{cmd: cmd, out: out, name: c[0], errBuf: errBuf}, nil
}
return nil, errors.New("no microphone recorder found (need parec, pw-record or arecord)")
}
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok {
return s
}
}
if item, ok := m["item"].(map[string]any); ok {
return firstString(item, keys...)
}
return ""
}
func (e *engine) stop() {
e.mu.Lock()
stop := e.stopSession
e.stopSession = nil
was := e.recording
e.recording = false
e.mu.Unlock()
if !was || stop == nil {
return
}
stop()
e.logs.add("recording stopped")
e.hub.send(event{"type": "recording", "recording": false})
}
func (e *engine) shutdown() {
e.stop()
e.mu.Lock()
n := e.nemo
e.mu.Unlock()
if n != nil && n.Process != nil {
_ = n.Process.Kill()
}
}
// ─── HTTP API ────────────────────────────────────────────────────────────────────────────────────
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// guard: two protections for a server that streams what you say.
// - Host check (DNS rebinding): a web page on any domain can make the browser resolve its own hostname
// to 127.0.0.1 and then read this API as "same-origin". Only requests addressed to 127.0.0.1 or
// localhost are served.
// - CORS: only the desktop webview's origin (tauri://localhost, or http://tauri.localhost on Windows)
// may read responses cross-origin. The Plugin Studio needs none: it reaches /api/rudder through the
// dev server's same-origin proxy. RUDDER_ALLOW_ORIGIN adds one more exact origin (tests).
func guard(next http.Handler) http.Handler {
extra := os.Getenv("RUDDER_ALLOW_ORIGIN")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if i := strings.LastIndex(host, ":"); i >= 0 && !strings.HasSuffix(host, "]") {
host = host[:i]
}
if host != "127.0.0.1" && host != "localhost" && host != "[::1]" {
http.Error(w, "forbidden host", http.StatusForbidden)
return
}
o := r.Header.Get("Origin")
if o == "tauri://localhost" || o == "http://tauri.localhost" || (extra != "" && o == extra) {
w.Header().Set("Access-Control-Allow-Origin", o)
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
cfg := loadConfig()
logs := &ring{}
h := &hub{clients: map[chan []byte]struct{}{}}
key := make([]byte, 24)
if _, err := rand.Read(key); err != nil {
log.Fatal("no randomness for the speech-server key: ", err)
}
e := &engine{cfg: cfg, logs: logs, hub: h, state: "starting", devs: &deviceWatcher{hub: h, logs: logs}, nemoKey: hex.EncodeToString(key)}
e.devs.run()
mux := http.NewServeMux()
mux.HandleFunc("/api/rudder/status", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, e.status())
})
mux.HandleFunc("/api/rudder/start", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, event{"error": "POST only"})
return
}
var body struct {
Language string `json:"language"`
Device string `json:"device"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if err := e.start(body.Language, body.Device); err != nil {
e.logs.add("start failed: %v", err)
writeJSON(w, 409, event{"error": err.Error()})
return
}
writeJSON(w, 200, e.status())
})
mux.HandleFunc("/api/rudder/stop", func(w http.ResponseWriter, r *http.Request) {
e.stop()
writeJSON(w, 200, e.status())
})
mux.HandleFunc("/api/rudder/devices", func(w http.ResponseWriter, r *http.Request) {
e.devs.refresh("request")
writeJSON(w, 200, e.devs.current())
})
mux.HandleFunc("/api/rudder/test", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Device string `json:"device"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, 200, e.micTest(body.Device))
})
mux.HandleFunc("/api/rudder/log", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, logs.text())
})
mux.HandleFunc("/api/rudder/events", func(w http.ResponseWriter, r *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", 500)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
c := e.hub.subscribe()
defer e.hub.unsubscribe(c)
first, _ := json.Marshal(event{"type": "status", "status": e.status()})
fmt.Fprintf(w, "data: %s\n\n", first)
devs, _ := json.Marshal(event{"type": "devices", "devices": e.devs.current()})
fmt.Fprintf(w, "data: %s\n\n", devs)
fl.Flush()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case b := <-c:
fmt.Fprintf(w, "data: %s\n\n", b)
fl.Flush()
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
}
}
})
srv := &http.Server{Addr: "127.0.0.1:" + cfg.Port, Handler: guard(mux)}
logs.add("rudder-engine listening on http://127.0.0.1:%s (runtime=%s model=%s)", cfg.Port, cfg.NemoBin, cfg.Model)
e.startNemo()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sig
e.shutdown()
os.Exit(0)
}()
if err := srv.ListenAndServe(); err != nil {
e.shutdown()
log.Fatal(err)
}
}

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# setup-runtime.sh — fetch Rudder's speech runtime + model, verified by SHA-256, then build the engine.
#
# Rudder (plugin-designs/rudder-voice) transcribes on THIS computer. It needs two things that are too big
# for git: NVIDIA's NeMo-Speech.cpp runtime (~4 MB, CPU build) and the Nemotron 3.5 ASR Streaming GGUF
# (~742 MB). Both are pinned by version and checksum; a mismatch aborts instead of running an unverified
# file. After this, `bun run dev` starts the engine and Rudder appears in the Plugin Studio.
#
# bash backend/rudder-engine/setup-runtime.sh # installs into ~/.local/share/rudder
# RUDDER_HOME=/some/dir bash backend/rudder-engine/setup-runtime.sh
#
# Linux x86_64 only for now (the runtime ships other platforms; add them when a desktop build needs them).
set -euo pipefail
RUDDER_HOME="${RUDDER_HOME:-$HOME/.local/share/rudder}"
RT_VER="0.1.0"
RT_NAME="nemo-speech-${RT_VER}-linux-x86_64-cpu"
RT_URL="https://github.com/NVIDIA/NeMo-Speech.cpp/releases/download/v${RT_VER}/${RT_NAME}.tar.gz"
RT_SHA="0f74131d631ad2c694cf0ec53490866bb6461147959589a69fb6fc231944065b"
# NVIDIA re-converted the GGUF on 2026-09-10 (embedded tokenizer); this revision is the one Rudder is tested with.
MODEL_REV="ea30d66debe3"
MODEL_FILE="nemotron-3.5-asr-streaming-0.6b.q8_0.gguf"
MODEL_URL="https://huggingface.co/nvidia/nemotron-3.5-asr-streaming-0.6b/resolve/${MODEL_REV}/${MODEL_FILE}"
MODEL_SHA="3fc991d3badad7277c11030a7519832cddaf2057aafed6d4b25147e953a070b1"
say() { printf '\033[1;34m→\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(uname -s)-$(uname -m)" = "Linux-x86_64" ] || die "This script currently supports Linux x86_64 only."
command -v curl >/dev/null || die "curl is required."
command -v sha256sum >/dev/null || die "sha256sum is required."
fetch() { # url dest sha
local url="$1" dest="$2" sha="$3"
if [ -f "$dest" ] && [ "$(sha256sum "$dest" | cut -d' ' -f1)" = "$sha" ]; then
say "already have $(basename "$dest") (checksum OK)"; return
fi
say "downloading $(basename "$dest")"
curl -fL --retry 3 -C - -o "$dest.part" "$url"
[ "$(sha256sum "$dest.part" | cut -d' ' -f1)" = "$sha" ] || { rm -f "$dest.part"; die "checksum mismatch for $(basename "$dest") — refusing to use it"; }
mv "$dest.part" "$dest"
}
mkdir -p "$RUDDER_HOME/bin" "$RUDDER_HOME/models"
fetch "$RT_URL" "$RUDDER_HOME/bin/$RT_NAME.tar.gz" "$RT_SHA"
tar -xzf "$RUDDER_HOME/bin/$RT_NAME.tar.gz" -C "$RUDDER_HOME/bin"
fetch "$MODEL_URL" "$RUDDER_HOME/models/$MODEL_FILE" "$MODEL_SHA"
if command -v go >/dev/null; then
say "building backend/bin/rudder-engine"
(cd "$(dirname "$0")" && go build -o ../bin/rudder-engine .)
else
printf '! Go not found — install Go, then: cd backend/rudder-engine && go build -o ../bin/rudder-engine .\n'
fi
for tool in parec pactl; do command -v "$tool" >/dev/null || printf '! %s not found — install pipewire-pulse or pulseaudio-utils for microphone capture.\n' "$tool"; done
say "done. Runtime: $RUDDER_HOME/bin/$RT_NAME Model: $RUDDER_HOME/models/$MODEL_FILE"
say "run \`bun run dev\` and open Rudder (rudder-voice) in the Plugin Studio."

View file

@ -0,0 +1,61 @@
/**
* internal/backend-dev-plugin.mjs — `bun run dev` starts the local plugin backends.
*
* WHY: some plugins ship to DESKTOP with a local backend (rudder-voice → backend/bin/rudder-engine,
* run as a Tauri sidecar). In the Plugin Studio the same backend must be running, or the plugin can't
* be tested in the browser. This plugin starts each built backend with the dev server, forwards its
* /api/<name>/ routes, and stops it when the dev server stops. A backend that isn't built is skipped
* with a hint: plugins without a backend never notice this file.
*
* DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
// name → how to run it. `route` is proxied to `port` on 127.0.0.1.
export const DEV_BACKENDS = [
{
name: 'rudder-engine',
bin: 'backend/bin/rudder-engine',
build: 'cd backend/rudder-engine && go build -o ../bin/rudder-engine .',
route: '/api/rudder',
port: Number(process.env.RUDDER_PORT || 5193),
env: { RUDDER_PORT: String(process.env.RUDDER_PORT || 5193), RUDDER_NEMO_PORT: String(process.env.RUDDER_NEMO_PORT || 5194) },
},
]
// The proxy table for vite's `server.proxy`. SSE (`/events`) streams through unbuffered.
export function devBackendProxy() {
return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }]))
}
export function backendDevPlugin(root) {
const children = []
return {
name: 'dx-backend-dev',
apply: 'serve',
configureServer(server) {
for (const b of DEV_BACKENDS) {
const bin = resolve(root, b.bin)
if (!existsSync(bin)) {
server.config.logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`)
continue
}
const child = spawn(bin, [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] })
const tag = `[${b.name}]`
const out = (d) => String(d).trim().split('\n').forEach((l) => l && server.config.logger.info(`${tag} ${l}`))
child.stdout.on('data', out)
child.stderr.on('data', out)
child.on('exit', (code) => server.config.logger.warn(`${tag} exited (${code})`))
children.push(child)
server.config.logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`)
}
const stop = () => children.forEach((c) => { try { c.kill('SIGTERM') } catch {} })
server.httpServer?.on('close', stop)
process.once('exit', stop)
process.once('SIGINT', () => { stop(); process.exit(0) })
process.once('SIGTERM', () => { stop(); process.exit(0) })
},
}
}

View file

@ -313,8 +313,11 @@ function profileReachesAMap(dir, file) {
function metaStageHeavy(dir, profileName) {
try {
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true }
} catch { return { stage: 'experimental', heavy: false } }
const corner = meta?.widget?.corner
return { stage: meta?.stage === 'production' ? 'production' : 'experimental', heavy: meta?.heavy === true,
// "widget": { "corner": "bottom-right" } — a corner widget the Plugin Studio mounts once, fixed, not in a pane
widget: ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(corner) ? { corner } : null }
} catch { return { stage: 'experimental', heavy: false, widget: null } }
}
function metaIsMap(dir, profileName) {
@ -1302,11 +1305,12 @@ function scanBundles() {
? composesAMapLeaf(dir, profileName, file)
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
const { stage: profileStage, heavy: profileHeavy } = metaStageHeavy(dir, profileName)
const { stage: profileStage, heavy: profileHeavy, widget: profileWidget } = metaStageHeavy(dir, profileName)
return {
profile: profileName,
stage: profileStage,
...(profileHeavy ? { heavy: true } : {}),
...(profileWidget ? { widget: profileWidget } : {}),
file,
description: description || fileTag || '',
element: tag,

View file

@ -95,8 +95,13 @@ const labelW = computed(() => {
return Math.max(96, Math.min(260, Math.round((width.value || 640) * 0.34)))
})
// Room on the right for the printed value when a bar runs long.
const VALUE_GUTTER = 74
const plotW = computed(() => Math.max(40, (width.value || 640) - labelW.value - VALUE_GUTTER))
// The value column fits the LONGEST value label (rega, row 82: a fixed 74px clipped "10,160,000" at the edge
// of a 335px card). ~7px per character at the value font, plus the 8px gap and a margin; never under 74.
const VALUE_GUTTER = computed(() => {
const longest = Math.max(0, ...rows.value.filter((r) => !r.unknown).map((r) => String(props.format(total(r))).length))
return Math.max(74, longest * 7 + 16)
})
const plotW = computed(() => Math.max(40, (width.value || 640) - labelW.value - VALUE_GUTTER.value))
const rowStep = computed(() => props.barHeight + props.gap)
const svgH = computed(() => Math.max(rowStep.value, rows.value.length * rowStep.value + 6))
@ -192,7 +197,10 @@ useShadowStyles(
<div v-if="!rows.length" class="bc-empty">{{ emptyText }}</div>
<svg v-else :width="width || 640" :height="svgH" role="img" :aria-label="ariaLabel">
<!-- direction:ltr on the SVG: its geometry is left-to-right by construction (labels at x=0, values after
the bar end). Under an inherited rtl, text-anchor start flips, so labels ran off the left edge and
values back over their bars (rega, row 82, ar). Each label's own text still shapes as Arabic. -->
<svg v-else :width="width || 640" :height="svgH" role="img" :aria-label="ariaLabel" direction="ltr" style="direction:ltr">
<defs>
<!-- Hatching means NOT MEASURED. It must not look like a small bar. -->
<pattern :id="`${uid}-hatch`" width="7" height="7" patternUnits="userSpaceOnUse"

View file

@ -469,7 +469,12 @@ export function useMapInstance({
accessToken = window.MAP_TOKEN
}
if (!accessToken) {
console.error('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
// ONE warn per page, not an error per map: the UI already says "Map unavailable", and a page with
// several maps logged this 7x as console.error, which breaks the 0-errors bar (dx-shepherd, TKWARN).
if (typeof window !== 'undefined' && !window.__doxaNoTokenWarned) {
window.__doxaNoTokenWarned = true
console.warn('No Mapbox access token provided. Pass "tk" in profile-config JSON or set window.MAP_TOKEN.')
}
return
}
if (!accessToken.startsWith('pk.')) {

View file

@ -14,13 +14,25 @@
"accessByBand": "الوصول بلغتهم، حسب استخدام الإنترنت في البلد",
"aria": "رسوم بيانية",
"bandCaveat": "استخدام الإنترنت رقم البلد (البنك الدولي)، وليس رقم المجموعة أبدًا.",
"gapSub": "أكبر 8 من المعروضة حسب عدد السكان. انقر إحداها لفتحها.",
"gapTake": "هذه الـ{k} وحدها تضم {people} شخص.",
"gapTitle": "أكبر المجموعات العرقية التي لا شيء لها بلغتها",
"groups": "المجموعات",
"groupsN": "{n} مجموعة",
"measure": "العدّ",
"messagingTop": "تطبيق المراسلة الأول، حسب البلدان ضمن النطاق",
"notMeasured": "غير مقيس",
"ntSub": "لغات المجموعات المعروضة التي لم يُعثر لها على عهد جديد، حسب عدد متحدثيها. الأحمر: لا شيء؛ الأصفر: شيء ما لكن بلا عهد جديد.",
"ntTake": "{n} لغة هنا لم يُعثر لها على عهد جديد، يتحدثها {people} شخص.",
"ntTitle": "أي اللغات أحوج إلى العهد الجديد؟",
"people": "الأشخاص",
"peopleN": "{n} شخص",
"q1sub": "عمود لكل مستوى وصول، للمجموعات العرقية المعروضة.",
"q1take": "{n} من {total} مجموعة عرقية معروضة لا شيء لها بلغتها.",
"q1title": "ماذا لديهم بلغتهم؟",
"q2sub": "المجموعات العرقية التي لا شيء لها بلغتها، حسب نسبة الاتصال بالإنترنت في بلدها.",
"q2take": "{online} من الـ{n} التي لا شيء لها تعيش في بلدان يستخدم فيها 60% أو أكثر الإنترنت.",
"q2title": "هل يمكن للإنترنت إيصاله إلى من لا شيء لهم؟",
"socialTop": "منصة التواصل الأولى، حسب البلدان ضمن النطاق",
"unitCountries": "بلدان",
"unitGroups": "مجموعة عرقية",
@ -119,6 +131,8 @@
"count": "{n} من {total} مجموعة عرقية",
"empty": "لا توجد مجموعة عرقية تطابق عوامل التصفية هذه.",
"exported": "تم تصدير {n} صف (نُسخت ونُزّلت).",
"openCapped": "يفتح أول {n} من المحدد.",
"openInGospel": "فتح في موارد الإنجيل",
"selectRow": "اختيار {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Zugang in eigener Sprache, nach Internetnutzung im Land",
"aria": "Diagramme",
"bandCaveat": "Die Internetnutzung ist die Zahl des Landes (Weltbank), nie die der Gruppe.",
"gapSub": "Die 8 größten der angezeigten, nach Bevölkerung. Klicken Sie eine an, um sie zu öffnen.",
"gapTake": "Allein diese {k} sind {people} Menschen.",
"gapTitle": "Die größten Volksgruppen ohne etwas in ihrer eigenen Sprache",
"groups": "Gruppen",
"groupsN": "{n} Gruppen",
"measure": "Zählen",
"messagingTop": "Top-Messenger nach Land im Auswahlbereich",
"notMeasured": "Nicht gemessen",
"ntSub": "Sprachen der angezeigten Gruppen ohne gefundenes Neues Testament, nach Zahl der Sprecher. Rot: gar nichts; Gelb: etwas, aber kein Neues Testament.",
"ntTake": "{n} Sprachen hier haben kein gefundenes Neues Testament, gesprochen von {people} Menschen.",
"ntTitle": "Welche Sprachen brauchen das Neue Testament am meisten?",
"people": "Menschen",
"peopleN": "{n} Menschen",
"q1sub": "Ein Balken je Zugangsstufe, für die angezeigten Volksgruppen.",
"q1take": "{n} von {total} angezeigten Volksgruppen haben nichts in ihrer eigenen Sprache.",
"q1title": "Was haben sie in ihrer eigenen Sprache?",
"q2sub": "Volksgruppen ohne etwas in ihrer Sprache, danach, wie viel ihres Landes online ist.",
"q2take": "{online} der {n} ohne etwas leben in Ländern, in denen 60 % oder mehr das Internet nutzen.",
"q2title": "Könnte das Internet es zu denen ohne etwas bringen?",
"socialTop": "Top-Social-Plattform nach Land im Auswahlbereich",
"unitCountries": "Länder",
"unitGroups": "Volksgruppen",
@ -119,6 +131,8 @@
"count": "{n} von {total} Volksgruppen",
"empty": "Keine Volksgruppe entspricht diesen Filtern.",
"exported": "{n} Zeilen exportiert (kopiert und heruntergeladen).",
"openCapped": "Öffnet die ersten {n} ausgewählten.",
"openInGospel": "In Evangeliumsressourcen öffnen",
"selectRow": "{name} auswählen"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Own-language access, by country internet use",
"aria": "Charts",
"bandCaveat": "Internet use is the country's figure (World Bank), never the group's.",
"gapSub": "The 8 biggest of those shown, by population. Click one to open it.",
"gapTake": "These {k} alone are {people} people.",
"gapTitle": "The largest people groups with nothing in their own language",
"groups": "Groups",
"groupsN": "{n} groups",
"measure": "Count",
"messagingTop": "Top messaging app, by country in scope",
"notMeasured": "Not measured",
"ntSub": "Languages of the groups shown with no New Testament found, by how many people speak them. Red: nothing at all; yellow: something, but no New Testament.",
"ntTake": "{n} languages here have no New Testament found, spoken by {people} people.",
"ntTitle": "Which languages most need the New Testament?",
"people": "People",
"peopleN": "{n} people",
"q1sub": "One bar per level of access, for the people groups shown.",
"q1take": "{n} of {total} people groups shown have nothing found in their own language.",
"q1title": "What do they have in their own language?",
"q2sub": "People groups with nothing in their own language, by how much of their country is online.",
"q2take": "{online} of the {n} with nothing live in countries where 60% or more use the internet.",
"q2title": "Could the internet carry it to those with nothing?",
"socialTop": "Top social platform, by country in scope",
"unitCountries": "countries",
"unitGroups": "people groups",
@ -119,6 +131,8 @@
"count": "{n} of {total} people groups",
"empty": "No people groups match these filters.",
"exported": "{n} rows exported (copied and downloaded).",
"openCapped": "Opens the first {n} selected.",
"openInGospel": "Open in Gospel Resources",
"selectRow": "Select {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Acceso en su idioma, según el uso de internet del país",
"aria": "Gráficos",
"bandCaveat": "El uso de internet es la cifra del país (Banco Mundial), nunca la del grupo.",
"gapSub": "Los 8 más grandes de los mostrados, por población. Haga clic en uno para abrirlo.",
"gapTake": "Solo estos {k} son {people} personas.",
"gapTitle": "Los grupos étnicos más grandes sin nada en su propio idioma",
"groups": "Grupos",
"groupsN": "{n} grupos",
"measure": "Contar",
"messagingTop": "App de mensajería principal, por país en la selección",
"notMeasured": "No medido",
"ntSub": "Idiomas de los grupos mostrados sin Nuevo Testamento encontrado, según cuántas personas los hablan. Rojo: nada; amarillo: algo, pero sin Nuevo Testamento.",
"ntTake": "{n} idiomas aquí no tienen Nuevo Testamento encontrado, hablados por {people} personas.",
"ntTitle": "¿Qué idiomas necesitan más el Nuevo Testamento?",
"people": "Personas",
"peopleN": "{n} personas",
"q1sub": "Una barra por nivel de acceso, para los grupos étnicos mostrados.",
"q1take": "{n} de {total} grupos étnicos mostrados no tienen nada en su propio idioma.",
"q1title": "¿Qué tienen en su propio idioma?",
"q2sub": "Grupos étnicos sin nada en su idioma, según cuánto de su país está en línea.",
"q2take": "{online} de los {n} sin nada viven en países donde el 60 % o más usa internet.",
"q2title": "¿Podría internet llevarlo a quienes no tienen nada?",
"socialTop": "Red social principal, por país en la selección",
"unitCountries": "países",
"unitGroups": "grupos étnicos",
@ -119,6 +131,8 @@
"count": "{n} de {total} grupos étnicos",
"empty": "Ningún grupo étnico coincide con estos filtros.",
"exported": "{n} filas exportadas (copiadas y descargadas).",
"openCapped": "Abre los primeros {n} seleccionados.",
"openInGospel": "Abrir en Recursos del Evangelio",
"selectRow": "Seleccionar {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Accès dans leur langue, selon l’usage d’internet du pays",
"aria": "Graphiques",
"bandCaveat": "L’usage d’internet est le chiffre du pays (Banque mondiale), jamais celui du groupe.",
"gapSub": "Les 8 plus grands de ceux affichés, par population. Cliquez pour en ouvrir un.",
"gapTake": "Ces {k} seuls représentent {people} personnes.",
"gapTitle": "Les plus grands groupes ethniques sans rien dans leur propre langue",
"groups": "Groupes",
"groupsN": "{n} groupes",
"measure": "Compter",
"messagingTop": "Messagerie principale, par pays sélectionné",
"notMeasured": "Non mesuré",
"ntSub": "Langues des groupes affichés sans Nouveau Testament trouvé, selon le nombre de locuteurs. Rouge : rien du tout ; jaune : quelque chose, mais pas de Nouveau Testament.",
"ntTake": "{n} langues ici n’ont pas de Nouveau Testament trouvé, parlées par {people} personnes.",
"ntTitle": "Quelles langues ont le plus besoin du Nouveau Testament ?",
"people": "Personnes",
"peopleN": "{n} personnes",
"q1sub": "Une barre par niveau d’accès, pour les groupes ethniques affichés.",
"q1take": "{n} des {total} groupes ethniques affichés n’ont rien dans leur propre langue.",
"q1title": "Qu’ont-ils dans leur propre langue ?",
"q2sub": "Groupes ethniques sans rien dans leur langue, selon la part de leur pays en ligne.",
"q2take": "{online} des {n} sans rien vivent dans des pays où 60 % ou plus utilisent internet.",
"q2title": "Internet pourrait-il le porter à ceux qui n’ont rien ?",
"socialTop": "Réseau social principal, par pays sélectionné",
"unitCountries": "pays",
"unitGroups": "groupes ethniques",
@ -119,6 +131,8 @@
"count": "{n} sur {total} groupes ethniques",
"empty": "Aucun groupe ethnique ne correspond à ces filtres.",
"exported": "{n} lignes exportées (copiées et téléchargées).",
"openCapped": "Ouvre les {n} premiers sélectionnés.",
"openInGospel": "Ouvrir dans Ressources de l’Évangile",
"selectRow": "Sélectionner {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "अपनी भाषा में पहुँच, देश के इंटरनेट उपयोग के अनुसार",
"aria": "चार्ट",
"bandCaveat": "इंटरनेट उपयोग देश का आंकड़ा है (विश्व बैंक), समूह का कभी नहीं।",
"gapSub": "दिखाए गए में से जनसंख्या के अनुसार 8 सबसे बड़े। खोलने के लिए किसी पर क्लिक करें।",
"gapTake": "केवल ये {k} ही {people} लोग हैं।",
"gapTitle": "अपनी भाषा में कुछ भी न रखने वाले सबसे बड़े जन-समूह",
"groups": "समूह",
"groupsN": "{n} समूह",
"measure": "गिनती",
"messagingTop": "प्रमुख मैसेजिंग ऐप, दायरे के देशों के अनुसार",
"notMeasured": "मापा नहीं गया",
"ntSub": "दिखाए गए समूहों की वे भाषाएँ जिनमें नया नियम नहीं मिला, बोलने वालों की संख्या के अनुसार। लाल: कुछ नहीं; पीला: कुछ है, पर नया नियम नहीं।",
"ntTake": "यहाँ {n} भाषाओं में नया नियम नहीं मिला, जिन्हें {people} लोग बोलते हैं।",
"ntTitle": "किन भाषाओं को नए नियम की सबसे अधिक ज़रूरत है?",
"people": "लोग",
"peopleN": "{n} लोग",
"q1sub": "दिखाए गए जन-समूहों के लिए, पहुँच के हर स्तर पर एक पट्टी।",
"q1take": "दिखाए गए {total} जन-समूहों में से {n} की अपनी भाषा में कुछ नहीं मिला।",
"q1title": "उनकी अपनी भाषा में क्या है?",
"q2sub": "जिन जन-समूहों की भाषा में कुछ नहीं, उनके देश का कितना हिस्सा ऑनलाइन है उसके अनुसार।",
"q2take": "जिन {n} के पास कुछ नहीं, उनमें से {online} ऐसे देशों में रहते हैं जहाँ 60% या अधिक लोग इंटरनेट का उपयोग करते हैं।",
"q2title": "क्या इंटरनेट इसे उन तक पहुँचा सकता है जिनके पास कुछ नहीं है?",
"socialTop": "प्रमुख सोशल प्लेटफ़ॉर्म, दायरे के देशों के अनुसार",
"unitCountries": "देश",
"unitGroups": "जन-समूह",
@ -119,6 +131,8 @@
"count": "{total} में से {n} जन-समूह",
"empty": "इन फ़िल्टरों से कोई जन-समूह मेल नहीं खाता।",
"exported": "{n} पंक्तियाँ निर्यात हुईं (कॉपी और डाउनलोड)।",
"openCapped": "पहले {n} चयनित खोलता है।",
"openInGospel": "सुसमाचार संसाधनों में खोलें",
"selectRow": "{name} चुनें"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Accesso nella propria lingua, per uso di internet del paese",
"aria": "Grafici",
"bandCaveat": "L’uso di internet è il dato del paese (Banca Mondiale), mai quello del gruppo.",
"gapSub": "Gli 8 più grandi tra quelli mostrati, per popolazione. Clicca per aprirne uno.",
"gapTake": "Solo questi {k} sono {people} persone.",
"gapTitle": "I gruppi etnici più grandi senza niente nella propria lingua",
"groups": "Gruppi",
"groupsN": "{n} gruppi",
"measure": "Conta",
"messagingTop": "App di messaggistica principale, per paese selezionato",
"notMeasured": "Non misurato",
"ntSub": "Lingue dei gruppi mostrati senza Nuovo Testamento trovato, per numero di parlanti. Rosso: niente; giallo: qualcosa, ma nessun Nuovo Testamento.",
"ntTake": "{n} lingue qui non hanno un Nuovo Testamento trovato, parlate da {people} persone.",
"ntTitle": "Quali lingue hanno più bisogno del Nuovo Testamento?",
"people": "Persone",
"peopleN": "{n} persone",
"q1sub": "Una barra per livello di accesso, per i gruppi etnici mostrati.",
"q1take": "{n} dei {total} gruppi etnici mostrati non hanno niente nella propria lingua.",
"q1title": "Che cosa hanno nella propria lingua?",
"q2sub": "Gruppi etnici senza niente nella loro lingua, secondo quanto del loro paese è online.",
"q2take": "{online} dei {n} senza niente vivono in paesi dove il 60 % o più usa internet.",
"q2title": "Internet potrebbe portarlo a chi non ha niente?",
"socialTop": "Piattaforma social principale, per paese selezionato",
"unitCountries": "paesi",
"unitGroups": "gruppi etnici",
@ -119,6 +131,8 @@
"count": "{n} di {total} gruppi etnici",
"empty": "Nessun gruppo etnico corrisponde a questi filtri.",
"exported": "{n} righe esportate (copiate e scaricate).",
"openCapped": "Apre i primi {n} selezionati.",
"openInGospel": "Apri in Risorse del Vangelo",
"selectRow": "Seleziona {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Acesso na própria língua, por uso de internet do país",
"aria": "Gráficos",
"bandCaveat": "O uso de internet é o número do país (Banco Mundial), nunca o do grupo.",
"gapSub": "Os 8 maiores dos exibidos, por população. Clique num para abri-lo.",
"gapTake": "Só estes {k} somam {people} pessoas.",
"gapTitle": "Os maiores povos sem nada na própria língua",
"groups": "Grupos",
"groupsN": "{n} grupos",
"measure": "Contar",
"messagingTop": "App de mensagens principal, por país no recorte",
"notMeasured": "Não medido",
"ntSub": "Línguas dos grupos exibidos sem Novo Testamento encontrado, por quantas pessoas as falam. Vermelho: nada; amarelo: algo, mas sem Novo Testamento.",
"ntTake": "{n} línguas aqui não têm Novo Testamento encontrado, faladas por {people} pessoas.",
"ntTitle": "Quais línguas mais precisam do Novo Testamento?",
"people": "Pessoas",
"peopleN": "{n} pessoas",
"q1sub": "Uma barra por nível de acesso, para os povos exibidos.",
"q1take": "{n} de {total} povos exibidos não têm nada na própria língua.",
"q1title": "O que eles têm na própria língua?",
"q2sub": "Povos sem nada na própria língua, pelo quanto do país está online.",
"q2take": "{online} dos {n} sem nada vivem em países onde 60 % ou mais usam a internet.",
"q2title": "A internet poderia levar isso a quem não tem nada?",
"socialTop": "Rede social principal, por país no recorte",
"unitCountries": "países",
"unitGroups": "povos",
@ -119,6 +131,8 @@
"count": "{n} de {total} povos",
"empty": "Nenhum povo corresponde a estes filtros.",
"exported": "{n} linhas exportadas (copiadas e baixadas).",
"openCapped": "Abre os primeiros {n} selecionados.",
"openInGospel": "Abrir em Recursos do Evangelho",
"selectRow": "Selecionar {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Acces în propria limbă, după folosirea internetului în țară",
"aria": "Grafice",
"bandCaveat": "Folosirea internetului este cifra țării (Banca Mondială), niciodată a grupului.",
"gapSub": "Cele mai mari 8 dintre cele afișate, după populație. Faceți clic pentru a deschide unul.",
"gapTake": "Doar aceste {k} înseamnă {people} oameni.",
"gapTitle": "Cele mai mari grupuri etnice fără nimic în propria limbă",
"groups": "Grupuri",
"groupsN": "{n} grupuri",
"measure": "Numără",
"messagingTop": "Aplicația de mesagerie principală, pe țări selectate",
"notMeasured": "Nemăsurat",
"ntSub": "Limbile grupurilor afișate fără Noul Testament găsit, după numărul vorbitorilor. Roșu: nimic; galben: ceva, dar fără Noul Testament.",
"ntTake": "{n} limbi de aici nu au Noul Testament găsit, vorbite de {people} oameni.",
"ntTitle": "Ce limbi au cea mai mare nevoie de Noul Testament?",
"people": "Oameni",
"peopleN": "{n} oameni",
"q1sub": "O bară pentru fiecare nivel de acces, pentru grupurile etnice afișate.",
"q1take": "{n} din {total} grupuri etnice afișate nu au nimic în propria limbă.",
"q1title": "Ce au în propria lor limbă?",
"q2sub": "Grupuri etnice fără nimic în limba lor, după cât din țara lor este online.",
"q2take": "{online} din cele {n} fără nimic trăiesc în țări unde 60 % sau mai mult folosesc internetul.",
"q2title": "Ar putea internetul să-l ducă celor care nu au nimic?",
"socialTop": "Platforma socială principală, pe țări selectate",
"unitCountries": "țări",
"unitGroups": "grupuri etnice",
@ -119,6 +131,8 @@
"count": "{n} din {total} grupuri etnice",
"empty": "Niciun grup etnic nu corespunde acestor filtre.",
"exported": "{n} rânduri exportate (copiate și descărcate).",
"openCapped": "Deschide primele {n} selectate.",
"openInGospel": "Deschide în Resurse ale Evangheliei",
"selectRow": "Selectează {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "Доступ на родном языке по уровню интернета в стране",
"aria": "Диаграммы",
"bandCaveat": "Использование интернета — показатель страны (Всемирный банк), а не группы.",
"gapSub": "8 крупнейших из показанных, по численности. Нажмите, чтобы открыть.",
"gapTake": "Только эти {k} — это {people} человек.",
"gapTitle": "Крупнейшие народы, у которых нет ничего на родном языке",
"groups": "Группы",
"groupsN": "{n} групп",
"measure": "Считать",
"messagingTop": "Главный мессенджер по странам в выборке",
"notMeasured": "Не измерено",
"ntSub": "Языки показанных групп без найденного Нового Завета, по числу говорящих. Красный: ничего; жёлтый: что-то есть, но нет Нового Завета.",
"ntTake": "Языков здесь без найденного Нового Завета: {n}; на них говорят {people} человек.",
"ntTitle": "Каким языкам больше всего нужен Новый Завет?",
"people": "Люди",
"peopleN": "{n} чел.",
"q1sub": "Одна полоса на уровень доступа, для показанных народов.",
"q1take": "У {n} из {total} показанных народов нет ничего на родном языке.",
"q1title": "Что у них есть на родном языке?",
"q2sub": "Народы, у которых нет ничего на их языке, по доле их страны в сети.",
"q2take": "{online} из {n} без ничего живут в странах, где интернетом пользуются 60 % и более.",
"q2title": "Может ли интернет донести это до тех, у кого ничего нет?",
"socialTop": "Главная соцсеть по странам в выборке",
"unitCountries": "стран",
"unitGroups": "народов",
@ -119,6 +131,8 @@
"count": "{n} из {total} народов",
"empty": "Ни один народ не подходит под эти фильтры.",
"exported": "Экспортировано строк: {n} (скопировано и скачано).",
"openCapped": "Открывает первые {n} выбранных.",
"openInGospel": "Открыть в «Евангельских ресурсах»",
"selectRow": "Выбрать: {name}"
},
"headline": {

View file

@ -14,13 +14,25 @@
"accessByBand": "母语资源情况,按国家互联网使用率",
"aria": "图表",
"bandCaveat": "互联网使用率是国家的数字(世界银行),不是该族群的数字。",
"gapSub": "所显示族群中人口最多的 8 个。点击即可打开。",
"gapTake": "仅这 {k} 个就有 {people} 人。",
"gapTitle": "母语中一无所有的最大族群",
"groups": "族群",
"groupsN": "{n} 个族群",
"measure": "统计",
"messagingTop": "各国(当前范围)最主要的通讯应用",
"notMeasured": "未统计",
"ntSub": "所显示族群中未找到新约的语言,按使用人数排列。红色:一无所有;黄色:有一些,但没有新约。",
"ntTake": "这里有 {n} 种语言未找到新约,使用人数为 {people}。",
"ntTitle": "哪些语言最需要新约?",
"people": "人数",
"peopleN": "{n} 人",
"q1sub": "每个访问级别一条,针对所显示的族群。",
"q1take": "所显示的 {total} 个族群中,有 {n} 个在母语中一无所有。",
"q1title": "他们的母语中有什么?",
"q2sub": "母语中一无所有的族群,按其国家的上网比例。",
"q2take": "在 {n} 个一无所有的族群中,有 {online} 个生活在 60% 或以上人口使用互联网的国家。",
"q2title": "互联网能把它带给一无所有的人吗?",
"socialTop": "各国(当前范围)最主要的社交平台",
"unitCountries": "个国家",
"unitGroups": "个族群",
@ -119,6 +131,8 @@
"count": "{total} 个族群中的 {n} 个",
"empty": "没有符合这些筛选条件的族群。",
"exported": "已导出 {n} 行(已复制并下载)。",
"openCapped": "打开前 {n} 个所选项。",
"openInGospel": "在福音资源中打开",
"selectRow": "选择 {name}"
},
"headline": {

View file

@ -21,7 +21,7 @@
* internet figures are the country's (or state's); "found in the sources checked", never "reached"; no
* engagement, no one-works-here. Sources are named from the table's own meta.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, getCurrentInstance } from 'vue'
import { useDashboardLayout } from '@map/composables/useDashboardLayout.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { listLabel } from '@map/lists/peopleGroupList.js'
@ -39,6 +39,29 @@ import GiCombos from './src/components/GiCombos.vue'
const T = useGiT()
const board = useBoard()
const { bus } = board
// ── INTEROP OUT: every committed selection leaves as a DOM event another element or the host page can
// take (SPEC-DASHBOARD-KIT 5.3, level 2): bubbling + composed, so it crosses the shadow boundary; CODES,
// never labels (slugs, ISO 639-3, ISO 3166 alpha-3); debounced so a range drag sends one event. The host
// is found through the instance captured HERE, in setup: getCurrentInstance() in a watcher is null, and
// that is exactly how Simple Research's map -> grid event silently never fired.
const selfInstance = getCurrentInstance()
let selTimer = null
watch(() => bus.selectionVersion.value, () => {
if (selTimer) clearTimeout(selTimer)
selTimer = setTimeout(() => {
selTimer = null
const host = selfInstance?.proxy?.$el?.getRootNode?.()?.host
if (!host) return
const slugs = [...bus.selectionSet]
const rows = slugs.map((s) => board.bySlug.value.get(s)).filter(Boolean)
host.dispatchEvent(new CustomEvent('gospel-internet:selection', { bubbles: true, composed: true, detail: {
slugs, languages: [...new Set(rows.map((r) => r.iso639_3))], countries: [...new Set(rows.map((r) => r.iso3))],
list: board.listId.value,
} }))
}, 150)
})
onBeforeUnmount(() => { if (selTimer) clearTimeout(selTimer) })
// The URL / host preset (view, facets, centre panel, group) is applied BEFORE the first render, so the
// board opens already in its state: no flash of the default view, and the first render mounts the panel
// the preset asks for (the language tree included) where a headless mount check can see it.
@ -62,7 +85,7 @@ onBeforeUnmount(() => { try { ro && ro.disconnect() } catch (_) {} })
// The kit's layout engine, under THIS board's key (landing 2's namespacing), grid mode.
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH } = useDashboardLayout({
shellRef: shell, bus, isNarrow, storageKey: 'gi-board-layout-v1',
defaults: { facetW: 264, railW: 300, tableH: '38%' },
defaults: { facetW: 264, railW: 360, tableH: '38%' },
})
// ── the headline: the board's question answered in one line, for the list in view ───────────────────
@ -124,9 +147,11 @@ const sourcesLine = computed(() => (board.meta.value.sources || []).join(' · ')
useShadowStyles(
':host{display:block}' +
'.gb{--gb-bg:#f3f3f1;position:relative;box-sizing:border-box;width:100%;height:100%;min-height:640px;display:grid;background:var(--gb-bg);color:#3b463d;' +
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,300px);' +
'grid-template-columns:var(--facet-w,264px) minmax(0,1fr) var(--rail-w,360px);' +
'grid-template-rows:auto auto auto minmax(0,1fr) var(--table-h,38%);' +
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid grid";' +
// The charts rail runs the full height beside the map AND the table: sized to the map row alone it was
// ~300px and clipped its four charts (rega, the Driver: "the charts don't look good").
'grid-template-areas:"top top top" "combos combos combos" "note note note" "facets map rail" "facets grid rail";' +
'font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;overflow:hidden}' +
'.gb-top{grid-area:top;display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;padding:10px 14px;background:#fff;border-bottom:1px solid #d1d4cd}' +
'.gb-title{margin:0;font-size:17px;font-weight:700;letter-spacing:.005em}' +

View file

@ -82,6 +82,13 @@ const back = M.buildQuery({ view: 'zero', filterSet: { band: ['yes', 'partial'],
ok(back.includes('lang=ar') && back.includes('list=700') && back.includes('band=yes%7Cpartial') && back.includes('view=zero'),
`query builds and keeps lang/list: ${back}`)
ok(M.parseQuery('?view=nonsense').view === 'board', 'an unknown view falls back to board')
// embed-contract parity with Gospel Resources: ?groups= (peoples by slug) and ?codes= / ?code= (languages)
const pg = M.parseQuery('?groups=abai-sungai,hadrami-arabs&codes=abf,ayh')
ok(pg.groups.length === 2 && Array.isArray(pg.filters.language) && pg.filters.language.join('|') === 'abf|ayh', 'parses ?groups= (slugs) and ?codes= (languages), comma-separated as Gospel Resources writes them')
ok(M.parseQuery('?code=ara').filters.language === 'ara' && M.parseQuery('?codes=ARAB,xx').filters.language === undefined, '?code= alone is a language; malformed codes are refused, never guessed')
const link = M.gospelResourcesLink('https://x.test/dx-maps/gospel-resources/resources-map/index.html', ['a', 'b'], { list: '700', lang: 'ar' })
ok(link.includes('groups=a%2Cb') && link.includes('list=700') && link.includes('lang=ar'), `out-link to Gospel Resources carries groups, list and lang: ${link.split('?')[1]}`)
ok(new URLSearchParams(M.gospelResourcesLink('u', Array.from({ length: 500 }, (_, i) => 's' + i)).split('?')[1]).get('groups').split(',').length === M.GROUPS_LINK_CAP, 'the out-link is capped at GROUPS_LINK_CAP slugs')
// 7. CSV
const csv = M.buildCsv(

View file

@ -61,7 +61,7 @@ try {
b.tree.value = markRaw({ ...M.indexTree(treeDoc), meta: treeDoc.meta })
b.resources.value = markRaw(res)
b.loading.value = false
if (open) b.openSlug.value = open.slug
if (open) { b.openSlug.value = open.slug; b.bus.setSelection([open.slug]) }
// narrow to the largest family through the tree, as a click would
const fam = b.tree.value.nodes.filter((n) => !n.p).sort((x, y) => b.tree.value.isos.get(y.id).size - b.tree.value.isos.get(x.id).size)[0]
b.setTreeNode(fam.id)
@ -89,6 +89,10 @@ try {
['GiTree', /data-tree-legend[\s\S]*data-tree-poster/], ['GiResources', /data-resources-total/], ['GiMap', /data-region="map"/]]) ok(re.test(en.html), `en: ${name} rendered its region`)
ok(!!open && en.html.includes(open.people_group), `en: the drawer shows the opened group (${open ? open.people_group : 'no row'})`)
ok(/data-notice="approximate-pin"/.test(en.html), 'en: the drawer carries the approximate-pin notice')
// interop out: the selection opens in Gospel Resources by its ?groups= contract, carrying the slug
const gl = (en.html.match(/href="([^"]*)"[^>]*data-open-gospel/) || [])[1] || ''
ok(!!open && gl.includes('gospel-resources/resources-map/index.html') && decodeURIComponent(gl).includes('groups=' + open.slug) && !POISON,
`en: the selection links out to Gospel Resources with ?groups= (${gl.split('?')[1] || 'no link'})`)
ok(/data-tree-card/.test(en.html), 'en: the tree card shows the node the board is narrowed to')
ok(/href="https?:\/\/[^"]+"/.test(en.html.slice(en.html.indexOf('data-region="resources"'))), 'en: the resources tab lists real links')
ok(/\b\d[\d,]* of [\d,]+ people groups\b/.test(en.html), 'en: the grid prints its scope count')
@ -97,6 +101,12 @@ try {
const fam = treeDoc.nodes.filter((n) => !n.p).sort((x, y) => 0)[0]
const famNode = (() => { const ix = M.indexTree(treeDoc); return ix.nodes.filter((n) => !n.p).sort((x, y) => ix.isos.get(y.id).size - ix.isos.get(x.id).size)[0] })()
ok(/data-chip="language"/.test(en.html) && en.html.includes(famNode.n), `en: the kit chip names the tree node (${famNode.n})`)
// the charts answer their question in words (the harness narrows to a family first, so assert the
// takeaways' FORM and that the gap charts name real groups/languages, not the roster's totals)
const plain = en.html.replace(/<!--[\s\S]*?-->/g, '')
ok(/\d[\d,]* of the \d[\d,]* with nothing live in countries/.test(plain) && !POISON, 'en: chart 3 states its takeaway')
ok(/data-chart="gaps"[\s\S]*?These \d+ alone are [\d,]+ people/.test(plain), 'en: the gap chart names its groups and totals them')
ok(/data-chart="ntNeed"[\s\S]*?languages here have no New Testament found/.test(plain), 'en: the New Testament chart states its takeaway')
ok(new RegExp('data-combo="digital-now"[^>]*>\\s*<span>[^<]+</span><span class="gic2-n">' + (POISON ? 'x' : '220')).test(en.html), 'en: a combination shows its live count (digital-now = 220)')
void fam
const ar = await render('ar')

View file

@ -1,69 +1,98 @@
<script setup>
/**
* GiCharts.vue — the right rail: the "bridge through the internet" picture in two charts, over the rows
* in scope (they follow the facets, like every panel).
* 1. In their own language x country internet band — stacked bars, groups or people.
* 2. Nothing in their own language, by band — the zero-access front door.
* The library's BarChart draws both (hand-rolled SVG, no chart CDN: a third-party request from a
* persecuted reader's device is a safety leak). A bar is a filter: clicking one toggles that band.
* GiCharts.vue — the right rail: FOUR charts, each answering one plain question about the groups shown,
* chosen to show a newcomer the GAPS they would not otherwise see (Driver, 2026-09-27: "a reader can't
* tell what they're saying"; "showcase the gaps ... the biggest, most helpful, clearest data"). Each
* carries its question as the title, says what one bar is and what it counts, and ends in a one-line
* takeaway computed from the same rows.
* 1. What do they have in their own language? one bar per level of access
* 2. The largest people groups with nothing the 8 biggest gaps, by name (click opens one)
* 3. Could the internet carry it to those with nothing? by how much of their country is online
* 4. Which languages most need the New Testament? languages with none found, by people who speak them
* Dropped: a stacked access x band chart (five colours on four bars, no readable answer), a social
* platform chart and a messaging-app chart (channels, not gaps: the facets and drawer carry them).
* They follow the facets like every panel; a bar is a filter (click to narrow the board to it).
*/
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { accessByBand, countriesBy, ACCESS_DETAILS, BANDS, DETAIL_FILL, label } from '../model.js'
import { ACCESS_DETAILS, BANDS, DETAIL_FILL, BAND_COLOR, TREE_COLOR, label } from '../model.js'
const T = useGiT()
const board = useBoardState()
const { bus } = board
const measure = ref('groups')
const fmt = (v) => (Number(v) || 0).toLocaleString()
// The 'none' segment is drawn in the rim colour: a white segment on a white chart would be invisible.
const SEG_COLOR = { ...DETAIL_FILL, none: '#7d3b30' }
const stack = computed(() => {
const m = accessByBand(board.filtered.value, measure.value)
return BANDS.map((b) => {
const segs = ACCESS_DETAILS.map((d) => ({ label: label(T, 'detail', d), value: m[b][d], color: SEG_COLOR[d] }))
return { label: label(T, 'band', b), value: segs.reduce((s, x) => s + x.value, 0), segments: segs, meta: { band: b } }
})
})
const legend = computed(() => ACCESS_DETAILS.map((d) => ({ label: label(T, 'detail', d), color: SEG_COLOR[d] })))
const zero = computed(() => {
const rows = board.filtered.value.filter((r) => r.access_detail === 'none')
return BANDS.map((b) => {
const inBand = rows.filter((r) => r.internet_band === b)
const people = inBand.reduce((s, r) => s + (Number(r.population) || 0), 0)
return { label: label(T, 'band', b), value: measure.value === 'people' ? people : inBand.length,
note: measure.value === 'people' ? T('gospelInternet.charts.groupsN', '{n} groups', { n: fmt(inBand.length) }) : T('gospelInternet.charts.peopleN', '{n} people', { n: fmt(people) }),
color: '#7d3b30', meta: { band: b } }
})
})
// Charts 3 and 4: which apps would carry a resource into the countries in scope. Counted by COUNTRY,
// and each source's own caveat is printed under its chart (verbatim from the table's meta).
const notInFeed = () => T('gospelInternet.charts.notMeasured', 'Not measured')
const appSeries = computed(() => countriesBy(board.filtered.value, board.countries.value, (c) => c && c.top_messaging)
.map((e) => ({ label: e.value || notInFeed(), value: e.n, color: e.value ? '#3b463d' : '#9aa2ad', meta: { app: e.value } })))
const socialSeries = computed(() => countriesBy(board.filtered.value, board.countries.value, (c) => c && c.social && c.social[0] && c.social[0].platform)
.map((e) => ({ label: e.value || notInFeed(), value: e.n, color: e.value ? '#92b195' : '#9aa2ad' })))
const pmeta = computed(() => board.meta.value.platforms || {})
const unitCountries = computed(() => T('gospelInternet.charts.unitCountries', 'countries'))
function onApp(row) { const a = row && row.meta && row.meta.app; if (a) bus.toggleFilterValue('messaging', a) }
const platformsFirst = computed(() => board.view.value === 'platforms')
const count = (rs) => measure.value === 'people' ? rs.reduce((s, r) => s + (Number(r.population) || 0), 0) : rs.length
const unit = computed(() => measure.value === 'people' ? T('gospelInternet.charts.unitPeople', 'people') : T('gospelInternet.charts.unitGroups', 'people groups'))
function onBar(row) { const b = row && row.meta && row.meta.band; if (b) bus.toggleFilterValue('band', b) }
const rows = computed(() => board.filtered.value)
// 1 ─ what they have in their own language
const SEG_COLOR = { ...DETAIL_FILL, none: '#7d3b30' } // a white bar would vanish on white
const q1 = computed(() => ACCESS_DETAILS.map((d) => {
const rs = rows.value.filter((r) => r.access_detail === d)
return { label: label(T, 'detail', d), value: count(rs), color: SEG_COLOR[d], meta: { key: 'access_detail', value: d } }
}))
const q1take = computed(() => {
const none = rows.value.filter((r) => r.access_detail === 'none').length
return T('gospelInternet.charts.q1take', '{n} of {total} people groups shown have nothing found in their own language.', { n: fmt(none), total: fmt(rows.value.length) })
})
// 2 ─ could the internet carry it to those with nothing?
const zero = computed(() => rows.value.filter((r) => r.access_detail === 'none'))
const q2 = computed(() => BANDS.map((b) => {
const rs = zero.value.filter((r) => r.internet_band === b)
return { label: label(T, 'band', b), value: count(rs), color: BAND_COLOR[b], meta: { key: 'band', value: b } }
}))
const q2take = computed(() => {
const online = zero.value.filter((r) => r.internet_band === 'yes').length
return T('gospelInternet.charts.q2take', '{online} of the {n} with nothing live in countries where 60% or more use the internet.', { online: fmt(online), n: fmt(zero.value.length) })
})
// 2 ─ the largest people groups with nothing in their own language: the biggest gaps, by name
const TOP = 8
const gaps = computed(() => [...zero.value].sort((a, b) => (Number(b.population) || 0) - (Number(a.population) || 0)).slice(0, TOP))
const gapSeries = computed(() => gaps.value.map((r) => ({ label: `${r.people_group} (${r.iso3})`, value: Number(r.population) || 0, color: SEG_COLOR.none, meta: { open: r.slug } })))
const gapTake = computed(() => T('gospelInternet.charts.gapTake', 'These {k} alone are {people} people.', { k: fmt(gaps.value.length), people: fmt(gaps.value.reduce((s, r) => s + (Number(r.population) || 0), 0)) }))
// 4 ─ which languages most need the New Testament? (status per language comes from the language tree)
onMounted(() => { board.loadTree().catch(() => {}) })
const ntNeed = computed(() => {
const ix = board.tree.value
if (!ix) return []
const by = new Map()
for (const r of rows.value) {
const node = ix.byId.get(ix.nodeOfIso.get(r.iso639_3))
if (!node || node.st === 'g') continue
const e = by.get(r.iso639_3) || { iso: r.iso639_3, name: r.language, st: node.st, people: 0 }
e.people += Number(r.population) || 0
by.set(r.iso639_3, e)
}
return [...by.values()].sort((a, b) => b.people - a.people)
})
const ntSeries = computed(() => ntNeed.value.slice(0, TOP).map((e) => ({ label: `${e.name} (${e.iso})`, value: e.people, color: TREE_COLOR[e.st], meta: { key: 'language', value: e.iso } })))
const ntTake = computed(() => T('gospelInternet.charts.ntTake', '{n} languages here have no New Testament found, spoken by {people} people.', { n: fmt(ntNeed.value.length), people: fmt(ntNeed.value.reduce((s, e) => s + e.people, 0)) }))
function onBar(row) {
const m = row && row.meta
if (!m) return
if (m.open) board.openGroup(m.open)
else bus.toggleFilterValue(m.key, m.value)
}
useShadowStyles(
'.gic{display:flex;flex-direction:column;height:100%;overflow-y:auto;box-sizing:border-box;padding:10px 12px 16px;background:#fff;color:#3b463d;font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
'.gic-seg{display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin-bottom:8px}' +
'.gic{display:flex;flex-direction:column;gap:4px;height:100%;overflow-y:auto;box-sizing:border-box;padding:10px 12px 16px;background:#fff;color:#3b463d;font:13px/1.35 Roboto,-apple-system,"Segoe UI",sans-serif;scrollbar-width:thin}' +
// A flex column's children SHRINK by default: inside this scroll box they were squeezed instead of
// scrolled, so the measure toggle collapsed to a 2px line and a caveat card was cut in half (rega).
'.gic > *{flex-shrink:0}' +
'.gic-seg{align-self:flex-start;display:inline-flex;border:1px solid #d1d4cd;border-radius:6px;overflow:hidden;margin-bottom:4px}' +
'.gic-seg button{font:inherit;font-size:12px;padding:4px 12px;min-height:26px;border:0;background:#fff;color:#3b463d;cursor:pointer}' +
'.gic-seg button[aria-pressed="true"]{background:#3b463d;color:#fff}' +
'.gic-seg button:focus-visible{outline:2px solid #92b195;outline-offset:-2px}' +
'.gic-seg{align-self:flex-start;order:-1}' +
'.gic-card{border-bottom:1px solid #e6e8e3;padding:6px 0 12px;margin-bottom:6px}',
'.gic-card{border-bottom:1px solid #e6e8e3;padding:4px 0 12px}' +
'.gic-take{margin:4px 0 0;font-size:12.5px;font-weight:600;color:#3b463d}',
'gi-charts',
)
</script>
@ -74,32 +103,34 @@ useShadowStyles(
<button type="button" :aria-pressed="measure === 'groups'" @click="measure = 'groups'">{{ T('gospelInternet.charts.groups', 'Groups') }}</button>
<button type="button" :aria-pressed="measure === 'people'" @click="measure = 'people'">{{ T('gospelInternet.charts.people', 'People') }}</button>
</div>
<div class="gic-card" :style="{ order: platformsFirst ? 0 : 3 }">
<BarChart :series="appSeries" :unit="unitCountries" :bar-height="18" :gap="6"
:title="T('gospelInternet.charts.messagingTop', 'Top messaging app, by country in scope')"
:caveats="[pmeta.ios_caveat, pmeta.ranking_caveat, pmeta.classification_caveat].filter(Boolean)"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onApp" />
<div class="gic-card" data-chart="q1">
<BarChart :series="q1" :unit="unit" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.q1title', 'What do they have in their own language?')"
:subtitle="T('gospelInternet.charts.q1sub', 'One bar per level of access, for the people groups shown.')"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p class="gic-take">{{ q1take }}</p>
</div>
<div class="gic-card" :style="{ order: platformsFirst ? 0 : 4 }">
<BarChart :series="socialSeries" :unit="unitCountries" :bar-height="18" :gap="6"
:title="T('gospelInternet.charts.socialTop', 'Top social platform, by country in scope')"
:caveats="[pmeta.social_caveat].filter(Boolean)"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" />
<div class="gic-card" data-chart="gaps">
<BarChart :series="gapSeries" :unit="T('gospelInternet.charts.unitPeople', 'people')" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.gapTitle', 'The largest people groups with nothing in their own language')"
:subtitle="T('gospelInternet.charts.gapSub', 'The 8 biggest of those shown, by population. Click one to open it.')"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p v-if="gaps.length" class="gic-take">{{ gapTake }}</p>
</div>
<div class="gic-card" style="order:1">
<BarChart :series="stack" :legend="legend" :unit="unit" :bar-height="20" :gap="8"
:title="T('gospelInternet.charts.accessByBand', 'Own-language access, by country internet use')"
<div class="gic-card" data-chart="q2">
<BarChart :series="q2" :unit="unit" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.q2title', 'Could the internet carry it to those with nothing?')"
:subtitle="T('gospelInternet.charts.q2sub', 'People groups with nothing in their own language, by how much of their country is online.')"
:caveats="[T('gospelInternet.charts.bandCaveat', 'Internet use is the country\'s figure (World Bank), never the group\'s.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')" @bar-click="onBar" />
<p v-if="zero.length" class="gic-take">{{ q2take }}</p>
</div>
<div class="gic-card" style="order:2">
<BarChart :series="zero" :unit="unit" :bar-height="20" :gap="8" color="#7d3b30"
:title="T('gospelInternet.charts.zeroByBand', 'Nothing in their own language, by country internet use')"
:caveats="[T('gospelInternet.charts.zeroCaveat', 'Every one of these has a way in: a bridge language, a candidate or a Deaf bridge.')]"
:empty-text="T('gospelInternet.grid.empty', 'No people groups match these filters.')"
@bar-click="onBar" />
<div class="gic-card" data-chart="ntNeed">
<BarChart :series="ntSeries" :unit="T('gospelInternet.charts.unitPeople', 'people')" :bar-height="18" :gap="6" :label-width="130"
:title="T('gospelInternet.charts.ntTitle', 'Which languages most need the New Testament?')"
:subtitle="T('gospelInternet.charts.ntSub', 'Languages of the groups shown with no New Testament found, by how many people speak them. Red: nothing at all; yellow: something, but no New Testament.')"
:empty-text="T('grid.loading', 'Loading…')" @bar-click="onBar" />
<p v-if="ntNeed.length" class="gic-take">{{ ntTake }}</p>
</div>
</aside>
</template>

View file

@ -18,7 +18,8 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useBoardState } from '../useBoard.js'
import { useGiT } from '../useGiT.js'
import { buildColumns } from '../columns.js'
import { buildCsv, exportScope, safeHref, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
import { buildCsv, exportScope, safeHref, gospelResourcesLink, GROUPS_LINK_CAP, DETAIL_FILL, DETAIL_RIM, BAND_COLOR } from '../model.js'
import { resolveShareUrl } from '@map/utils/embedBase.js'
const T = useGiT()
const board = useBoardState()
@ -108,6 +109,15 @@ function exportCsv() {
status.value = T('gospelInternet.grid.exported', '{n} rows exported (copied and downloaded).', { n: n.toLocaleString() })
setTimeout(() => { status.value = '' }, 4000)
}
// INTEROP OUT, as a link: the selected peoples, opened in Gospel Resources' map (its ?groups= contract).
const gospelLink = computed(() => {
void bus.selectionVersion.value
if (!bus.selectionSet.size) return ''
const cfg = board.profileConfig.value || {}
const base = resolveShareUrl({ embedPath: 'gospel-resources/resources-map/index.html', profileConfig: { ...cfg, shareUrl: '' } })
return gospelResourcesLink(base, bus.selectionSet, { list: board.listIsDefault.value ? '' : board.listId.value, lang: cfg.lang || cfg.locale || '' })
})
const gospelCapped = computed(() => { void bus.selectionVersion.value; return bus.selectionSet.size > GROUPS_LINK_CAP })
const exportLabel = computed(() => selCount.value ? T('dashboard.export_selection', 'Export this selection') : T('dashboard.export_csv', 'Export CSV'))
const fmt = (n) => (Number(n) || 0).toLocaleString()
@ -118,6 +128,7 @@ useShadowStyles(
'.gig-search{flex:1 1 180px;max-width:320px;border:1px solid #d1d4cd;border-radius:6px;padding:5px 9px;font:inherit;font-size:12.5px;background:#fff;color:#3b463d}' +
'.gig-btn{border:1px solid #d1d4cd;background:#fff;color:#3b463d;border-radius:6px;padding:4px 10px;min-height:28px;font:inherit;font-size:12.5px;cursor:pointer}' +
'.gig-btn:hover{border-color:#92b195}' +
'a.gig-btn{text-decoration:none;display:inline-flex;align-items:center}' +
'.gig-btn.primary{background:#3b463d;border-color:#3b463d;color:#fff}' +
'.gig-btn:focus-visible,.gig-th button:focus-visible,.gig-tr:focus-visible{outline:2px solid #92b195;outline-offset:1px}' +
'.gig-status{font-size:12px;color:#4f6b52}' +
@ -157,6 +168,8 @@ useShadowStyles(
:placeholder="T('grid.search_people_or_countries', 'Search people groups or countries…')"
:aria-label="T('grid.search_people_or_countries', 'Search people groups or countries…')" />
<button v-if="selCount" type="button" class="gig-btn" @click="bus.clearSelection()">{{ T('buttons.clearSelection', 'Clear selection') }} ({{ fmt(selCount) }})</button>
<a v-if="gospelLink" class="gig-btn" :href="gospelLink" target="_blank" rel="noopener" data-open-gospel
:title="gospelCapped ? T('gospelInternet.grid.openCapped', 'Opens the first {n} selected.', { n: fmt(GROUPS_LINK_CAP) }) : ''">{{ T('gospelInternet.grid.openInGospel', 'Open in Gospel Resources') }}</a>
<span class="gig-status" role="status">{{ status }}</span>
<span style="flex:1"></span>
<div class="gig-picker">

View file

@ -94,11 +94,18 @@ export function parseQuery(search) {
if (vals.length) filters[key] = vals.length === 1 ? vals[0] : vals
}
const view = VIEWS.includes(q.get('view')) ? q.get('view') : 'board'
// EMBED-CONTRACT PARITY with Gospel Resources (resources-map.vue:341/354): ?codes= (and ?code=) name
// LANGUAGES by ISO 639-3, ?groups= names PEOPLES by DOXA slug. Comma-separated there, so accepted with
// ',' as well as '|' here (neither can occur inside a slug or a code).
const list = (name) => (q.get(name) || '').split(/[,|]/).map((s) => s.trim().toLowerCase()).filter(Boolean)
const codes = [...list('codes'), ...list('code')].filter((c) => /^[a-z]{3}$/.test(c))
if (codes.length && !filters.language) filters.language = codes.length === 1 ? codes[0] : codes
const groups = list('groups').filter((s) => /^[a-z0-9-]+$/.test(s))
const panel = q.get('panel') === 'tree' ? 'tree' : 'map'
const tree = /^[a-z0-9-]{3,40}$/.test(q.get('tree') || '') ? q.get('tree') : ''
// A tree node narrows by LANGUAGE (its ISO codes); the URL carries the node, not the code list.
if (tree) delete filters.language
return { view, filters, group: q.get('group') || '', panel, tree }
return { view, filters, group: q.get('group') || '', panel, tree, groups }
}
/** The inverse: filterSet + view + group -> a query string (no leading '?'), stable key order. */
@ -300,6 +307,22 @@ export function exportScope(rows, filtered, selectionSet) {
return { scope: filtered.length === rows.length ? 'all' : 'filter', rows: filtered }
}
/**
* Where a selection goes next: Gospel Resources' map, narrowed to exactly these peoples (its ?groups=
* contract, by DOXA slug). A URL is the interop link that works across bundles, pages and partner sites
* alike. Capped: a link past a few hundred slugs outgrows what browsers and servers accept, so the first
* GROUPS_LINK_CAP go and the caller says so. `base` is the tree's embed base (resolveShareUrl).
*/
export const GROUPS_LINK_CAP = 300
export function gospelResourcesLink(base, slugs, { list = '', lang = '' } = {}) {
const pick = [...slugs].slice(0, GROUPS_LINK_CAP)
const q = new URLSearchParams()
if (pick.length) q.set('groups', pick.join(','))
if (list) q.set('list', list)
if (lang && lang !== 'en') q.set('lang', lang)
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`
}
/** A safe outbound URL: http(s) only (a javascript: or data: link in a table cell is never followed).
* Not library/utils/html.js safeUrl: that one HTML-ESCAPES its result for string-built popups, and a
* Vue :href binding escapes again, so '&' would reach the browser as '&amp;amp;'. Every link in this

View file

@ -153,12 +153,17 @@ export function useBoard() {
if (group) pendingGroup = group
const panel = q.panel === 'tree' || cfg().panel === 'tree' ? 'tree' : 'map'
if (q.tree) pendingTree = q.tree
if (q.groups && q.groups.length) pendingSelection = q.groups
setCentre(panel)
}
let pendingGroup = ''
let pendingSelection = null
watch(all, () => {
if (pendingGroup && bySlug.value.has(pendingGroup)) openGroup(pendingGroup)
pendingGroup = ''
// ?groups= arrives SELECTED (ringed, framed by the camera), the same peoples Gospel Resources would
// draw for that link; slugs not on this list are dropped, never guessed
if (pendingSelection) { bus.setSelection(pendingSelection.filter((s) => bySlug.value.has(s))); pendingSelection = null }
})
function syncUrl() {
if (!writeUrl.value || typeof history === 'undefined') return

View file

@ -915,6 +915,7 @@ export default defineComponent({
const useModel = (m) => {
this.manifestBundles = (m && m.bundles) || []
this.rows = this.rowsFromManifest(m)
this.setupWidgets()
this.route()
}
const baked = (typeof window !== 'undefined' && window.__psBaked) || null
@ -961,6 +962,9 @@ export default defineComponent({
cap,
icon: CAP_ICON[cap] || 'embed',
description: p.description || '',
// CORNER WIDGET (the Driver, BUILD-CORNER-WIDGET-CLASS): a profile whose .meta.json declares
// "widget": { "corner": ... } is mounted once in a fixed corner of the Studio, not in a pane.
widget: (p.widget && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(p.widget.corner)) ? p.widget.corner : '',
heavy,
bundleJs: b.scriptUrl || (b.bundle + '/' + b.bundle + '.js'),
embed: (p.embed && p.embed.script) || '',
@ -1017,6 +1021,10 @@ export default defineComponent({
// guard would blank the content pane (flatRows has no matching entry to render) with the "Choose
// an element" hint also gone — a real, if minor, regression a synthetic row must never cause.
if (!this.rows.some(r => r.key === key)) return
// A corner widget is never mounted in a pane (one live instance, in its corner): picking it in the
// rail opens its corner instead.
const wrow = this.rows.find(r => r.key === key)
if (wrow && wrow.widget) { this.openWidget(wrow, true); return }
const prevKey = this.activeKey
this.activeKey = key
this.embedOpen = false
@ -1050,6 +1058,73 @@ export default defineComponent({
row.state = 'idle'
row.error = ''
},
// ── CORNER WIDGETS ────────────────────────────────────────────────────────────────────────────────
// One fixed host per corner, created on document.body and kept in a PAGE-LEVEL singleton
// (window.__doxaStudioWidgets), outside Vue's tree: a Studio hot update or remount finds the same host
// and the same mounted element, so a widget like Rudder keeps its live state across HMR. Collapsed to a
// button by default; the open state is remembered per widget.
widgetStore() {
if (!window.__doxaStudioWidgets) window.__doxaStudioWidgets = { hosts: {}, els: {} }
return window.__doxaStudioWidgets
},
widgetHost(corner) {
const W = this.widgetStore()
if (W.hosts[corner] && W.hosts[corner].isConnected) return W.hosts[corner]
const h = document.createElement('div')
h.setAttribute('data-studio-widgets', corner)
const [v, x] = corner.split('-')
h.style.cssText = `position:fixed;${v}:12px;${x}:12px;z-index:2147483647;display:flex;flex-direction:column;align-items:${x === 'right' ? 'flex-end' : 'flex-start'};gap:8px;`
document.body.appendChild(h)
W.hosts[corner] = h
return h
},
setupWidgets() {
for (const row of this.rows.filter(r => r.widget)) {
const W = this.widgetStore()
const host = this.widgetHost(row.widget)
let box = host.querySelector(`[data-widget="${row.key}"]`)
if (!box) {
box = document.createElement('div')
box.setAttribute('data-widget', row.key)
box.style.cssText = 'display:flex;flex-direction:column;align-items:inherit;gap:6px;'
const panel = document.createElement('div')
panel.setAttribute('data-widget-panel', '')
panel.style.cssText = 'display:none;width:min(380px,calc(100vw - 24px));height:min(520px,calc(100vh - 80px));position:relative;background:#fff;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.25);overflow:hidden;'
const btn = document.createElement('button')
btn.type = 'button'
btn.textContent = row.label
btn.setAttribute('aria-expanded', 'false')
btn.style.cssText = 'font:600 13px system-ui,sans-serif;padding:8px 14px;border-radius:999px;border:1px solid #30363d;background:#0d1117;color:#e6edf3;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.25);'
btn.addEventListener('click', () => this.openWidget(row, panel.style.display === 'none'))
box.appendChild(panel); box.appendChild(btn); host.appendChild(box)
}
if (W.els[row.key] && !box.querySelector(row.tag)) box.querySelector('[data-widget-panel]').appendChild(W.els[row.key])
let open = false
try { open = localStorage.getItem('plugin-studio:widget-open:' + row.key) === '1' } catch (_) {}
if (open) this.openWidget(row, true)
}
},
openWidget(row, open) {
const host = this.widgetHost(row.widget)
const box = host.querySelector(`[data-widget="${row.key}"]`)
if (!box) { this.setupWidgets(); return }
const panel = box.querySelector('[data-widget-panel]')
const btn = box.querySelector('button')
panel.style.display = open ? 'block' : 'none'
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
try { localStorage.setItem('plugin-studio:widget-open:' + row.key, open ? '1' : '0') } catch (_) {}
const W = this.widgetStore()
if (!open || W.els[row.key]) return
loadScriptOnce(this.resolve(row.bundleJs)).then(() => whenTagDefined(row.tag)).then(() => {
if (W.els[row.key]) return
const el = document.createElement(row.tag)
el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, this.locale)))
if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, ''))
el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;'
W.els[row.key] = el
panel.appendChild(el)
}).catch((e) => { panel.textContent = (e && e.message) || String(e) })
},
mountElement(row) {
row.mounted = true
row.state = 'loading'

View file

@ -1,6 +1,7 @@
<script setup>
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
@ -56,7 +57,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
map.value.on('load', async () => {

View file

@ -1,6 +1,7 @@
<script setup>
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn, setProjectionFor } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
@ -432,7 +433,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
if (map.value) {

View file

@ -7,6 +7,7 @@
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -40,7 +41,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
map.value.on('load', async () => {

View file

@ -1,6 +1,8 @@
<script setup>
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn, setProjectionFor } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { religionFamilyLabel } from '@map/colors/religion.js'
import { tStatic } from '@map/i18n/index.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
@ -137,6 +139,12 @@ const RELIGION_GROUPS = [
{ key: 'other', label: 'Other / Unknown', color: '#4b5563' }, // dark gray
]
// Shown in the reader's language through the shared religion catalogue; the key and the filter stay
// English. "Other / Unknown" is one bucket here, so it is named from both catalogue entries.
const groupLabel = (g) => (g.key === 'other'
? `${religionFamilyLabel('Other')} / ${religionFamilyLabel('Unknown')}`
: religionFamilyLabel(g.label))
const COLOR_EXPR = [
'match', ['get', 'rg'],
...RELIGION_GROUPS.flatMap(g => [g.key, g.color]),
@ -147,7 +155,7 @@ const COLOR_EXPR = [
const religionCounts = ref({})
const religionLegendItems = computed(() => RELIGION_GROUPS.map(g => ({
key: g.key,
label: g.label,
label: groupLabel(g),
color: g.color,
count: religionCounts.value[g.key] || 0,
filter: ['==', ['get', 'rg'], g.key],
@ -166,7 +174,7 @@ function onLegendSelect(node) {
const religionLocalIndex = computed(() => {
const out = RELIGION_GROUPS.map((g, i) => ({
id: `rel:${g.key}`,
label: g.label,
label: groupLabel(g),
color: g.color,
kind: 'religion',
depth: 0,
@ -250,7 +258,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
if (map.value) {
@ -279,7 +287,7 @@ onMounted(async () => {
religionCounts.value = counts
const dominant = RELIGION_GROUPS.reduce((a, b) => (counts[a.key] || 0) > (counts[b.key] || 0) ? a : b)
// Per user 2026-05-02: drop the "Ready" prefix — it was unclear and chewed up width.
status.value = t('pplr.status.readyReligion', { pins: pgData.value.features.length.toLocaleString(), religion: dominant.label, count: (counts[dominant.key]||0).toLocaleString() })
status.value = t('pplr.status.readyReligion', { pins: pgData.value.features.length.toLocaleString(), religion: groupLabel(dominant), count: (counts[dominant.key]||0).toLocaleString() })
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true

View file

@ -1,6 +1,7 @@
<script setup>
import { dataUrl, glFor, exposeMap, setProjectionFor } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
/**
* pplr-rop-tree.vue — ROP hierarchy: Affinity Bloc → Cluster → People Group → PGIC.
*
@ -399,7 +400,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}

View file

@ -15,6 +15,7 @@
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -48,7 +49,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
map.value.on('load', async () => {

View file

@ -1,6 +1,7 @@
<script setup>
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { usePplrT } from '../composables/usePplrT.js'
import { tStatic } from '@map/i18n/index.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
@ -62,7 +63,7 @@ onMounted(async () => {
if (!map.value) {
// The map did not construct (e.g. provider=mapbox with no token; useMapInstance logs why). Say so
// and stop: everything below needs a map, and map.value.on() threw into the mounted hook.
status.value = t('messages.mapUnavailable')
status.value = tStatic('messages.mapUnavailable') // a shared key: usePplrT would prefix it with pplr.
return
}
if (map.value) {

View file

@ -0,0 +1,55 @@
# rudder-voice — Rudder, private voice-to-text (desktop target)
**Rudder is a plugin that isn't a map.** It shows that the same dx-maps pattern — one folder in
`plugin-designs/`, developed live in the Plugin Studio — can ship a **desktop app with its own local
backend**, not only embeddable maps. It is also a genuinely useful tool for the people who build here:
talk to your agents instead of typing, with nothing leaving your computer.
- **Streaming.** Words appear in the text box while you speak; talk for as long as you like.
- **Type and talk at once.** Type, paste links or code anywhere in the box; everything you say is
added to the end. While you're typing, Rudder holds its words and adds them when you pause.
- **CPU only.** No graphics card. About 1 GB of memory; keeps up with normal speech on a laptop CPU.
- **Offline, no telemetry.** NVIDIA Nemotron 3.5 ASR Streaming (0.6B) run by NVIDIA's open-source
NeMo-Speech.cpp (ggml). Traced with `strace` while transcribing: zero sockets, zero DNS lookups.
- **Microphones like a video-call app.** Pick the input, hot-plug updates, a 3-second mic test, and
plain-language errors (muted, disconnected, dead Bluetooth mic link — which it tries to recover).
## Run it in the Plugin Studio
```bash
bash backend/rudder-engine/setup-runtime.sh # once: runtime + model (verified by SHA-256) + engine build
bun run dev # starts the engine too; open "rudder-voice" in the studio
```
Linux with PipeWire or PulseAudio (`parec`, `pactl`). The engine listens on `127.0.0.1` only.
## How it fits together
```
<rudder-voice> (this folder) ──HTTP + SSE /api/rudder──► backend/rudder-engine (Go)
├─ parec / pw-record (mic, open only while recording)
└─ WebSocket ► nemo-speech serve (127.0.0.1, model stays loaded)
```
`bun run dev` starts `backend/bin/rudder-engine` through `internal/backend-dev-plugin.mjs` and forwards
`/api/rudder` to it, so the plugin calls the same API in the browser as it will inside the desktop app,
where Tauri runs the engine as a sidecar and passes its address in `profile-config` (`apiBase`).
`rudder-voice.meta.json` declares `"targets": ["desktop"]`: on a plain website there is no local engine,
so this plugin is not meant for web embeds.
## Settings (environment variables)
| Variable | Default | What it does |
|---|---|---|
| `RUDDER_PORT` | `5193` | engine HTTP port (loopback) |
| `RUDDER_NEMO_PORT` | `5194` | speech server port (loopback) |
| `RUDDER_MODEL` / `RUDDER_NEMO_BIN` | `~/.local/share/rudder/...` | model and runtime paths; the engine refuses a model that isn't a local file |
| `RUDDER_LANG` | `en-US` | default language (the model supports ~30) |
| `RUDDER_KEEP_MSBC` | unset | keep Bluetooth headsets on mSBC instead of switching them to the more stable CVSD |
| `RUDDER_BT_RESET` | unset | optional command that fully resets a Bluetooth headset, used when its mic link dies |
## Not yet
- Desktop build (`desktop:build`) with the engine as a Tauri sidecar.
- A saved word-correction table (PocketBase) for technical terms the model mishears.

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · rudder-voice staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 960px; margin: 0 auto; padding: 28px 20px 48px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; max-width: 820px; }
.sub code { color: #c4b5fd; }
.host { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
rudder-voice { display: block; width: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>rudder-voice · staging</h1>
<p class="sub">Desktop-target plugin: private, offline, streaming voice-to-text. The speech model runs in the
local backend <code>backend/rudder-engine</code> (NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR), started by
<code>bun run dev</code> and reached at <code>/api/rudder</code>.</p>
<div class="host">
<rudder-voice></rudder-voice>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
/**
* rudder-voice / index.js — Bundle entry (Capability-1 single element, DESKTOP target).
*
* <rudder-voice> — private, offline, streaming voice-to-text. The UI is this bundle; the speech model
* runs in the local backend (backend/rudder-engine), started by `bun run dev` in the Plugin Studio and
* by Tauri as a sidecar in the desktop app. See rudder-voice.vue for the contract.
*
* Desktop-only: rudder-voice.meta.json declares "targets": ["desktop"]. On a plain website there is no
* local engine, so the element shows "Engine offline" instead of pretending to work.
*
* Usage (desktop shell / Plugin Studio):
* <rudder-voice profile-config='{"apiBase":"http://127.0.0.1:5193/api/rudder"}'></rudder-voice>
*/
import { defineCustomElement } from 'vue'
import RudderVoice from './rudder-voice.vue'
const RudderVoiceElement = defineCustomElement(RudderVoice)
if (typeof customElements !== 'undefined') {
if (!customElements.get('rudder-voice')) customElements.define('rudder-voice', RudderVoiceElement)
}
export default RudderVoiceElement

View file

@ -0,0 +1,8 @@
{
"_comment": "Rudder — private, offline, streaming voice-to-text. DESKTOP target: the speech model runs in the local backend (backend/rudder-engine: NVIDIA NeMo-Speech.cpp + Nemotron 3.5 ASR GGUF), started by `bun run dev` and run by Tauri as a sidecar. Not for public web embeds: a website has no local engine.",
"type": "single",
"widget": { "corner": "bottom-right" },
"label": "Rudder — voice to text (desktop)",
"targets": ["desktop"],
"backend": "backend/rudder-engine"
}

View file

@ -0,0 +1,656 @@
<script setup>
/**
* rudder-voice.vue — Rudder: private, offline, streaming voice-to-text.
*
* @element rudder-voice
* @description A DESKTOP-target plugin (rudder-voice.meta.json "targets": ["desktop"]). The speech
* model runs in the local backend (backend/rudder-engine → NVIDIA NeMo-Speech.cpp + Nemotron 3.5
* ASR), never in the browser and never on a server. In the Plugin Studio, `bun run dev` starts that
* backend and forwards /api/rudder; in the desktop app, Tauri runs it as a sidecar and passes its
* address in `profile-config` {"apiBase": "http://127.0.0.1:<port>/api/rudder"}.
*
* Everything is agent-interfaceable (the old Rudder's prime directive): the 🐞 panel copies the
* engine log + status with a header telling an agent what to do with it.
*/
import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({ profileConfig: { type: String, default: '' } })
const cfg = computed(() => {
try { return props.profileConfig ? JSON.parse(props.profileConfig) : {} } catch { return {} }
})
const apiBase = computed(() => (cfg.value.apiBase || '/api/rudder').replace(/\/$/, ''))
const LANGUAGES = [
['en-US', 'English (US)'], ['en-GB', 'English (UK)'], ['es-US', 'Spanish (US)'], ['es-ES', 'Spanish (Spain)'],
['fr-FR', 'French'], ['de-DE', 'German'], ['it-IT', 'Italian'], ['pt-BR', 'Portuguese (Brazil)'],
['nl-NL', 'Dutch'], ['ru-RU', 'Russian'], ['uk-UA', 'Ukrainian'], ['tr-TR', 'Turkish'], ['ar-AR', 'Arabic'],
['hi-IN', 'Hindi'], ['ja-JP', 'Japanese'], ['ko-KR', 'Korean'], ['vi-VN', 'Vietnamese'], ['zh-CN', 'Mandarin'],
]
const STORE_KEY = 'rudder-voice:v1'
function load() {
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}') } catch { return {} }
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify({ language: language.value, device: device.value, draft: draft.value })) } catch {}
}
const saved = load()
const engine = ref('connecting') // connecting | starting | ready | error | offline
const engineError = ref('')
const runtime = ref('')
const model = ref('')
const recording = ref(false)
const busy = ref(false)
const language = ref(saved.language || cfg.value.language || 'en-US')
// The draft: a text area you type and paste into; every finished utterance is APPENDED to its end.
// (v1 kept a list of lines — migrate it so nothing already dictated is lost.)
const draft = ref(typeof saved.draft === 'string' ? saved.draft
: Array.isArray(saved.lines) ? saved.lines.map((l) => l.text).join(' ') : '')
const partial = ref('') // the utterance being heard right now (streamed into the box's live tail)
const box = ref(null)
const level = ref(0)
const notice = ref('')
const noticeKind = ref('info')
const copied = ref('')
const showDebug = ref(false)
const showInfo = ref(false)
const clientLog = ref([])
// ─── Microphones (the Jitsi device-selection pattern, served natively by the engine) ───
const devices = ref([]) // [{id,label,bus,default,muted,volume,state}]
const offCards = ref([]) // sound cards with a mic that are switched off (e.g. the laptop's own)
const deviceError = ref('')
const device = ref(saved.device || '') // '' = follow the system default input
const testing = ref(false)
const testResult = ref(null) // {ok, verdict, peak}
const BUS_ICON = { bluetooth: '🎧', usb: '🔌', pci: '💻' }
function deviceLabel(d) {
const bits = [(BUS_ICON[d.bus] || '🎙') + ' ' + d.label]
if (d.muted) bits.push('(muted)')
else if (d.volume) bits.push('· ' + d.volume)
return bits.join(' ')
}
const defaultDevice = computed(() => devices.value.find((d) => d.default))
const chosen = computed(() => devices.value.find((d) => d.id === device.value) || (device.value ? null : defaultDevice.value))
function applyDevices(list) {
if (!list) return
devices.value = list.devices || []
offCards.value = list.offCards || []
deviceError.value = list.error || ''
// A saved choice that is gone (headset switched off) falls back to the default, and says so.
if (device.value && !devices.value.some((d) => d.id === device.value)) {
if (!recording.value) {
say('Your chosen microphone is no longer connected — using the system default instead.', 'warn')
device.value = ''
save()
}
}
}
async function refreshDevices() {
try { applyDevices(await (await fetch(apiBase.value + '/devices')).json()) } catch (err) { logc('devices fetch failed: ' + err) }
}
async function testMic() {
if (testing.value || recording.value) return
testing.value = true
testResult.value = null
try {
testResult.value = await post('/test', { device: device.value })
} catch (err) {
testResult.value = { ok: false, verdict: String(err.message || err) }
} finally {
testing.value = false
level.value = 0
}
}
let es = null
let retry = null
function logc(msg) {
clientLog.value.push(new Date().toISOString().slice(11, 23) + ' ' + msg)
if (clientLog.value.length > 200) clientLog.value.splice(0, clientLog.value.length - 200)
}
function say(msg, kind = 'info') { notice.value = msg; noticeKind.value = kind }
// ─── Writing into the box ────────────────────────────────────────────────────────────────────────
// Every word streams straight into the END of the box while you keep typing, pasting and editing
// anywhere. The sentence in progress lives in a "live tail" — the last `live.text.length` characters
// of the draft — and each new word rewrites only that tail.
//
// ★ TYPING WHILE TALKING (bug fixed 2026-09-27): once you type into or after the tail it is yours, and
// Rudder must continue with ONLY the words it hasn't written yet. v1 re-wrote the whole sentence-so-far
// after every keystroke, so each key press duplicated the utterance. Now `written` counts the words of
// this utterance already in the box, and the finished (punctuated) sentence is lined up word-by-word
// against them, so nothing is ever written twice.
let live = null // { start, text, shown } — the tail Rudder may still rewrite; shown = words in it
let written = 0 // words of the current utterance committed to the box before the current tail
// The tail is still Rudder's while the box ENDS with it — wherever it now starts. Typing or pasting
// anywhere above it shifts its position, and that must not count as touching it (bug 2026-09-27: a
// position-based check released the tail on every keystroke above it, cutting "speaking" to "spe").
function liveIntact() {
if (!live || !draft.value.endsWith(live.text)) return false
live.start = draft.value.length - live.text.length
return true
}
// Change only the end of the draft, keeping the person's caret/selection and scroll where they were.
function writeEnd(next) {
const el = box.value
const focused = el && el.getRootNode().activeElement === el
const selStart = el ? el.selectionStart : 0
const selEnd = el ? el.selectionEnd : 0
const atBottom = !el || el.scrollHeight - el.scrollTop - el.clientHeight < 32
draft.value = next
save()
nextTick(() => {
if (!el) return
if (focused) el.setSelectionRange(Math.min(selStart, next.length), Math.min(selEnd, next.length))
if (atBottom || !focused) el.scrollTop = el.scrollHeight
})
}
const sepFor = (text) => (!text || /\s$/.test(text) ? '' : ' ')
const wordsOf = (text) => (text || '').trim().split(/\s+/).filter(Boolean)
// The person typed into or after the tail, so it's theirs now. Count its words as written — except a
// HALF-FINISHED last word: the model streams words in pieces ("for" … "tnight"), and typing between the
// pieces used to freeze "for" and lose the rest (measured 2026-09-27: "for a for [typed]"). If the word
// has since grown, the fragment is removed (it sits just before the person's text; their caret is
// shifted so it stays put relative to what they typed) and the whole word is written after it.
const norm = (x) => (x || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '')
function releaseTail(utteranceWords) {
const shownWords = wordsOf(live.text)
const lastShown = shownWords[shownWords.length - 1] || ''
const nowWord = utteranceWords[written + live.shown - 1]
const grew = live.shown > 0 && nowWord !== undefined && norm(nowWord) !== norm(lastShown) && norm(nowWord).startsWith(norm(lastShown))
// Find the tail by its text: typing above it may have moved it since it was written.
const found = draft.value.lastIndexOf(live.text)
if (found >= 0) live.start = found
const tailEnd = live.start + live.text.length
const prefixOk = found >= 0
if (grew && prefixOk && lastShown) {
let fragStart = tailEnd - lastShown.length
if (fragStart > live.start && draft.value[fragStart - 1] === ' ') fragStart--
const cut = tailEnd - fragStart
const el = box.value
const focused = el && el.getRootNode().activeElement === el
const shift = (i) => (i >= tailEnd ? i - cut : i > fragStart ? fragStart : i)
const a = el ? shift(el.selectionStart) : 0
const b = el ? shift(el.selectionEnd) : 0
draft.value = draft.value.slice(0, fragStart) + draft.value.slice(tailEnd)
if (focused) nextTick(() => el.setSelectionRange(a, b))
written += live.shown - 1
} else {
written += live.shown
}
live = null
}
// Write `words` as the live tail: rewrite it if it's still ours, otherwise open a new one at the end.
function writeTail(words, utterance) {
const t = words.join(' ')
if (liveIntact()) {
const lead = live.text.match(/^\s*/)[0]
live.text = t ? lead + t : ''
live.shown = words.length
writeEnd(draft.value.slice(0, live.start) + live.text)
return
}
if (live) releaseTail(utterance) // the person took over the old tail: keep it
if (!t) return
live = { start: draft.value.length, text: sepFor(draft.value) + t, shown: words.length }
writeEnd(draft.value + live.text)
}
// ─── Hold the stream while the person is typing ─────────────────────────────────────────────────
// Typing and streaming both write at the end of the box; a word landing between two keystrokes splices
// into what is being typed (measured 2026-09-27: "he'[typed]ll"). So while keys are being pressed the
// stream is buffered, and flushed — in order, nothing dropped — 700 ms after the last keystroke.
const TYPING_HOLD_MS = 700
let lastTypedAt = 0
let pendingPartial = null
const pendingFinals = []
let flushTimer = null
function markTyping() { lastTypedAt = Date.now() }
function onUserInput() {
markTyping()
save()
}
function typingNow() { return Date.now() - lastTypedAt < TYPING_HOLD_MS }
function scheduleFlush() {
clearTimeout(flushTimer)
flushTimer = setTimeout(() => {
if (typingNow()) return scheduleFlush()
const finals = pendingFinals.splice(0)
const p = pendingPartial
pendingPartial = null
for (const f of finals) finishUtterance(f)
if (p !== null) streamPartial(p)
}, TYPING_HOLD_MS)
}
function onPartial(full) {
if (typingNow() || pendingFinals.length) { pendingPartial = full; scheduleFlush(); return }
streamPartial(full)
}
function onFinal(text) {
if (typingNow() || pendingFinals.length) { pendingFinals.push(text); pendingPartial = null; scheduleFlush(); return }
finishUtterance(text)
}
// A word arrived: show the words of this utterance that aren't in the box yet.
function streamPartial(full) {
const w = wordsOf(full)
if (live && !liveIntact()) releaseTail(w)
writeTail(w.slice(written), w)
}
// The sentence is finished: the same span of words, now punctuated and capitalised.
function finishUtterance(text) {
const w = wordsOf(text)
if (live && !liveIntact()) releaseTail(w)
writeTail(w.slice(written), w)
live = null
written = 0
}
function applyStatus(s) {
if (!s) return
if (s.engine) engine.value = s.engine
engineError.value = s.error || ''
if (s.runtime) runtime.value = s.runtime
if (s.model) model.value = s.model
if (typeof s.recording === 'boolean') recording.value = s.recording
}
function connect() {
clearTimeout(retry)
try { es?.close() } catch {}
// No EventSource (a headless mount, an old WebView): show the engine-not-running state instead of throwing
// in the mounted hook, which failed check:profiles-mount for every lane (go-int, END-DXS-ESRC).
if (typeof window === 'undefined' || typeof window.EventSource !== 'function') {
engineError.value = 'The desktop engine is not running.'
return
}
es = new window.EventSource(apiBase.value + '/events')
es.onopen = () => logc('events connected')
es.onmessage = (m) => {
let e
try { e = JSON.parse(m.data) } catch { return }
switch (e.type) {
case 'status':
applyStatus(e.status || e)
if (e.engine === 'error') say(e.error || 'Speech engine error', 'error')
if (e.engine === 'ready' && noticeKind.value !== 'error') say('')
break
case 'recording':
recording.value = !!e.recording
if (!e.recording) { level.value = 0; flushPartial() }
break
case 'partial':
partial.value += e.text
onPartial(partial.value)
break
case 'final':
partial.value = ''
onFinal(e.text)
break
case 'recovering':
say(e.message, 'warn')
break
case 'recovered':
say(e.message, 'ok')
setTimeout(() => { if (noticeKind.value === 'ok') say('') }, 4000)
break
case 'devices':
applyDevices(e.devices)
break
case 'level':
level.value = Math.min(1, (e.peak || 0) * 3)
break
case 'warning':
say(e.message, 'warn')
break
case 'error':
say(e.message, 'error')
logc('engine error: ' + e.message)
break
}
}
es.onerror = () => {
engine.value = 'offline'
recording.value = false
logc('events disconnected; retrying in 3s')
try { es.close() } catch {}
retry = setTimeout(connect, 3000)
}
}
// Recording stopped mid-sentence: the streamed words are already in the box, so just let go of them.
function flushPartial() {
clearTimeout(flushTimer)
const finals = pendingFinals.splice(0)
const p = pendingPartial
pendingPartial = null
for (const f of finals) finishUtterance(f)
if (p !== null) streamPartial(p)
partial.value = ''
live = null
written = 0
written = 0
}
async function post(path, body) {
const r = await fetch(apiBase.value + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}),
})
const j = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`)
return j
}
async function toggle() {
if (busy.value) return
busy.value = true
try {
if (recording.value) {
await post('/stop')
} else {
say('')
testResult.value = null
applyStatus(await post('/start', { language: language.value, device: device.value }))
}
} catch (err) {
say(String(err.message || err), 'error')
logc('toggle failed: ' + err)
} finally {
busy.value = false
}
}
const words = computed(() => (draft.value.trim() ? draft.value.trim().split(/\s+/).length : 0))
async function copy(text, what) {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text; document.body.appendChild(ta); ta.select()
try { document.execCommand('copy') } catch {}
ta.remove()
}
copied.value = what
setTimeout(() => { if (copied.value === what) copied.value = '' }, 1500)
}
function clearAll() {
if (draft.value.trim() && !window.confirm('Clear the whole text box? This cannot be undone.')) return
draft.value = ''
partial.value = ''
live = null
save()
}
// ★ PRIVACY: the report is meant to be pasted to an agent, so it describes the setup without
// identifying the person: no browser fingerprint (user-agent), no device names ("Anna's AirPods"), no
// device IDs (they embed the Bluetooth MAC address), and never the text in the box — only its length.
// The engine log it includes is already redacted at the source (MACs, home folder).
const MAC_RE = /\b([0-9a-f]{2}[:_]){5}[0-9a-f]{2}\b/gi
function privateDevices() {
return devices.value.map((d, i) => ({ mic: 'mic ' + (i + 1), bus: d.bus, default: d.default, muted: d.muted, volume: d.volume, state: d.state }))
}
function browserFamily() {
const ua = navigator.userAgent
return /firefox/i.test(ua) ? 'Firefox' : /edg\//i.test(ua) ? 'Edge' : /chrom/i.test(ua) ? 'Chromium-based' : /safari/i.test(ua) ? 'Safari' : 'other'
}
async function copyDebug() {
let engineLog = '(unreachable)'
let status = '(unreachable)'
try { engineLog = await (await fetch(apiBase.value + '/log')).text() } catch {}
try { status = JSON.stringify(await (await fetch(apiBase.value + '/status')).json(), null, 2) } catch {}
const text = [
'// RUDDER DEBUG REPORT — for a coding agent.',
'// Read the engine status and log below, find why transcription failed or misbehaved, and propose a fix',
'// in backend/rudder-engine/main.go or plugin-designs/rudder-voice/rudder-voice.vue. Do not guess:',
'// quote the log lines that prove the cause.',
'',
'== status ==', status,
'== plugin ==', JSON.stringify({ apiBase: apiBase.value, draftChars: draft.value.length, engine: engine.value, recording: recording.value, language: language.value, device: device.value ? 'chosen mic' : '(system default)', browser: browserFamily() }, null, 2),
'== microphones ==', JSON.stringify({ devices: privateDevices(), offCards: offCards.value.length, error: deviceError.value, lastTest: testResult.value }, null, 2),
'== client log ==', clientLog.value.join('\n'),
'== engine log ==', engineLog,
].join('\n').replace(MAC_RE, 'XX:XX:XX:XX:XX:XX')
copy(text, 'debug')
}
function onKey(ev) {
// Ctrl+Shift+Space toggles recording without reaching for the mouse.
if (ev.ctrlKey && ev.shiftKey && ev.code === 'Space') { ev.preventDefault(); toggle() }
}
onMounted(() => {
connect()
refreshDevices()
window.addEventListener('keydown', onKey)
})
onBeforeUnmount(() => {
clearTimeout(retry)
try { es?.close() } catch {}
window.removeEventListener('keydown', onKey)
})
const statusText = computed(() => ({
connecting: 'Connecting…',
starting: 'Loading speech model… (about 20 s the first time)',
ready: recording.value ? 'Listening' : 'Ready',
error: 'Engine error',
offline: 'Engine offline',
}[engine.value] || engine.value))
</script>
<template>
<div class="rudder" :class="{ rec: recording }">
<header class="top">
<div class="brand">
<span class="logo" aria-hidden="true">⎈</span>
<div>
<h1>Rudder</h1>
<p class="sub">Voice-to-text for vibe coders · private · runs on this computer · no internet</p>
</div>
</div>
<button class="info-btn" :aria-expanded="showInfo" title="What is Rudder?" aria-label="What is Rudder?" @click="showInfo = !showInfo">ⓘ</button>
<div class="status" :data-state="engine" role="status">
<span class="dot" aria-hidden="true"></span>{{ statusText }}
</div>
</header>
<section v-if="showInfo" class="info">
<h2>What is Rudder?</h2>
<p>A voice-to-text tool for vibe coders: talk to your agents instead of typing to them. Paste links, code
and notes into the box while you talk; everything you say is added to the end, ready to copy into a chat.</p>
<ul>
<li><b>Streaming.</b> Words appear while you speak, and you can keep talking for as long as you like —
an hour is fine. There is no time limit and nothing to wait for at the end.</li>
<li><b>Runs on your CPU.</b> No graphics card needed. The model uses about 1 GB of memory and keeps up with
normal speech on an ordinary laptop processor.</li>
<li><b>Private and offline.</b> Your voice never leaves this computer. No account, no cloud, no telemetry —
checked with a network tracer: zero connections while transcribing.</li>
<li><b>The model.</b> NVIDIA Nemotron 3.5 ASR Streaming (0.6B), run by NVIDIA's open-source
NeMo-Speech.cpp. It adds punctuation and capital letters by itself.</li>
<li><b>Why it lives in dx-maps.</b> Rudder is a plugin like the maps: built in the Plugin Studio, tested in the
browser, and shipped as a desktop app with its own local backend — proof that one dx-maps codebase can
build desktop apps, not only maps.</li>
</ul>
<p class="fine">Bluetooth headsets: Rudder switches them to their stable voice mode (CVSD), because the
higher-quality mode (mSBC) kept dropping the mic after a few words.</p>
</section>
<section class="controls">
<button
class="mic"
:class="{ on: recording }"
:disabled="busy || engine !== 'ready'"
:aria-pressed="recording"
:aria-label="recording ? 'Stop recording' : 'Start recording'"
@click="toggle"
>
<span class="mic-icon" aria-hidden="true">{{ recording ? '■' : '●' }}</span>
{{ recording ? 'Stop' : 'Record' }}
</button>
<div class="meter" aria-hidden="true"><div class="fill" :style="{ width: (level * 100).toFixed(0) + '%' }"></div></div>
<label class="lang">
<span>Language</span>
<select v-model="language" :disabled="recording" @change="save">
<option v-for="[code, name] in LANGUAGES" :key="code" :value="code">{{ name }}</option>
</select>
</label>
</section>
<section class="mics">
<label class="mic-pick">
<span>Microphone</span>
<select v-model="device" :disabled="recording || testing" @change="save(); testResult = null">
<option value="">System default{{ defaultDevice ? ' — ' + defaultDevice.label : '' }}</option>
<option v-for="d in devices" :key="d.id" :value="d.id">{{ deviceLabel(d) }}</option>
</select>
</label>
<button class="small" :disabled="testing || recording || engine === 'offline'" @click="testMic">
{{ testing ? 'Testing… speak now' : 'Test mic' }}
</button>
<button class="small ghost" title="Look for microphones again" aria-label="Look for microphones again" :disabled="recording" @click="refreshDevices">↻</button>
</section>
<p v-if="testResult" class="notice" :data-kind="testResult.ok ? (testResult.peak < 0.01 ? 'warn' : 'ok') : 'error'">
{{ testResult.ok ? '✓ ' : '✗ ' }}{{ testResult.verdict }}
</p>
<p v-if="chosen && chosen.muted" class="notice" data-kind="warn">“{{ chosen.label }}” is muted in your system sound settings — Rudder won't hear anything until it's unmuted.</p>
<p v-if="deviceError" class="notice" data-kind="error">{{ deviceError }}</p>
<p v-else-if="!devices.length && engine !== 'offline'" class="notice" data-kind="warn">No microphone found. Connect a headset or turn on your computer's microphone in Sound settings.</p>
<p v-if="offCards.length" class="hint">Also available but switched off in Sound settings: {{ offCards.join(', ') }}. Turn it on there to use it here.</p>
<p v-if="notice" class="notice" :data-kind="noticeKind">{{ notice }}</p>
<p v-else-if="engine === 'offline'" class="notice" data-kind="warn">
The local speech engine isn't reachable at <code>{{ apiBase }}</code>. In the Plugin Studio it starts with
<code>bun run dev</code> once built: <code>cd backend/rudder-engine &amp;&amp; go build -o ../bin/rudder-engine .</code>
</p>
<section class="compose">
<textarea
ref="box"
v-model="draft"
class="draft"
spellcheck="true"
aria-label="Your text. Type or paste here; everything Rudder hears is added to the end."
placeholder="Type or paste here — and press Record (Ctrl+Shift+Space) to talk. Your words stream into the end of this box as you speak."
@input="onUserInput"
@keydown="markTyping"
></textarea>
<p class="hearing" aria-live="polite">
<template v-if="recording"><span class="tag">listening</span> words appear at the end of the box as you speak — keep typing or pasting anywhere</template>
</p>
</section>
<footer class="bottom">
<button :disabled="!draft.trim()" @click="copy(draft, 'all')">{{ copied === 'all' ? 'Copied ✓' : 'Copy all' }}</button>
<button :disabled="!draft.trim() || recording" @click="clearAll">Clear</button>
<span class="count">{{ words }} {{ words === 1 ? 'word' : 'words' }}</span>
<span class="spacer"></span>
<button class="ghost" :aria-expanded="showDebug" @click="showDebug = !showDebug">🐞 Debug</button>
</footer>
<section v-if="showDebug" class="debug">
<dl>
<dt>API</dt><dd><code>{{ apiBase }}</code></dd>
<dt>Runtime</dt><dd>{{ runtime || '—' }}</dd>
<dt>Model</dt><dd>{{ model || '—' }}</dd>
<dt>Engine</dt><dd>{{ engine }}<span v-if="engineError"> — {{ engineError }}</span></dd>
</dl>
<button @click="copyDebug">{{ copied === 'debug' ? 'Copied ✓' : 'Copy debug report for an agent' }}</button>
</section>
</div>
</template>
<style>
:host { display: block; }
.rudder {
--bg: #0b0f14; --panel: #111823; --line: #1f2937; --text: #e5e7eb; --muted: #9ca3af;
--accent: #38bdf8; --rec: #ef4444; --ok: #22c55e; --warn: #f59e0b;
box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; min-height: 480px; height: 100%;
padding: 18px; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-size: 15px;
}
.rudder *, .rudder *::before, .rudder *::after { box-sizing: border-box; }
.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 12px; }
.logo { font-size: 30px; color: var(--accent); line-height: 1; }
h1 { margin: 0; font-size: 20px; letter-spacing: -0.01em; }
.sub { margin: 2px 0 0; color: var(--muted); font-size: 12.5px; }
.status { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid var(--line);
border-radius: 999px; font-size: 13px; color: var(--muted); background: var(--panel); }
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.status[data-state='ready'] .dot { background: var(--ok); }
.status[data-state='starting'] .dot, .status[data-state='connecting'] .dot { background: var(--warn); animation: pulse 1.2s infinite; }
.status[data-state='error'] .dot, .status[data-state='offline'] .dot { background: var(--rec); }
.rec .status .dot { background: var(--rec); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.mic { display: inline-flex; align-items: center; gap: 10px; padding: 12px 22px; border-radius: 999px;
border: 1px solid var(--line); background: var(--accent); color: #04121c; font-weight: 700; font-size: 16px; cursor: pointer; }
.mic.on { background: var(--rec); color: #fff; }
.mic:disabled { opacity: .45; cursor: not-allowed; }
.mic:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.mic-icon { font-size: 14px; }
.meter { flex: 1 1 140px; min-width: 100px; height: 8px; border-radius: 4px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; }
.meter .fill { height: 100%; background: linear-gradient(90deg, var(--ok), var(--warn), var(--rec)); transition: width .12s linear; }
.lang { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
select { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; font-size: 14px; }
.notice { margin: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); font-size: 13.5px; line-height: 1.45; }
.notice[data-kind='error'] { border-color: #7f1d1d; background: #1f0f12; }
.notice[data-kind='warn'] { border-color: #78350f; background: #1c140a; }
.notice[data-kind='ok'] { border-color: #14532d; background: #0b1a11; }
.mics { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.mic-pick { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; flex: 1 1 260px; }
.mic-pick select { flex: 1; min-width: 0; }
.small { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13.5px; }
.small:disabled { opacity: .45; cursor: not-allowed; }
.small.ghost { background: transparent; }
.hint { margin: 0; color: var(--muted); font-size: 12.5px; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; color: #c4b5fd; overflow-wrap: anywhere; }
.compose { flex: 1 1 auto; display: flex; flex-direction: column; gap: 6px; min-height: 260px; }
.draft { flex: 1 1 auto; width: 100%; min-height: 240px; resize: vertical; padding: 14px; border: 1px solid var(--line);
border-radius: 12px; background: var(--panel); color: var(--text); font: inherit; font-size: 16px; line-height: 1.6; }
.draft:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.draft::placeholder { color: #6b7280; }
.hearing { margin: 0; min-height: 1.5em; color: var(--accent); font-size: 14px; overflow-wrap: anywhere; }
.hearing .tag { display: inline-block; margin-right: 6px; padding: 1px 7px; border-radius: 999px; background: #0c2a3a;
color: var(--accent); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.count { color: var(--muted); font-size: 12.5px; }
.bottom { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.bottom button, .debug button { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 14px; }
.bottom button:disabled { opacity: .45; cursor: not-allowed; }
.bottom .ghost { background: transparent; }
.spacer { flex: 1; }
.info-btn { margin-left: auto; background: transparent; border: 1px solid var(--line); color: var(--muted); border-radius: 999px;
width: 30px; height: 30px; font-size: 16px; cursor: pointer; }
.info-btn:hover, .info-btn[aria-expanded='true'] { color: var(--accent); border-color: var(--accent); }
.info { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); padding: 14px 16px; font-size: 14px; line-height: 1.55; }
.info h2 { margin: 0 0 6px; font-size: 16px; }
.info p { margin: 0 0 8px; }
.info ul { margin: 0 0 8px; padding-left: 18px; }
.info li { margin-bottom: 6px; }
.info .fine { color: var(--muted); font-size: 12.5px; margin: 0; }
.debug { border: 1px dashed var(--line); border-radius: 10px; padding: 12px; font-size: 13px; }
.debug dl { display: grid; grid-template-columns: 90px 1fr; gap: 6px 10px; margin: 0 0 10px; }
.debug dt { color: var(--muted); }
.debug dd { margin: 0; overflow-wrap: anywhere; }
</style>

View file

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

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-xpQXT3X2W81IPWaSXifrP9kXGf/nbU8hwT5ZWMQjH0EwDWHMJeAiWj+yEuAi+KBx" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-ZDia7fZFwFRQlQ9EaeWXDOxKbMC7Uv9zMg+k43Ok4UhWgOjDbPxW9JJd+GEivhyr" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../demo-map.js" integrity="sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../../demo-map.js" integrity="sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 24,
"profileCount": 36,
"bundleCount": 25,
"profileCount": 37,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -12,7 +12,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-xpQXT3X2W81IPWaSXifrP9kXGf/nbU8hwT5ZWMQjH0EwDWHMJeAiWj+yEuAi+KBx",
"integrity": "sha384-ZDia7fZFwFRQlQ9EaeWXDOxKbMC7Uv9zMg+k43Ok4UhWgOjDbPxW9JJd+GEivhyr",
"elements": [
"all-people-groups"
],
@ -30,7 +30,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-xpQXT3X2W81IPWaSXifrP9kXGf/nbU8hwT5ZWMQjH0EwDWHMJeAiWj+yEuAi+KBx\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-ZDia7fZFwFRQlQ9EaeWXDOxKbMC7Uv9zMg+k43Ok4UhWgOjDbPxW9JJd+GEivhyr\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -165,7 +165,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "dx-maps/country-posters/country-posters.js",
"integrity": "sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd",
"integrity": "sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI",
"elements": [
"country-posters-map"
],
@ -187,7 +187,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +202,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -217,7 +217,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -226,7 +226,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw",
"integrity": "sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv",
"elements": [
"dashboard-plus-grid"
],
@ -248,7 +248,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -272,7 +272,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "dx-maps/demo-map/demo-map.js",
"integrity": "sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6",
"integrity": "sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG",
"elements": [
"demo-map"
],
@ -290,7 +290,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -371,7 +371,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7",
"integrity": "sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n",
"elements": [
"doxa-research-map"
],
@ -389,7 +389,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -415,7 +415,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn",
"integrity": "sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -434,7 +434,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq",
"integrity": "sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -723,7 +723,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq",
"integrity": "sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR",
"elements": [
"gospel-resources-map"
],
@ -745,7 +745,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -760,7 +760,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -775,7 +775,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -790,7 +790,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -805,7 +805,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -814,7 +814,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR",
"integrity": "sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU",
"elements": [
"my-upg-100-list-map"
],
@ -836,7 +836,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -851,7 +851,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -866,7 +866,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -938,7 +938,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "dx-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX",
"integrity": "sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd",
"elements": [
"plugin-studio"
],
@ -968,7 +968,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{
@ -995,7 +995,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-idrM+onVQ8aeioB8d67pktceaA4tHSoJCH3OX1eHhr5YHoI+X6/JSePapupxL9Rc",
"integrity": "sha384-ITBwAAzT9m4q3VvhBRMSwYGuJlTi/SFb5K3fyqqhPaa9FdbafyIPbI1Iu/YtWbae",
"elements": [
"pplr-data-maps"
],
@ -1013,7 +1013,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-idrM+onVQ8aeioB8d67pktceaA4tHSoJCH3OX1eHhr5YHoI+X6/JSePapupxL9Rc\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-ITBwAAzT9m4q3VvhBRMSwYGuJlTi/SFb5K3fyqqhPaa9FdbafyIPbI1Iu/YtWbae\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}
@ -1023,7 +1023,7 @@
"bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "dx-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-zEGH2wLAtRReRGWJJcYWgfxVmjx/cmNBh1HWp9EoO77UdnIdaB+SGDQJ5P1cl7/N",
"integrity": "sha384-vDE4FMYhrLgkHnj6K2nRGEbLyf8EyuV7OUo+F93bGd00+/F+MRpev49x/iXsllFq",
"elements": [
"prayer-walk-planner"
],
@ -1041,7 +1041,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-zEGH2wLAtRReRGWJJcYWgfxVmjx/cmNBh1HWp9EoO77UdnIdaB+SGDQJ5P1cl7/N\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-vDE4FMYhrLgkHnj6K2nRGEbLyf8EyuV7OUo+F93bGd00+/F+MRpev49x/iXsllFq\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
}
}
]
@ -1050,7 +1050,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q",
"integrity": "sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ",
"elements": [
"regions-dashboard"
],
@ -1072,7 +1072,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1087,7 +1087,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1102,7 +1102,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]
@ -1111,7 +1111,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-GBlVmWSyzwQX0ty93zufhUFNTSJoDn8RF+7ywLJ0Q5sx5RLUoC2yt7W/XPtuX7LB",
"integrity": "sha384-Oqb5Jww1i+G5kkTq8Bggw/DrP3F8T34jIRCgvR80AkiMJ3/WskZKVJl2M64iRwOe",
"elements": [
"research-dashboard-map"
],
@ -1129,7 +1129,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-GBlVmWSyzwQX0ty93zufhUFNTSJoDn8RF+7ywLJ0Q5sx5RLUoC2yt7W/XPtuX7LB\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-Oqb5Jww1i+G5kkTq8Bggw/DrP3F8T34jIRCgvR80AkiMJ3/WskZKVJl2M64iRwOe\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}
@ -1181,6 +1181,36 @@
}
]
},
{
"bundle": "rudder-voice",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"output": "dx-maps/rudder-voice/rudder-voice.js",
"integrity": "sha384-Pb1pQJqoB3wMTum8vw0uUrm7e74j90ytj1hNdIl/1HbRlfy1YoT0hf6ggWQ3qDNO",
"elements": [
"rudder-voice"
],
"stage": "experimental",
"profiles": [
{
"profile": "rudder-voice",
"stage": "experimental",
"widget": {
"corner": "bottom-right"
},
"file": "rudder-voice.vue",
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
"element": "rudder-voice",
"type": "single",
"isMap": false,
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"embed": {
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-Pb1pQJqoB3wMTum8vw0uUrm7e74j90ytj1hNdIl/1HbRlfy1YoT0hf6ggWQ3qDNO\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
}
}
]
},
{
"bundle": "stack-demo",
"scriptUrl": "./stack-demo/stack-demo.js",
@ -1231,7 +1261,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-D2e4OB60VjRAhfnyVG192dx3hxC9tfP1WQMu1E+JcINQ2XRO7tCUhXXNZBMoQrD+",
"integrity": "sha384-X6YaUyV+9g7wppNaQ+YWgER7pRrk8y4egSZypQF8+2lVYRith+nPvkpiI77Oh6p3",
"elements": [
"upg-neighbor"
],
@ -1249,7 +1279,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-D2e4OB60VjRAhfnyVG192dx3hxC9tfP1WQMu1E+JcINQ2XRO7tCUhXXNZBMoQrD+\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-X6YaUyV+9g7wppNaQ+YWgER7pRrk8y4egSZypQF8+2lVYRith+nPvkpiI77Oh6p3\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../doxa-simple-map.js" integrity="sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-internet-board> custom element. -->
<script src="../../gospel-internet.js" integrity="sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq" crossorigin="anonymous"></script>
<script src="../../gospel-internet.js" integrity="sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -19,6 +19,6 @@
</head>
<body>
<plugin-studio tree-base="."></plugin-studio>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX" crossorigin="anonymous"></script>
<script src="./plugin-studio/plugin-studio.js" integrity="sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd" crossorigin="anonymous"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -2,8 +2,8 @@
"schema": "doxa-maps-manifest@1",
"generatedFrom": "plugin-designs/*/*.vue",
"embedBase": ".",
"bundleCount": 24,
"profileCount": 36,
"bundleCount": 25,
"profileCount": 37,
"runtime": {
"apiBaseUrl": "https://pray.doxa.life"
},
@ -12,7 +12,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-xpQXT3X2W81IPWaSXifrP9kXGf/nbU8hwT5ZWMQjH0EwDWHMJeAiWj+yEuAi+KBx",
"integrity": "sha384-ZDia7fZFwFRQlQ9EaeWXDOxKbMC7Uv9zMg+k43Ok4UhWgOjDbPxW9JJd+GEivhyr",
"elements": [
"all-people-groups"
],
@ -30,7 +30,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-xpQXT3X2W81IPWaSXifrP9kXGf/nbU8hwT5ZWMQjH0EwDWHMJeAiWj+yEuAi+KBx\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-ZDia7fZFwFRQlQ9EaeWXDOxKbMC7Uv9zMg+k43Ok4UhWgOjDbPxW9JJd+GEivhyr\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
},
"bundle": "all-people-groups"
}
@ -165,7 +165,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "dx-maps/country-posters/country-posters.js",
"integrity": "sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd",
"integrity": "sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI",
"elements": [
"country-posters-map"
],
@ -187,7 +187,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -202,7 +202,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -217,7 +217,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-gWxzvc16MGYwzrZ6Z/XBGaPkI5Ezh45OMepMoAXMj/USGmin8hgMCR7bVhVa5Bcd\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-kkdmZjOS8KJTTdcUmnMU5bpeVJZBMPFw4mb1rdZ3K2dacuRGxLSiCH4L7d6BN8dI\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -226,7 +226,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw",
"integrity": "sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv",
"elements": [
"dashboard-plus-grid"
],
@ -248,7 +248,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-gLs4MQRznMyg9cKyDneIKEkjCDfd/Buvv+G8Xwwn+ggxaKshHtD1zKO658e8L6pw\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-G2cfmNzjdjkx6ZLplzjiMJ/zUO5UzGNUprk+Uo409T5tqQkFk7qD4RkFsn4mzOZv\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
},
"instances": [
{
@ -272,7 +272,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "dx-maps/demo-map/demo-map.js",
"integrity": "sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6",
"integrity": "sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG",
"elements": [
"demo-map"
],
@ -290,7 +290,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-IVZC654ptY88sI56Xw0Mlmc58TNBXDzTR2+WrGRwv5LYPALSneviSCNrpg61Wzi6\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./demo-map/demo-map.js\" integrity=\"sha384-Q3BuUcCOHvp7ko/LLlos7iF71x6bq4+TjfYQjbgCl2dynmwe7h7yCO94yNUdjoOG\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -371,7 +371,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7",
"integrity": "sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n",
"elements": [
"doxa-research-map"
],
@ -389,7 +389,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-QF6F4GttfIpkev5vLjQn5SftilFmw7HksE+bvoYu5WccjETMjVxPoeqEB3wqmGh7\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-yfb9suM6BjJ8npI9/Ok9mmEUDxxuWUqB6gXCB33cCgLMfOeDtGwszF+fJiwAw//n\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -415,7 +415,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn",
"integrity": "sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -434,7 +434,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-qz7rw+kKZQs04E7Am98uyFYO8TfhD8uGy73NUFfbiR7Nag2rON9XEyjzgeg6c6Yn\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-bPFak/dvNd3d7uNUAJfMGiZudEOZlEM26mVv3IiEIN4Xi/y+mc9P36HhXV3r1O79\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -604,7 +604,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq",
"integrity": "sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm",
"elements": [
"gospel-internet-board"
],
@ -623,7 +623,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-oCqqOQ/nxQoV8hoX75YIVe3DBUOC+yWpy6TGCCsmoN6iNzctsiO8tR2FklINZLnq\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-internet/gospel-internet.js\" integrity=\"sha384-FNeWKsULwwuFfsy7c6d5c7K9mi9MTGUDIJablvw48K8xMzkfcvQJUrjRvIta6klm\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -723,7 +723,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq",
"integrity": "sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR",
"elements": [
"gospel-resources-map"
],
@ -745,7 +745,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -760,7 +760,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -775,7 +775,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -790,7 +790,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -805,7 +805,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-heRNvaCP4oO4GsoQg8gWD5/mXBkUm6d1CEVUqyzJ/Jh3Mr50waoiOEp4RCn6CfEq\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-2FCDJcK//gT/pWmCpB50HlBexpcO3SdbRlATTvtSQGWCEYohocvr9Hvt2U2hbCNR\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -814,7 +814,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR",
"integrity": "sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU",
"elements": [
"my-upg-100-list-map"
],
@ -836,7 +836,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -851,7 +851,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -866,7 +866,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]
@ -938,7 +938,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "dx-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX",
"integrity": "sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd",
"elements": [
"plugin-studio"
],
@ -968,7 +968,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{
@ -995,7 +995,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-idrM+onVQ8aeioB8d67pktceaA4tHSoJCH3OX1eHhr5YHoI+X6/JSePapupxL9Rc",
"integrity": "sha384-ITBwAAzT9m4q3VvhBRMSwYGuJlTi/SFb5K3fyqqhPaa9FdbafyIPbI1Iu/YtWbae",
"elements": [
"pplr-data-maps"
],
@ -1013,7 +1013,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-idrM+onVQ8aeioB8d67pktceaA4tHSoJCH3OX1eHhr5YHoI+X6/JSePapupxL9Rc\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-ITBwAAzT9m4q3VvhBRMSwYGuJlTi/SFb5K3fyqqhPaa9FdbafyIPbI1Iu/YtWbae\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}
@ -1023,7 +1023,7 @@
"bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "dx-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-zEGH2wLAtRReRGWJJcYWgfxVmjx/cmNBh1HWp9EoO77UdnIdaB+SGDQJ5P1cl7/N",
"integrity": "sha384-vDE4FMYhrLgkHnj6K2nRGEbLyf8EyuV7OUo+F93bGd00+/F+MRpev49x/iXsllFq",
"elements": [
"prayer-walk-planner"
],
@ -1041,7 +1041,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-zEGH2wLAtRReRGWJJcYWgfxVmjx/cmNBh1HWp9EoO77UdnIdaB+SGDQJ5P1cl7/N\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-vDE4FMYhrLgkHnj6K2nRGEbLyf8EyuV7OUo+F93bGd00+/F+MRpev49x/iXsllFq\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
}
}
]
@ -1050,7 +1050,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q",
"integrity": "sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ",
"elements": [
"regions-dashboard"
],
@ -1072,7 +1072,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1087,7 +1087,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -1102,7 +1102,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]
@ -1111,7 +1111,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-GBlVmWSyzwQX0ty93zufhUFNTSJoDn8RF+7ywLJ0Q5sx5RLUoC2yt7W/XPtuX7LB",
"integrity": "sha384-Oqb5Jww1i+G5kkTq8Bggw/DrP3F8T34jIRCgvR80AkiMJ3/WskZKVJl2M64iRwOe",
"elements": [
"research-dashboard-map"
],
@ -1129,7 +1129,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-GBlVmWSyzwQX0ty93zufhUFNTSJoDn8RF+7ywLJ0Q5sx5RLUoC2yt7W/XPtuX7LB\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-Oqb5Jww1i+G5kkTq8Bggw/DrP3F8T34jIRCgvR80AkiMJ3/WskZKVJl2M64iRwOe\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
},
"bundle": "research-dashboard"
}
@ -1181,6 +1181,36 @@
}
]
},
{
"bundle": "rudder-voice",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"output": "dx-maps/rudder-voice/rudder-voice.js",
"integrity": "sha384-Pb1pQJqoB3wMTum8vw0uUrm7e74j90ytj1hNdIl/1HbRlfy1YoT0hf6ggWQ3qDNO",
"elements": [
"rudder-voice"
],
"stage": "experimental",
"profiles": [
{
"profile": "rudder-voice",
"stage": "experimental",
"widget": {
"corner": "bottom-right"
},
"file": "rudder-voice.vue",
"description": "A DESKTOP-target plugin (rudder-voice.meta.json \"targets\": [\"desktop\"]). The speech",
"element": "rudder-voice",
"type": "single",
"isMap": false,
"iframeUrl": "./rudder-voice/rudder-voice/index.html",
"scriptUrl": "./rudder-voice/rudder-voice.js",
"embed": {
"iframe": "<iframe src=\"./rudder-voice/rudder-voice/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./rudder-voice/rudder-voice.js\" integrity=\"sha384-Pb1pQJqoB3wMTum8vw0uUrm7e74j90ytj1hNdIl/1HbRlfy1YoT0hf6ggWQ3qDNO\" crossorigin=\"anonymous\"></script>\n<rudder-voice profile-config='{\"profile\":\"rudder-voice\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></rudder-voice>"
}
}
]
},
{
"bundle": "stack-demo",
"scriptUrl": "./stack-demo/stack-demo.js",
@ -1231,7 +1261,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-D2e4OB60VjRAhfnyVG192dx3hxC9tfP1WQMu1E+JcINQ2XRO7tCUhXXNZBMoQrD+",
"integrity": "sha384-X6YaUyV+9g7wppNaQ+YWgER7pRrk8y4egSZypQF8+2lVYRith+nPvkpiI77Oh6p3",
"elements": [
"upg-neighbor"
],
@ -1249,7 +1279,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-D2e4OB60VjRAhfnyVG192dx3hxC9tfP1WQMu1E+JcINQ2XRO7tCUhXXNZBMoQrD+\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"script": "<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in \"tk\": without one (or with \"provider\":\"maplibre\") the map draws with MapLibre and this line must go -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-X6YaUyV+9g7wppNaQ+YWgER7pRrk8y4egSZypQF8+2lVYRith+nPvkpiI77Oh6p3\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-zw1aZwvqZImE3ACuMOXExalaEsDUF+rKTxA+AQWZYnlTlW71kawA8QI6YrOIOGmR" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-meHgA2FfgqXz885IdY5fY9VgMQDHEBhl1nsot6tRszitV8aub/mWfBRoLnAmeFxU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../plugin-studio.js" integrity="sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX" crossorigin="anonymous"></script>
<script src="../plugin-studio.js" integrity="sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
<script src="../../plugin-studio.js" integrity="sha384-IQjG7XqoeppiQ+mxxtUxQApF7LoNWEc+LuQEftMZxWdDyht1X1u0mnB6LvDKAMJX" crossorigin="anonymous"></script>
<script src="../../plugin-studio.js" integrity="sha384-L2MCcGfR6X5/0VxHzMs9eDvMsdSYufZ66OhQdodC7vwcNgM7op8u8jNjayoJsySd" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
<script src="../pplr-data-maps.js" integrity="sha384-idrM+onVQ8aeioB8d67pktceaA4tHSoJCH3OX1eHhr5YHoI+X6/JSePapupxL9Rc" crossorigin="anonymous"></script>
<script src="../pplr-data-maps.js" integrity="sha384-ITBwAAzT9m4q3VvhBRMSwYGuJlTi/SFb5K3fyqqhPaa9FdbafyIPbI1Iu/YtWbae" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
<script src="../prayer-walk-planner.js" integrity="sha384-zEGH2wLAtRReRGWJJcYWgfxVmjx/cmNBh1HWp9EoO77UdnIdaB+SGDQJ5P1cl7/N" crossorigin="anonymous"></script>
<script src="../prayer-walk-planner.js" integrity="sha384-vDE4FMYhrLgkHnj6K2nRGEbLyf8EyuV7OUo+F93bGd00+/F+MRpev49x/iXsllFq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-Ah45bl/Z2Qirx42bUMN9sh9/B/OlA2HahdujWOX8lIOONPT868Q2ct/ehMJ/I41Q" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-aCE8BYISZb9Awa2lEWI0A3qgAc5ZFh1Q8EkSwnTyn2SizzOeyPwCRP8wz+oiVTmQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-GBlVmWSyzwQX0ty93zufhUFNTSJoDn8RF+7ywLJ0Q5sx5RLUoC2yt7W/XPtuX7LB" crossorigin="anonymous"></script>
<script src="../research-dashboard.js" integrity="sha384-Oqb5Jww1i+G5kkTq8Bggw/DrP3F8T34jIRCgvR80AkiMJ3/WskZKVJl2M64iRwOe" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more