The country link went to the people group; four links broke on a missing slash

Two interop defects on production plugins, found by reading the links rather
than the code around them, and corrected against DX-life's own component once
dx-shepherd pointed me at it.

THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. On the regions dashboard's card,
the country name under the title linked to that people group's own profile —
the same destination as the "Full Profile" button directly beneath it. Two
links on one card, one labelled with a country, both landing in the same place.

Theirs, in BOTH card variants (app/components/public/UupgsList.vue:610, :638):

    localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)

A page on the HOST site — not a research profile, and not the ../countries/
path my first pass reached for. So the base is a PROP, not a constant: only the
host knows where its country pages live, and DX-life's are under /regions/.
Prop, then profile-config, then the site base, so an embedder can point country
names anywhere without touching the research base.

people-groups-grid is ported from that same component and had the same defect
in a different disguise: `${researchUrl}../countries/<slug>`, climbing out of
the research base to a section DX-life does not use for this. Both now build
the same URL from the same countrySlug.

Our countrySlug is theirs: identical body, identical slugify (every transform,
in order), and the tables behind it match exactly — 242 codes each, zero
missing, zero differing labels. My first comparison of those tables parsed
their file into ZERO entries and reported "no differences", which is the shape
of a check that cannot fail; the number above is from the run that actually
parsed 242.

FOUR LINKS DEPENDED ON A TRAILING SLASH NOBODY WAS TOLD ABOUT. researchUrl and
prayBaseUrl are set by whoever embeds the element, in someone else's HTML. The
joins were bare concatenation, and RegionCountryMap's own prop comment records
the contract without ever telling the embedder:

    Pray -> prayBaseUrl/<slug>,  Full Profile -> researchUrl<slug>

One needs a trailing slash, the other must not have one. An embedder writing
research-url="https://doxa.life/research" — the spelling a human would choose —
got "https://doxa.life/researchbantu-tswana" on every profile link the plugin
draws: a 404, with nothing logged anywhere.

We cannot test what an embedder passes, so joinBase() encodes the rule instead
of documenting it: exactly one slash, whatever we are handed. Eight shapes
checked, including a doubled slash, a leading slash on the path, an empty path
and a null base.
This commit is contained in:
Doxa Maps 2026-09-28 20:43:25 +00:00
parent a035408e26
commit 6956fd790f
5 changed files with 66 additions and 6 deletions

View file

@ -0,0 +1,27 @@
/**
* joinBase.js — SHARED (@map/composables/joinBase.js). Join a host-supplied base URL to a path.
*
* ★ WHY THIS EXISTS: `researchUrl` and `prayBaseUrl` are set by whoever embeds the element —
* an attribute in someone else's HTML, on someone else's page. Four sites joined them by bare
* concatenation, and RegionCountryMap's own prop comment records the contract this leaves on the
* embedder without ever telling them:
*
* Pray -> prayBaseUrl/<slug>, Full Profile -> researchUrl<slug>
*
* One of those two needs a trailing slash and the other must not have one. An embedder who writes
* research-url="https://doxa.life/research" — the spelling a human would choose — gets
* "https://doxa.life/researchbantu-tswana": a 404 with no error anywhere, on every profile link
* the plugin draws. The people-groups-grid case is worse, because its country link is
* `${researchUrl}../countries/<slug>` and silently resolves one level too high without the slash.
*
* We cannot test what an embedder passes, so the constraint is encoded instead of documented:
* exactly one slash between the two halves, whatever we are handed.
*
* Relative segments are preserved on purpose — joinBase(base, '../countries/x') keeps the '..',
* which the browser resolves at navigation exactly as the hand-written version did.
*/
export function joinBase(base, path = '') {
const b = String(base == null ? '' : base).replace(/\/+$/, '')
const p = String(path == null ? '' : path).replace(/^\/+/, '')
return p ? `${b}/${p}` : `${b}/`
}

View file

