From d332cc135e6c2da77f8695a84563c10a879072d0 Mon Sep 17 00:00:00 2001 From: Falkan Date: Wed, 18 Mar 2026 19:51:01 -0400 Subject: [PATCH] fix: description placeholder sentinel leaking to output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. Sentinel used double-underscore delimiters (__DESC:id__) — fragile regex [^_]+ would break on IDs containing underscores, and Pass-1 output containing sentinels would leak through Pass 2 unstripped. 2. After Pass 2 substitution, sentinels inside substituted text (from Pass-1 output of the referenced id) were never cleaned up. Fix: switch to null-byte delimiters (\x00DESC:id\x00) which cannot appear in user text, and add a second .replace(SENTINEL_RE, '') pass to strip any sentinels that survive after substitution (self-refs, nested cycles). --- data/units.json | 18 ++++++----- src/lib/description.ts | 73 ++++++++++++++++++++---------------------- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/data/units.json b/data/units.json index dda85f8..5118571 100644 --- a/data/units.json +++ b/data/units.json @@ -6,7 +6,8 @@ "labelPlural": "pounds", "symbol": "lb", "group": "dickloads", - "toBase": 0.038 + "toBase": 0.038, + "description": "{dickload:description}" }, { "id": "dickload", @@ -14,7 +15,8 @@ "labelPlural": "dickloads", "symbol": "dl", "group": "dickloads", - "toBase": 1 + "toBase": 1, + "description": "See {pound:description}" }, { "id": "barrel", @@ -69,24 +71,24 @@ }, { "id": "assload", - "label": "Assload", - "labelPlural": "Assloads", + "label": "assload", + "labelPlural": "assloads", "symbol": "assl", "group": "dickloads", "toBase": 2 }, { "id": "shitload", - "label": "Shitload", - "labelPlural": "Shitloads", + "label": "shitload", + "labelPlural": "shitloads", "symbol": "shtld", "group": "dickloads", "toBase": 12.6666666667 }, { "id": "shit-ton", - "label": "Shit ton", - "labelPlural": "Shit tons", + "label": "shit ton", + "labelPlural": "shit tons", "symbol": "shttn", "group": "dickloads", "toBase": 38 diff --git a/src/lib/description.ts b/src/lib/description.ts index c796389..9c21f3a 100644 --- a/src/lib/description.ts +++ b/src/lib/description.ts @@ -10,30 +10,28 @@ import type { Unit, Group } from './types'; * {id:symbol} → symbol e.g. "shttn" * {id:description} → resolved description of that unit/group * - * For groups: - * {id} → group label - * {id:label} → group label - * {id:description} → resolved description (if groups gain descriptions later) - * - * Unknown IDs or fields are left as-is. - * * Cycle handling — two-pass algorithm: - * Pass 1: resolve all non-description fields ({id}, {id:label}, {id:plural}, - * {id:symbol}) in every description. Replace {id:description} tokens - * with a sentinel __DESC:id__ instead of recursing. - * Pass 2: replace each __DESC:id__ sentinel with the Pass 1 output for that id. - * Since Pass 1 output contains no live {id:description} tokens (only - * sentinels), this is one substitution deep — cycles terminate naturally - * after one level rather than producing an empty slot. + * Pass 1: resolve all non-description field tokens in every unit's description. + * Replace {id:description} tokens with a sentinel \x00DESC:id\x00 + * (null-byte delimited — can't appear in user-authored text). + * Pass 2: replace each sentinel with the Pass 1 output for the referenced id. + * Since Pass 1 output contains only sentinels (not live {id:description} + * tokens), this is one substitution deep. Any remaining sentinels after + * Pass 2 (self-references, unresolvable cycles) are stripped to empty. * - * This means a mutual reference (id0 ↔ id2) renders each unit's description - * containing the other's Pass-1 text — one level of expansion, then stops. + * Result for a mutual reference (id0 ↔ id2): + * - id0's description contains id2's Pass-1 text (with id2's back-reference stripped) + * - id2's description contains id0's Pass-1 text (with id0's back-reference stripped) + * One level of expansion, then stops cleanly. */ -const SENTINEL_PREFIX = '__DESC:'; -const SENTINEL_SUFFIX = '__'; +// Null-byte delimiters can't appear in user-authored strings. +const SENTINEL_RE = /\x00DESC:([^\x00]+)\x00/g; const PLACEHOLDER_RE = /\{([^}]+)\}/g; -const SENTINEL_RE = /__DESC:([^_]+)__/g; + +function makeSentinel(id: string): string { + return `\x00DESC:${id}\x00`; +} /** Resolve all non-description placeholder fields in a single string. */ function resolveFields( @@ -48,7 +46,7 @@ function resolveFields( // {id:description} — defer to pass 2 if (field === 'description') { - return `${SENTINEL_PREFIX}${id}${SENTINEL_SUFFIX}`; + return makeSentinel(id); } const unit = unitMap.get(id); @@ -76,11 +74,8 @@ function resolveFields( } /** - * Build a map of id → fully resolved description for every unit and group, - * then return the resolved description for the requested id. - * - * Call this once per render cycle (e.g. in a $derived in ConverterCard) - * rather than per-unit to avoid redundant work. + * Build a map of id → fully resolved description for every unit and group. + * Call once per reactive update rather than per-unit. */ export function resolveAllDescriptions( units: Unit[], @@ -89,23 +84,25 @@ export function resolveAllDescriptions( const unitMap = new Map(units.map((u) => [u.id, u])); const groupMap = new Map(groups.map((g) => [g.id, g])); - // Pass 1: resolve all non-description fields; replace {id:description} with sentinels. + // Pass 1: resolve field tokens; replace {id:description} with sentinels. const pass1 = new Map(); for (const u of units) { pass1.set(u.id, u.description ? resolveFields(u.description, unitMap, groupMap) : ''); } for (const g of groups) { - // Groups don't have descriptions yet, but wire it up for future use. pass1.set(g.id, ''); } - // Pass 2: replace sentinels with the Pass 1 output for the referenced id. - // One substitution deep — cycles produce one level of expansion then stop. + // Pass 2: replace sentinels with Pass 1 output of the referenced id. + // Strip any remaining sentinels (self-refs, unresolvable cycles) → empty string. const result = new Map(); for (const [id, p1] of pass1) { result.set( id, - p1.replace(SENTINEL_RE, (_match, refId: string) => pass1.get(refId) ?? '') + p1 + .replace(SENTINEL_RE, (_match, refId: string) => pass1.get(refId) ?? '') + // Strip any sentinels that survived (self-references or nested cycles) + .replace(SENTINEL_RE, '') ); } @@ -113,9 +110,8 @@ export function resolveAllDescriptions( } /** - * Convenience wrapper: resolve a single description string given the full - * units/groups context. Builds the full map internally — use resolveAllDescriptions - * directly when resolving many units at once. + * Convenience wrapper for resolving a single description string. + * Use resolveAllDescriptions when resolving many units at once. */ export function resolveDescription( description: string | undefined, @@ -126,9 +122,10 @@ export function resolveDescription( const unitMap = new Map(units.map((u) => [u.id, u])); const groupMap = new Map(groups.map((g) => [g.id, g])); const p1 = resolveFields(description, unitMap, groupMap); - return p1.replace(SENTINEL_RE, (_match, refId: string) => { - const unit = unitMap.get(refId); - if (unit?.description) return resolveFields(unit.description, unitMap, groupMap); - return ''; - }); + return p1 + .replace(SENTINEL_RE, (_match, refId: string) => { + const unit = unitMap.get(refId); + return unit?.description ? resolveFields(unit.description, unitMap, groupMap) : ''; + }) + .replace(SENTINEL_RE, ''); // strip surviving sentinels }