check:share-policy's known-debt line is now GONE: it named two published maps, then
one, now zero. Every published map has exactly one decided sharing path.
★ THE OPEN QUESTION WAS ANSWERED BY THE CODE, NOT BY A RULING. This was held back
asking "is it MEANT to be shareable? a link carrying no route arrives as an empty
planner, and a button that reliably produces a blank map is worse than none". That
framing assumed a design choice existed. It did not: the profile keeps polygons in
localStorage and has NO BACKEND — its own header says so — so a URL cannot carry a
drawn route under any design. The shareable artefact is the PLANNER, exactly as
every other map shares its own page. The failure only exists if the link CLAIMS to
carry the route, so the status strip says once, on click: "This link shares the
planner. Your drawn zones stay on this device." — using the status mechanism the
profile already had rather than adding a surface.
★ ADDING <ShareButton> WAS NOT ENOUGH, AND check:share-policy COULD NOT SEE IT.
The button rendered NOWHERE. This bundle mounts its SFC directly as the custom
element — deliberate, it is what surfaces the defineExpose()d client-side API on the
DOM element ("the only API we can build has to be CLIENT SIDE") — and so installed
no i18n. ShareButton calls useI18n(), which throws in setup(), and index.js's own
header records the cost: "a thrown setup() removes the whole shadow-DOM subtree
silently." Measured: a runtime SyntaxError, five toolbar buttons rendered, the sixth
absent — while the policy gate, which counts <ShareButton> in SOURCE, said 1 button
and PASSED. That blind spot is now written into the gate, pointing at the smoke that
does catch it.
Fixed WITHOUT changing the mounting shape: defineCustomElement takes configureApp
whichever component is the root, so i18n installs and the API stays on the element.
The direct-mount decision and i18n were orthogonal; I had assumed they were coupled.
VERIFIED AT THE RENDERED LAYER: share-roundtrip-smoke resolves the url 200 text/html
and REOPENS it as <prayer-walk-planner> with a rendered canvas. The client-side API
was re-verified after the change — all 7 exposed methods present, addPolygon ->
listPolygons round-trips, 0 page errors. i18n verified in 6 locales: Share /
Compartir / مشاركة / साझा करें / 分享 / Partager. Added to smoke:share-all, now
12/12 pass 0 fail.
142 lines
8.2 KiB
JavaScript
142 lines
8.2 KiB
JavaScript
// check-share-policy — one written rule for WHICH MAPS CARRY A SHARE BUTTON.
|
|
//
|
|
// Driver: "Some maps need a map-level share button and some do not. The Prayer
|
|
// Progress map is the one intended for embedding on other people's websites, so it
|
|
// MUST have a share button. UPG-100 must not (the header already provides it).
|
|
// Decide + record the per-map rule, then apply it."
|
|
//
|
|
// The BEHAVIOUR already existed on both of those. What did not exist was the RULE,
|
|
// so nothing stopped the next agent adding a share button to a map that should not
|
|
// have one, or leaving a published map with no way to share it at all. Surveying
|
|
// all 11 map bundles found FOUR different patterns in use and two published maps
|
|
// with no sharing path by any mechanism.
|
|
//
|
|
// ⚠ WHAT THIS GATE CANNOT SEE — read this before trusting a green run.
|
|
// It counts <ShareButton> occurrences in SOURCE. A button that is present in source
|
|
// and RENDERS NOWHERE counts exactly the same as a working one. Measured twice:
|
|
// - mutation test 2026-09-24: gating all-people-groups' toolbar off left this gate
|
|
// reporting "1 button / own-button", green, with no button in the DOM;
|
|
// - for real, same day: prayer-walk-planner mounts its SFC directly as the custom
|
|
// element and so installed no i18n. ShareButton calls useI18n(), which THROWS in
|
|
// setup(), and a thrown setup() removes the shadow-DOM subtree SILENTLY. Five
|
|
// toolbar buttons rendered, the sixth did not exist, and this gate passed.
|
|
// smoke:share-all is the gate that catches that: it clicks the real control, follows
|
|
// the url, and asserts the SAME custom element reopens. Keep every published map in
|
|
// its list — this file decides the RULE, that one proves the BEHAVIOUR.
|
|
//
|
|
// ── THE RULE ────────────────────────────────────────────────────────────────
|
|
// A map bundle must have EXACTLY ONE share affordance, and it belongs at the
|
|
// OUTERMOST surface a reader actually sees:
|
|
//
|
|
// own-button the map IS the product surface -> <ShareButton> in the map
|
|
// header a composite wraps it with a header -> the header owns sharing,
|
|
// the map omits the button
|
|
// composite a parent stacks it and overrides
|
|
// shareUrl so the PARENT is shared -> child button is fine,
|
|
// parent supplies the url
|
|
// none not published — nothing to share yet
|
|
//
|
|
// TWO share buttons on one surface is the defect this prevents (it shipped once:
|
|
// BUG-UPG100-DOUBLE-SHARE). ZERO on a published map is the opposite defect, and it
|
|
// is currently REAL on two bundles — recorded below as known debt rather than
|
|
// silently passing, so the count cannot drift without someone deciding.
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
import { resolve, join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
// Same fix as check-zone-census.mjs, same reason. DOXA_REPO still wins; only the FALLBACK moved
|
|
// off an absolute path into one author's worktree. check:no-abs-paths rejects those outright — "a
|
|
// clone must work on any machine" — and it runs inside build:unlocked, so the hardcoded default
|
|
// turned the whole build red for every builder. This is the idiom that gate names in its own
|
|
// failure text, and the one every sibling script here uses.
|
|
const REPO = process.env.DOXA_REPO
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const DESIGNS = join(REPO, 'plugin-designs')
|
|
|
|
// The decided policy, per bundle. A map bundle MUST appear here — an unlisted one
|
|
// fails, so adding a map forces the sharing decision rather than deferring it.
|
|
const POLICY = {
|
|
'doxa-simple-map': 'own-button', // Prayer Progress — the embed flagship
|
|
'doxa-research-map': 'own-button',
|
|
'country-posters': 'header', // country-posters-mfe header owns it
|
|
'cities-internet': 'own-button',
|
|
'gospel-resources': 'own-button',
|
|
'demo-map': 'own-button',
|
|
'my-upg-100-list': 'header', // upg-100-mfe.vue header owns it
|
|
'research-map-plus-grid': 'composite', // overrides child shareUrl to share itself
|
|
'key-cities': 'none', // not published
|
|
'all-people-groups': 'own-button', // was debt-no-share; ShareButton added 2026-09-24
|
|
// ⚠ prayer-walk-planner took a SECOND LOOK before this flipped, and the reason is worth
|
|
// keeping: it builds a PERSONAL WALKING ROUTE from polygons the reader draws, so a share
|
|
// link that silently drops them arrives as an empty planner — a button that reliably
|
|
// produces a blank map is worse than none, because it LOOKS like sharing worked.
|
|
// That read as a semantics question needing a ruling. It was not: the profile stores
|
|
// polygons in localStorage and has NO BACKEND (its own file header says so), so a URL
|
|
// CANNOT carry a route. There is no design freedom — the shareable artefact is the
|
|
// planner itself, exactly as every other map shares its own page. The failure mode only
|
|
// exists if the link CLAIMS to carry the route, so the button says "tool" and the status
|
|
// strip tells the reader their zones stay on the device.
|
|
'prayer-walk-planner': 'own-button', // was debt-no-share; resolved 2026-09-24
|
|
}
|
|
|
|
const isMap = (src) => /mapboxgl|maplibregl|useMapInstance/.test(src)
|
|
const manifest = JSON.parse(readFileSync(join(REPO, 'public', 'doxa-maps', 'manifest.json'), 'utf8'))
|
|
const published = new Set(manifest.bundles.map((b) => b.bundle))
|
|
|
|
const fails = []
|
|
const rows = []
|
|
for (const name of readdirSync(DESIGNS)) {
|
|
const dir = join(DESIGNS, name)
|
|
let vues = []
|
|
try { vues = readdirSync(dir).filter((f) => f.endsWith('.vue')) } catch { continue }
|
|
if (!vues.length) continue
|
|
const src = vues.map((f) => readFileSync(join(dir, f), 'utf8')).join('\n')
|
|
if (!isMap(src)) continue
|
|
|
|
const buttons = (src.match(/<ShareButton/g) || []).length
|
|
const want = POLICY[name]
|
|
rows.push({ name, buttons, want, published: published.has(name) })
|
|
|
|
if (!want) {
|
|
fails.push(`${name}: a map bundle with no entry in POLICY — decide whether it shares, and record it`)
|
|
continue
|
|
}
|
|
if (want === 'own-button' && buttons !== 1) {
|
|
fails.push(`${name}: policy "own-button" but ${buttons} <ShareButton> — `
|
|
+ (buttons === 0 ? 'the embed surface has no way to be shared' : 'two affordances on one surface'))
|
|
}
|
|
if (want === 'header' && buttons !== 1) {
|
|
// UPG-100's single button lives in the MFE header, not the map — still exactly one.
|
|
fails.push(`${name}: policy "header" expects exactly one share affordance in the bundle, found ${buttons}`)
|
|
}
|
|
// ⚠ 'header' HAS A CONSEQUENCE FOR smoke:share-all, and it drifted once. The button is
|
|
// in the MFE SHELL, so the bundle's LEAF url correctly has none — and smoke:share-all
|
|
// was still pointing at the leaf for both header bundles, reporting 2 failures that
|
|
// were the policy working as designed (measured 2026-09-24: 10/12, both failures
|
|
// 'header'). When a bundle moves to 'header', its entry in smoke:share-all must move
|
|
// from <bundle>/<leaf>/ to <bundle>/<mfe>/ or the suite tests a surface the policy
|
|
// says should be empty.
|
|
if (want === 'debt-no-share' && buttons !== 0) {
|
|
fails.push(`${name}: listed as known debt (no sharing) but now has ${buttons} — update POLICY`)
|
|
}
|
|
if (want === 'none' && published.has(name)) {
|
|
fails.push(`${name}: policy "none" assumes unpublished, but it IS published — it needs a share path`)
|
|
}
|
|
}
|
|
|
|
console.log(' bundle published buttons policy')
|
|
for (const r of rows.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
console.log(` ${r.name.padEnd(24)} ${(r.published ? 'yes' : 'no').padEnd(10)} ${String(r.buttons).padEnd(8)} ${r.want || '(UNDECIDED)'}`)
|
|
}
|
|
const debt = rows.filter((r) => r.want === 'debt-no-share')
|
|
if (debt.length) {
|
|
console.log(`\n known debt: ${debt.length} published map(s) with NO sharing path — `
|
|
+ debt.map((d) => d.name).join(', '))
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error(`\nSHARE-POLICY: FAIL — ${fails.length} problem(s)`)
|
|
for (const f of fails) console.error(' - ' + f)
|
|
process.exit(1)
|
|
}
|
|
console.log(`\nSHARE-POLICY: PASS — ${rows.length} map bundles, each with exactly one decided sharing rule.`)
|