fix: cycle references resolve to Label (symbol) instead of empty

When a {id:description} reference forms a cycle, the back-reference
now expands to 'Label (symbol)' rather than being stripped.
So pound ↔ dickload renders as 'See dickload (dl)' and 'See pound (lb)'.
This commit is contained in:
Falkan
2026-03-18 19:59:33 -04:00
parent d332cc135e
commit 2c27edc487
2 changed files with 23 additions and 12 deletions

View File

@@ -15,14 +15,13 @@ import type { Unit, Group } from './types';
* Replace {id:description} tokens with a sentinel \x00DESC:id\x00
* (null-byte delimited — can't appear in user-authored text).
* Pass 2: replace each sentinel with the Pass 1 output for the referenced id.
* Since Pass 1 output contains only sentinels (not live {id:description}
* tokens), this is one substitution deep. Any remaining sentinels after
* Pass 2 (self-references, unresolvable cycles) are stripped to empty.
* Any sentinels that survive after that substitution are cycles — they
* are resolved as "Label (symbol)" (the {id} expansion) rather than
* leaving an empty slot.
*
* Result for a mutual reference (id0 ↔ id2):
* - 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.
* Result for a mutual reference (pound ↔ dickload):
* pound's description: "See dickload (dl)"
* dickload's description: "See pound (lb)"
*/
// Null-byte delimiters can't appear in user-authored strings.
@@ -94,15 +93,21 @@ export function resolveAllDescriptions(
}
// Pass 2: replace sentinels with Pass 1 output of the referenced id.
// Strip any remaining sentinels (self-refs, unresolvable cycles) → empty string.
// Any sentinels surviving after that substitution are cycles — resolve them
// as Label (symbol) instead of leaving an empty slot.
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) ?? '')
// Strip any sentinels that survived (self-references or nested cycles)
.replace(SENTINEL_RE, '')
.replace(SENTINEL_RE, (_match, refId: string) => {
const u = unitMap.get(refId);
if (u) return `${u.label} (${u.symbol})`;
const g = groupMap.get(refId);
if (g) return g.label;
return '';
})
);
}
@@ -127,5 +132,11 @@ export function resolveDescription(
const unit = unitMap.get(refId);
return unit?.description ? resolveFields(unit.description, unitMap, groupMap) : '';
})
.replace(SENTINEL_RE, ''); // strip surviving sentinels
.replace(SENTINEL_RE, (_match, refId: string) => {
const u = unitMap.get(refId);
if (u) return `${u.label} (${u.symbol})`;
const g = groupMap.get(refId);
if (g) return g.label;
return '';
});
}