@ -58,6 +58,7 @@ import { countrySlug } from './src/countries.js'
import { RELIGION_FAMILIES } from '@map/colors/religion.js'
import { canonicalFamilyName } from '@map/colors/language-family.js'
import langFamilyByIso from '@map/data/langFamilyByIso.json'
import { joinBase } from '@map/composables/joinBase.js'
// ★ THE HOST'S INLINE STYLE MUST NOT LAND ON OUR ROOT.
// This SFC is mounted AS a custom element, so a style attribute the HOST page puts on the tag falls
@ -79,6 +80,7 @@ const props = defineProps({
// and kept winning (measured: bundle carried the canonical branch AND default:"/research/",
// and every rendered link was still relative). Unset must mean unset. */
researchUrl: { type: String, default: '' },
countryUrl: { type: String, default: '' },
selectUrl: { type: String, default: '' },
t: { type: Object, default: () => ({}) },
@ -300,6 +302,13 @@ watch(textDir, reflectDir)
// surfaces, one rule. Prop and cfg still take precedence for hosts that want their own base.
const researchUrl = computed(() => props.researchUrl || cfg.value.researchUrl
|| (((typeof window !== 'undefined' && window.DOXA_SITE_URL) || 'https://doxa.life').replace(/\/+$/, '') + '/research/'))
// ★ THE COUNTRY LINK WAS THE WRONG PAGE, not just the wrong join. It built
// `${researchUrl}../countries/<slug>`, climbing out of the research base to a /countries/ path.
// Theirs (app/components/public/UupgsList.vue:610 and :638, the component this card is ported
// from) links to localePath(`/regions/<slug>`) — a page on the host site. Same site base as
// researchUrl above, different section, and a host can override either.
const countryUrl = computed(() => props.countryUrl || cfg.value.countryUrl
|| (((typeof window !== 'undefined' && window.DOXA_SITE_URL) || 'https://doxa.life').replace(/\/+$/, '') + '/regions/'))
const selectUrl = computed(() => props.selectUrl || cfg.value.selectUrl || '')
// ★ EVERY LABEL IN THIS GRID WAS ENGLISH IN ALL ELEVEN LOCALES.
// The `t` object is a HOST-SUPPLIED map (the Nuxt page passes its own strings), with an English
@ -1102,7 +1111,7 @@ onMounted(() => {
<p v-if="uupg.country_code" class="uupg__country">
<a
class="uupg__country-link"
:href="`${researchUrl}../countries/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`"
:href="joinBase(countryUrl, countrySlug(uupg.country_code.value, uupg.country_code.label))"
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a>
<template v-if="uupg.rop1"> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1.label" />)</template>
</p>
@ -1113,7 +1122,7 @@ onMounted(() => {
</template>
</div>
<p v-if="uupg.location_description" class="uupg__content">{{ uupg.location_description }}</p>
<a class="uupg__more-button" :href="`${researchUrl}${uupg.slug}`">{{ t.full_profile || 'Full Profile' }}</a>
<a class="uupg__more-button" :href="joinBase(researchUrl, uupg.slug)">{{ t.full_profile || 'Full Profile' }}</a>
</div>
</template>

View file

@ -85,6 +85,9 @@ const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl |
// we have no router, so it comes from profile-config with their public site as the default —
// the same ladder and the same default UupgsList.vue:74 already uses.
const researchUrl = computed(() => cfg.value.researchUrl || 'https://doxa.life/research/')
// Where a country NAME links, as distinct from where a people group's profile lives. DX-life sends
// it to its own /regions/<slug> page, so a host embedding us keeps that under its own control.
const countryUrl = computed(() => cfg.value.countryUrl || 'https://doxa.life/regions/')
// The prayer API localises its LABELS from ?lang= — country names, region names, religion
// names. MEASURED: with locale=ar the UI translated but every country in the table stayed
// English, because this read only cfg.languageCode while the locale arrives as
@ -1141,6 +1144,7 @@ useShadowStyles(
:select-on-pin-click="isAll"
:pray-base-url="prayBaseUrl"
:research-url="researchUrl"
:country-url="countryUrl"
@pick-country="onPickFromMap"
/>
</div>

View file

@ -47,6 +47,7 @@ import { applyColor as regionColorExpression, PALETTE as REGION_PALETTE, getColo
// ALREADY in the catalogue — translated for the Prayer Coverage card and hardcoded here.
// Their CountryMap.vue calls t() for the same strings (:268-272).
import { useRegionsI18n } from './useRegionsI18n.js'
import { joinBase } from '@map/composables/joinBase.js'
const { t, locale } = useRegionsI18n()
@ -417,8 +418,8 @@ function popupHtml(p) {
// so a host setting profile-config.locale to 'ar-EG' is the case that actually changes digits.
// A fixed 'en-US' is immune to it, which is a second reason the revert is safe.
const pop = Number(p.population) > 0 ? Number(p.population).toLocaleString('en-US') : ''
const profileUrl = `${props.researchUrl}${p.slug || ''}`
const prayUrl = `${String(props.prayBaseUrl).replace(/\/+$/, '')}/${p.slug || ''}?source=doxalife`
const profileUrl = joinBase(props.researchUrl, p.slug || '')
const prayUrl = `${joinBase(props.prayBaseUrl, p.slug || '')}?source=doxalife`
const rows = []
if (pop) rows.push(`<div><dt>${esc(t('Population'))}</dt><dd>${pop}</dd></div>`)

View file

@ -67,6 +67,8 @@ import { highlightRanges } from '@map/composables/useTextHighlight.js'
// DX-life's spacing/type scales under THEIR token names. Prepended to this
// component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from '../dxlife-tokens.js'
import { joinBase } from '@map/composables/joinBase.js'
import { countrySlug } from '@map/utils/countries-meta.js'
const props = defineProps({
// ISO-3. Their `lockCountryCode`: the country filter is pre-applied and its dropdown hidden.
@ -74,6 +76,9 @@ const props = defineProps({
prayBaseUrl: { type: String, default: 'https://pray.doxa.life' },
languageCode: { type: String, default: '' },
researchUrl: { type: String, default: 'https://doxa.life/research/' },
// Where a COUNTRY name links. DX-life's own UupgsList sends it to localePath('/regions/<slug>'),
// i.e. a page on the host site, not a research profile — so the base has to come from the host.
countryUrl: { type: String, default: 'https://doxa.life/regions/' },
// Their perPage / morePerPage defaults (UupgsList.vue props block).
perPage: { type: Number, default: 24 },
morePerPage: { type: Number, default: 0 },
@ -290,7 +295,21 @@ function toggleFlag(name, label) {
function onFilterChange(e) { if (e && e.name) active.value = { ...active.value, [e.name]: { value: e.value, label: e.label } } }
function onFilterClear(e) { if (e && e.name) active.value = { ...active.value, [e.name]: null } }
function clearAll() { active.value = { wagf_region: null, wagf_block: null, rop1: null, religion: null } }
function profileUrl(p) { return `${props.researchUrl}${p.slug || ''}` }
function profileUrl(p) { return joinBase(props.researchUrl, p.slug || '') }
// ★ THE COUNTRY LINK POINTED AT THE PEOPLE GROUP. It used profileUrl(p), the same destination as
// the "Full Profile" button directly beneath it — two links on one card, one of them labelled with
// a country name, both landing on that people group's profile.
//
// THEIRS, both card variants (app/components/public/UupgsList.vue:610 and :638):
// localePath(`/regions/${countrySlug(country_code.value, country_code.label)}`)
// A page on the HOST site. That is why the base is a prop and not a constant: only the host knows
// where its country pages live, and DX-life's are under /regions/, not under a research base.
// Our countrySlug is theirs, verbatim — same body, same slugify, same ENGLISH_NAME_BY_CODE.
function countryHref(p) {
const code = value(p.country_code)
if (!code) return profileUrl(p)
return joinBase(props.countryUrl, countrySlug(code, label(p.country_code)))
}
// Every value resolved against DX-life's 10px rem root, from the same token files as the rest of
// this bundle. Sources: blocks/_filters.scss (.filters*, .filter-chip*), blocks/_form.scss
@ -583,7 +602,7 @@ useShadowStyles(
<h3 v-if="p.name_label" class="uupg__name" v-html="p.name_label" />
<h3 v-else class="uupg__name">{{ p.name }}</h3>
<p class="uupg__country">
<a class="uupg__country-link" :href="profileUrl(p)">
<a class="uupg__country-link" :href="countryHref(p)">
<span v-if="p.country_label" v-html="p.country_label" /><span v-else>{{ label(p.country_code) }}</span>
</a>
<!-- THEIR NODE SHAPE, UupgsList.vue:639: `</a> (<span v-html=... />)`. The parens are