fix: description placeholder sentinel leaking to output

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).
This commit is contained in:
Falkan
2026-03-18 19:51:01 -04:00
parent 1204ede140
commit d332cc135e
2 changed files with 45 additions and 46 deletions

View File

@@ -6,7 +6,8 @@
"labelPlural": "pounds", "labelPlural": "pounds",
"symbol": "lb", "symbol": "lb",
"group": "dickloads", "group": "dickloads",
"toBase": 0.038 "toBase": 0.038,
"description": "{dickload:description}"
}, },
{ {
"id": "dickload", "id": "dickload",
@@ -14,7 +15,8 @@
"labelPlural": "dickloads", "labelPlural": "dickloads",
"symbol": "dl", "symbol": "dl",
"group": "dickloads", "group": "dickloads",
"toBase": 1 "toBase": 1,
"description": "See {pound:description}"
}, },
{ {
"id": "barrel", "id": "barrel",
@@ -69,24 +71,24 @@
}, },
{ {
"id": "assload", "id": "assload",
"label": "Assload", "label": "assload",
"labelPlural": "Assloads", "labelPlural": "assloads",
"symbol": "assl", "symbol": "assl",
"group": "dickloads", "group": "dickloads",
"toBase": 2 "toBase": 2
}, },
{ {
"id": "shitload", "id": "shitload",
"label": "Shitload", "label": "shitload",
"labelPlural": "Shitloads", "labelPlural": "shitloads",
"symbol": "shtld", "symbol": "shtld",
"group": "dickloads", "group": "dickloads",
"toBase": 12.6666666667 "toBase": 12.6666666667
}, },
{ {
"id": "shit-ton", "id": "shit-ton",
"label": "Shit ton", "label": "shit ton",
"labelPlural": "Shit tons", "labelPlural": "shit tons",
"symbol": "shttn", "symbol": "shttn",
"group": "dickloads", "group": "dickloads",
"toBase": 38 "toBase": 38

View File

@@ -10,30 +10,28 @@ import type { Unit, Group } from './types';
* {id:symbol} → symbol e.g. "shttn" * {id:symbol} → symbol e.g. "shttn"
* {id:description} → resolved description of that unit/group * {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: * Cycle handling — two-pass algorithm:
* Pass 1: resolve all non-description fields ({id}, {id:label}, {id:plural}, * Pass 1: resolve all non-description field tokens in every unit's description.
* {id:symbol}) in every description. Replace {id:description} tokens * Replace {id:description} tokens with a sentinel \x00DESC:id\x00
* with a sentinel __DESC:id__ instead of recursing. * (null-byte delimited — can't appear in user-authored text).
* Pass 2: replace each __DESC:id__ sentinel with the Pass 1 output for that id. * Pass 2: replace each sentinel with the Pass 1 output for the referenced id.
* Since Pass 1 output contains no live {id:description} tokens (only * Since Pass 1 output contains only sentinels (not live {id:description}
* sentinels), this is one substitution deep — cycles terminate naturally * tokens), this is one substitution deep. Any remaining sentinels after
* after one level rather than producing an empty slot. * Pass 2 (self-references, unresolvable cycles) are stripped to empty.
* *
* This means a mutual reference (id0 ↔ id2) renders each unit's description * Result for a mutual reference (id0 ↔ id2):
* containing the other's Pass-1 text — one level of expansion, then stops. * - 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:'; // Null-byte delimiters can't appear in user-authored strings.
const SENTINEL_SUFFIX = '__'; const SENTINEL_RE = /\x00DESC:([^\x00]+)\x00/g;
const PLACEHOLDER_RE = /\{([^}]+)\}/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. */ /** Resolve all non-description placeholder fields in a single string. */
function resolveFields( function resolveFields(
@@ -48,7 +46,7 @@ function resolveFields(
// {id:description} — defer to pass 2 // {id:description} — defer to pass 2
if (field === 'description') { if (field === 'description') {
return `${SENTINEL_PREFIX}${id}${SENTINEL_SUFFIX}`; return makeSentinel(id);
} }
const unit = unitMap.get(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, * Build a map of id → fully resolved description for every unit and group.
* then return the resolved description for the requested id. * Call once per reactive update rather than per-unit.
*
* Call this once per render cycle (e.g. in a $derived in ConverterCard)
* rather than per-unit to avoid redundant work.
*/ */
export function resolveAllDescriptions( export function resolveAllDescriptions(
units: Unit[], units: Unit[],
@@ -89,23 +84,25 @@ export function resolveAllDescriptions(
const unitMap = new Map(units.map((u) => [u.id, u])); const unitMap = new Map(units.map((u) => [u.id, u]));
const groupMap = new Map(groups.map((g) => [g.id, g])); 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<string, string>(); const pass1 = new Map<string, string>();
for (const u of units) { for (const u of units) {
pass1.set(u.id, u.description ? resolveFields(u.description, unitMap, groupMap) : ''); pass1.set(u.id, u.description ? resolveFields(u.description, unitMap, groupMap) : '');
} }
for (const g of groups) { for (const g of groups) {
// Groups don't have descriptions yet, but wire it up for future use.
pass1.set(g.id, ''); pass1.set(g.id, '');
} }
// Pass 2: replace sentinels with the Pass 1 output for the referenced id. // Pass 2: replace sentinels with Pass 1 output of the referenced id.
// One substitution deep — cycles produce one level of expansion then stop. // Strip any remaining sentinels (self-refs, unresolvable cycles) → empty string.
const result = new Map<string, string>(); const result = new Map<string, string>();
for (const [id, p1] of pass1) { for (const [id, p1] of pass1) {
result.set( result.set(
id, 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 * Convenience wrapper for resolving a single description string.
* units/groups context. Builds the full map internally — use resolveAllDescriptions * Use resolveAllDescriptions when resolving many units at once.
* directly when resolving many units at once.
*/ */
export function resolveDescription( export function resolveDescription(
description: string | undefined, description: string | undefined,
@@ -126,9 +122,10 @@ export function resolveDescription(
const unitMap = new Map(units.map((u) => [u.id, u])); const unitMap = new Map(units.map((u) => [u.id, u]));
const groupMap = new Map(groups.map((g) => [g.id, g])); const groupMap = new Map(groups.map((g) => [g.id, g]));
const p1 = resolveFields(description, unitMap, groupMap); const p1 = resolveFields(description, unitMap, groupMap);
return p1.replace(SENTINEL_RE, (_match, refId: string) => { return p1
const unit = unitMap.get(refId); .replace(SENTINEL_RE, (_match, refId: string) => {
if (unit?.description) return resolveFields(unit.description, unitMap, groupMap); const unit = unitMap.get(refId);
return ''; return unit?.description ? resolveFields(unit.description, unitMap, groupMap) : '';
}); })
.replace(SENTINEL_RE, ''); // strip surviving sentinels
} }