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:
@@ -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>
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user