feat: two-pass description placeholder resolver with cycle handling

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).
This commit is contained in:
Falkan
2026-03-18 17:37:00 -04:00
parent 998cda0bb3
commit d088e61006
2 changed files with 102 additions and 22 deletions

View File

@@ -2,7 +2,7 @@
import type { Unit, Group, ResultItem } from '$lib/types'; import type { Unit, Group, ResultItem } from '$lib/types';
import type { Big } from 'big.js'; import type { Big } from 'big.js';
import { uniformFontSize } from '$lib/actions/uniformFontSize'; import { uniformFontSize } from '$lib/actions/uniformFontSize';
import { resolveDescription } from '$lib/description'; import { resolveAllDescriptions } from '$lib/description';
import UnitInput from './UnitInput.svelte'; import UnitInput from './UnitInput.svelte';
import UnitDropdown from './UnitDropdown.svelte'; import UnitDropdown from './UnitDropdown.svelte';
import ConversionResult from './ConversionResult.svelte'; import ConversionResult from './ConversionResult.svelte';
@@ -36,9 +36,7 @@
} = $props(); } = $props();
// Pre-resolve all unit descriptions so placeholders are expanded at render time. // Pre-resolve all unit descriptions so placeholders are expanded at render time.
let resolvedDescriptions = $derived( let resolvedDescriptions = $derived(resolveAllDescriptions(units, groups));
new Map(units.map((u) => [u.id, resolveDescription(u.description, units, groups)]))
);
</script> </script>
<article> <article>

View File

@@ -1,32 +1,56 @@
import type { Unit, Group } from './types'; 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): * Supported formats (using the unit or group's ID):
* {id} → "Label (symbol)" e.g. "Shit Ton (shttn)" * {id} → "Label (symbol)" e.g. "Shit Ton (shttn)"
* {id:label} → singular label e.g. "Shit Ton" * {id:label} → singular label e.g. "Shit Ton"
* {id:plural} → plural label e.g. "Shit Tons" * {id:plural} → plural label e.g. "Shit Tons"
* {id:symbol} → symbol e.g. "shttn" * {id:symbol} → symbol e.g. "shttn"
* {id:glabel} → group label e.g. "Mass" * {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. * 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, const SENTINEL_PREFIX = '__DESC:';
units: Unit[], const SENTINEL_SUFFIX = '__';
groups: Group[] 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<string, Unit>,
groupMap: Map<string, Group>
): string { ): string {
if (!description) return ''; return description.replace(PLACEHOLDER_RE, (match, token: string) => {
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) => {
const colonIdx = token.indexOf(':'); const colonIdx = token.indexOf(':');
const id = colonIdx === -1 ? token : token.slice(0, colonIdx); const id = colonIdx === -1 ? token : token.slice(0, colonIdx);
const field = colonIdx === -1 ? '' : token.slice(colonIdx + 1); 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); const unit = unitMap.get(id);
if (unit) { if (unit) {
switch (field) { switch (field) {
@@ -41,7 +65,7 @@ export function resolveDescription(
const group = groupMap.get(id); const group = groupMap.get(id);
if (group) { if (group) {
switch (field) { switch (field) {
case '': return group.label; case '':
case 'label': return group.label; case 'label': return group.label;
default: return match; default: return match;
} }
@@ -50,3 +74,61 @@ export function resolveDescription(
return match; // unknown ID — leave as-is 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<string, string> {
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<string, string>();
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<string, string>();
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 '';
});
}