From d088e61006765576c924dfe7a0db861a82df5ee3 Mon Sep 17 00:00:00 2001 From: Falkan Date: Wed, 18 Mar 2026 17:37:00 -0400 Subject: [PATCH] feat: two-pass description placeholder resolver with cycle handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 1 resolves all field tokens ({id}, {id:label}, {id:plural}, {id:symbol}) and replaces {id:description} tokens with sentinels. Pass 2 replaces sentinels with Pass 1 output for the referenced id — one level deep, so mutual references expand once then stop cleanly. ConverterCard now uses resolveAllDescriptions() for efficiency (one map build per reactive update instead of per-unit). --- src/components/ConverterCard.svelte | 6 +- src/lib/description.ts | 118 +++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/components/ConverterCard.svelte b/src/components/ConverterCard.svelte index 52cd32b..64edf22 100644 --- a/src/components/ConverterCard.svelte +++ b/src/components/ConverterCard.svelte @@ -2,7 +2,7 @@ import type { Unit, Group, ResultItem } from '$lib/types'; import type { Big } from 'big.js'; import { uniformFontSize } from '$lib/actions/uniformFontSize'; - import { resolveDescription } from '$lib/description'; + import { resolveAllDescriptions } from '$lib/description'; import UnitInput from './UnitInput.svelte'; import UnitDropdown from './UnitDropdown.svelte'; import ConversionResult from './ConversionResult.svelte'; @@ -36,9 +36,7 @@ } = $props(); // Pre-resolve all unit descriptions so placeholders are expanded at render time. - let resolvedDescriptions = $derived( - new Map(units.map((u) => [u.id, resolveDescription(u.description, units, groups)])) - ); + let resolvedDescriptions = $derived(resolveAllDescriptions(units, groups));
diff --git a/src/lib/description.ts b/src/lib/description.ts index 36ba995..c796389 100644 --- a/src/lib/description.ts +++ b/src/lib/description.ts @@ -1,32 +1,56 @@ import type { Unit, Group } from './types'; /** - * Resolves placeholder tokens in a description string. + * Resolves placeholder tokens in description strings. * - * Supported formats (using the unit/group's ID): - * {id} → "Label (symbol)" e.g. "Shit Ton (shttn)" - * {id:label} → singular label e.g. "Shit Ton" - * {id:plural} → plural label e.g. "Shit Tons" - * {id:symbol} → symbol e.g. "shttn" - * {id:glabel} → group label e.g. "Mass" + * Supported formats (using the unit or group's ID): + * {id} → "Label (symbol)" e.g. "Shit Ton (shttn)" + * {id:label} → singular label e.g. "Shit Ton" + * {id:plural} → plural label e.g. "Shit Tons" + * {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. + * + * 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. */ -export function resolveDescription( - description: string | undefined, - units: Unit[], - groups: Group[] + +const SENTINEL_PREFIX = '__DESC:'; +const SENTINEL_SUFFIX = '__'; +const PLACEHOLDER_RE = /\{([^}]+)\}/g; +const SENTINEL_RE = /__DESC:([^_]+)__/g; + +/** Resolve all non-description placeholder fields in a single string. */ +function resolveFields( + description: string, + unitMap: Map, + groupMap: Map ): string { - if (!description) return ''; - - const unitMap = new Map(units.map((u) => [u.id, u])); - const groupMap = new Map(groups.map((g) => [g.id, g])); - - return description.replace(/\{([^}]+)\}/g, (match, token: string) => { + return description.replace(PLACEHOLDER_RE, (match, token: string) => { const colonIdx = token.indexOf(':'); const id = colonIdx === -1 ? token : token.slice(0, colonIdx); const field = colonIdx === -1 ? '' : token.slice(colonIdx + 1); + // {id:description} — defer to pass 2 + if (field === 'description') { + return `${SENTINEL_PREFIX}${id}${SENTINEL_SUFFIX}`; + } + const unit = unitMap.get(id); if (unit) { switch (field) { @@ -41,7 +65,7 @@ export function resolveDescription( const group = groupMap.get(id); if (group) { switch (field) { - case '': return group.label; + case '': case 'label': return group.label; default: return match; } @@ -50,3 +74,61 @@ export function resolveDescription( return match; // unknown ID — leave as-is }); } + +/** + * 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. + */ +export function resolveAllDescriptions( + units: Unit[], + groups: Group[] +): Map { + 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. + 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. + const result = new Map(); + for (const [id, p1] of pass1) { + result.set( + id, + p1.replace(SENTINEL_RE, (_match, refId: string) => pass1.get(refId) ?? '') + ); + } + + return result; +} + +/** + * 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. + */ +export function resolveDescription( + description: string | undefined, + units: Unit[], + groups: Group[] +): string { + if (!description) return ''; + 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 ''; + }); +}