From e79d6dc8f4b3c2cbbd1dfff68b8ccf3c31e15f8a Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 19:10:08 +0000 Subject: [PATCH] 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. --- internal/check-share-policy.mjs | 8 ++++++++ library/composables/useShareableView.js | 24 +++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/check-share-policy.mjs b/internal/check-share-policy.mjs index 122bb8772..33b22a721 100644 --- a/internal/check-share-policy.mjs +++ b/internal/check-share-policy.mjs @@ -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') } } diff --git a/library/composables/useShareableView.js b/library/composables/useShareableView.js index f62b85a8f..771207827 100644 --- a/library/composables/useShareableView.js +++ b/library/composables/useShareableView.js @@ -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