diff --git a/src/lib/utils/text-parser.test.ts b/src/lib/utils/text-parser.test.ts index d2ad7e1..672c5a6 100644 --- a/src/lib/utils/text-parser.test.ts +++ b/src/lib/utils/text-parser.test.ts @@ -117,4 +117,11 @@ describe('stripRichTextMarkup', () => { expect(out).not.toContain('{{'); expect(out.toLowerCase()).toContain('handshake'); }); + + it('leaves Mermaid node-shape syntax untouched (non-slug ids)', () => { + // Mermaid reuses {{…}} (hexagon) and [[…]] (subroutine) — these are not + // our refs and must survive stripping so diagram labels stay valid. + expect(stripRichTextMarkup('T3{{"Middlebox"}}')).toBe('T3{{"Middlebox"}}'); + expect(stripRichTextMarkup('N[[Subroutine Step]]')).toBe('N[[Subroutine Step]]'); + }); }); diff --git a/src/lib/utils/text-parser.ts b/src/lib/utils/text-parser.ts index a9859a9..0a2ceca 100644 --- a/src/lib/utils/text-parser.ts +++ b/src/lib/utils/text-parser.ts @@ -243,6 +243,10 @@ function bracketFallbackLabel(rawId: string): string { } } +/** A real cross-reference id is a lowercase slug (optionally `prefix:id` or + * `part/chapter`). Used to tell our refs apart from Mermaid shape syntax. */ +const REF_ID = /^[a-z0-9][a-z0-9:/-]*$/; + /** * Strip rich-text atoms ([[…]] / {{…}} / **…**) down to their label * text. For surfaces that render strings as raw DOM text (screen-reader @@ -261,12 +265,19 @@ export function stripRichTextMarkup(raw: string): string { // abbreviation, concept term, …) rather than vanishing — otherwise a bare // `[[tcp]]` in a stripped field (tooltip / aria-label) would silently drop // the word. + // + // IMPORTANT: only resolve when the id is slug-shaped (lowercase kebab/colon). + // Mermaid reuses `{{…}}` (hexagon) and `[[…]]` (subroutine) for node *shapes*, + // e.g. `T3{{"Middlebox"}}`. Those aren't our refs — left untouched so this + // function (which also cleans Mermaid node labels) doesn't corrupt diagrams. return raw - .replace(/\[\[([^\][|]+)(?:\|([^\]]+))?\]\]/g, (_m, id, label) => - label != null ? label : bracketFallbackLabel(id) - ) - .replace(/\{\{([^}{|]+)(?:\|([^}]+))?\}\}/g, (_m, id, label) => - label != null ? label : (getConceptById(id)?.term ?? id) - ) + .replace(/\[\[([^\][|]+)(?:\|([^\]]+))?\]\]/g, (m, id, label) => { + if (label != null) return label; + return REF_ID.test(id) ? bracketFallbackLabel(id) : m; + }) + .replace(/\{\{([^}{|]+)(?:\|([^}]+))?\}\}/g, (m, id, label) => { + if (label != null) return label; + return REF_ID.test(id) ? (getConceptById(id)?.term ?? id) : m; + }) .replace(/\*\*([^*]+)\*\*/g, '$1'); }