A shared link reads a list separated by , or |, so partner links already published keep working

Links on partner pages write band=yes|partial, and the reader split on ',' only,
so such a link read back as one value and matched no row. Reading now accepts
either separator; writing stays ','. A literal '|' in a value is percent-encoded
before any join, so every separator left in a URL is a real one.
This commit is contained in:
Doxa Maps 2026-09-27 19:10:08 +00:00
parent b6e3e005a3
commit e79d6dc8f4
2 changed files with 29 additions and 3 deletions

View file

@ -192,6 +192,14 @@ if (debt.length) {
eq('alias reads an already-shared link', parseQuery('?access=low&app=sms', ALIAS), { filters: { access_level: 'low', messaging: 'sms' }, search: '' })
eq('an undeclared bus key keeps its own name', serializeQuery({ country: 'BT' }, '', '', { facetKeys: ALIAS }), '?country=BT')
eq('a host param survives under an alias map', serializeQuery({ access_level: 'low' }, '', '?utm=x', { facetKeys: ALIAS }), '?utm=x&access=low')
// ★ BOTH SEPARATORS ON READ, ONE ON WRITE. go-int's board has always written band=yes|partial and
// those links are on partner pages; splitting only on ',' read that back as the single value
// "yes|partial" and matched no row, so adopting this composable would have broken every multi-value
// link they had already shared. Reserving `|` on write is what makes accepting it on read free.
eq('an already-shared | link restores', parseQuery('?band=yes|partial', ['band']).filters, { band: ['yes', 'partial'] })
eq('mixed separators in one link', parseQuery('?band=yes|partial&country=IND,CHN', ['band', 'country']).filters, { band: ['yes', 'partial'], country: ['IND', 'CHN'] })
eq('we still write one canonical separator', serializeQuery({ band: ['yes', 'partial'] }, '', '', { facetKeys: ['band'] }), '?band=yes,partial')
eq('a literal | inside a value is escaped, not split', parseQuery(serializeQuery({ x: 'a|b' }, '', '', { facetKeys: ['x'] }), ['x']).filters, { x: 'a|b' })
if (!fails.some((f) => f.startsWith('SHARE-VIEW'))) notes.push('share link: filters AND the typed search round-trip in BOTH hash and query mode; old links, legacy JSON and a host page\'s own query all survive')
}
}

View file

@ -51,7 +51,25 @@ const slugify = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-')
// values of ONE key (OR-within-key), and is reserved for the same reason `;` `:` `*` are: a delimiter
// that can also occur inside a value is not a delimiter. `&` joins params, so it is reserved too —
// without that, a search for "rice & beans" would truncate the link at the ampersand.
const escapeValue = (s) => String(s).replace(/[%;:*+,&]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
// `|` joins as well as `,` — see LIST_SEP below — so it is reserved too. A delimiter that can also occur
// inside a value is not a delimiter, and that rule has already cost this tree once: the language-code link
// joined on `,` until 120 of 864 language labels turned out to contain one.
const escapeValue = (s) => String(s).replace(/[%;:*+,&|]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/ /g, '+')
/**
* ★ READ BOTH `,` AND `|`; WRITE `,`. go-int's board has always written `band=yes|partial` and
* `country=IND|CHN`, and those links are on partner pages now. Splitting only on `,` read that back as the
* single value "yes|partial", which matches no row — so adopting this composable would have broken every
* multi-value link they had already shared. Accepting BOTH separators on read costs nothing: escapeValue
* percent-encodes a literal `,` or `|` inside a value before any join, so every separator still present in
* a URL is genuinely a separator.
* Writing stays `,` so there is ONE canonical form going forward rather than two dialects to reason about.
* The one case this does not cover, stated rather than papered over: an OLD link whose VALUE contained a
* literal `|` written before `|` was reserved would now be split. No facet vocabulary in the tree contains
* one (checked religion.js and the region/language codes), and go-int confirms `|` cannot occur in a facet
* code, so the exposure is a value shape that does not exist rather than one we are choosing to risk.
*/
const LIST_SEP = /[,|]/
const unescapeValue = (s) => { try { return decodeURIComponent(String(s).replace(/\+/g, '%20')) } catch (_) { return String(s).replace(/\+/g, ' ') } }
/** Serialize a view to a hash string. Exported so a gate can drive it without a browser. */
@ -88,7 +106,7 @@ export function parseView(hash, { hashKey = 'f', searchKey = 'q' } = {}) {
// Split BEFORE unescaping: a literal comma inside a value is percent-encoded by escapeValue, so
// every `,` still here is a separator. Unescaping first would resurrect content commas and split
// the value in half.
const parts = body.split(',').map(unescapeValue).filter(Boolean)
const parts = body.split(LIST_SEP).map(unescapeValue).filter(Boolean)
if (key && parts.length) out.filters[key] = parts.length > 1 ? parts : parts[0]
}
return out
@ -153,7 +171,7 @@ export function parseQuery(search, facetKeys = [], { searchKey = 'q' } = {}) {
if (k === searchKey) { out.search = unescapeValue(body); continue }
const busKey = keyOf(k)
if (!busKey) continue
const parts = body.split(',').map(unescapeValue).filter(Boolean)
const parts = body.split(LIST_SEP).map(unescapeValue).filter(Boolean)
if (parts.length) out.filters[busKey] = parts.length > 1 ? parts : parts[0]
}
return out