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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
.replace(SENTINEL_RE, (_match, refId: string) => {
|
||||||
const unit = unitMap.get(refId);
|
const unit = unitMap.get(refId);
|
||||||
if (unit?.description) return resolveFields(unit.description, unitMap, groupMap);
|
return unit?.description ? resolveFields(unit.description, unitMap, groupMap) : '';
|
||||||
return '';
|
})
|
||||||
});
|
.replace(SENTINEL_RE, ''); // strip surviving sentinels
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user