From 95fc6b01d59a0cfff1a0c052c65236f3363d296a Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:47:16 +0700 Subject: [PATCH] fix(shared): decode common named HTML entities in plaintext output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit htmlToPlainText (behind the public renderToPlainText, which builds the text/plain MIME part of an email) only mapped ~17 named entities, so common ones such as £, €, é, ° and × leaked into the plaintext as literal entity text (e.g. "Price £5" instead of "Price £5"). Expand the named-entity map to cover currency, typographic punctuation, common symbols, and the Latin-1 accented letters used in European names/words. Kept as a curated map (not the full HTML5 named-reference set) so the ESM bundle stays within its CI size budget; anything omitted still decodes when written as a numeric entity. Unknown entities are still left untouched. --- .../shared/src/utils/html-to-text.test.ts | 33 ++++++++++ packages/shared/src/utils/html-to-text.ts | 62 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/utils/html-to-text.test.ts b/packages/shared/src/utils/html-to-text.test.ts index 66ec89f4..a2e77da1 100644 --- a/packages/shared/src/utils/html-to-text.test.ts +++ b/packages/shared/src/utils/html-to-text.test.ts @@ -77,6 +77,39 @@ describe("htmlToPlainText", () => { expect(htmlToPlainText("©")).toBe("\u00A9"); // © }); + it("decodes common currency and symbol named entities", () => { + expect(htmlToPlainText("Price £5, €9, ¥100, ¢50")).toBe( + "Price £5, €9, ¥100, ¢50" + ); // pound euro yen cent + expect(htmlToPlainText("5° × ½ ÷ ±")).toBe( + "5° × ½ ÷ ±" + ); // deg times frac12 divide plusmn + expect(htmlToPlainText("§2 ¶3 µg")).toBe( + "§2 ¶3 µg" + ); // sect para micro + }); + + it("decodes typographic quote named entities", () => { + expect(htmlToPlainText("“Hi” ‘yo’")).toBe( + "“Hi” ‘yo’" + ); + }); + + it("decodes Latin-1 accented named entities", () => { + // Cafe resume naive Zoe Francois Munoz Strasse (with accents) + expect( + htmlToPlainText( + "Café résumé naïve Zoë François Muñoz Straße" + ) + ).toBe( + "Café résumé naïve Zoë François Muñoz Straße" + ); + }); + + it("leaves unknown named entities untouched", () => { + expect(htmlToPlainText("¬anentity; x")).toBe("¬anentity; x"); + }); + // Skip elements it("skips elements with data-skip-in-text attribute", () => { const html = '
hidden preview

Visible content

'; diff --git a/packages/shared/src/utils/html-to-text.ts b/packages/shared/src/utils/html-to-text.ts index 51be77a9..5fe0b02d 100644 --- a/packages/shared/src/utils/html-to-text.ts +++ b/packages/shared/src/utils/html-to-text.ts @@ -8,9 +8,18 @@ */ /** - * Common HTML entity map for decoding. + * Common HTML named entity map for decoding. + * + * Covers the entities that actually appear in email/marketing content — + * currency, typographic punctuation, common symbols, and the Latin-1 accented + * letters used in European names and words. Kept as a curated map (rather than + * the full HTML5 named-reference set, which is ~2000 entries) so the bundle + * stays within its size budget. Numeric entities (`{`, ``) are + * handled separately below, so anything missing here still decodes if written + * numerically. */ const HTML_ENTITIES: Record = { + // Core markup + whitespace "&": "&", "<": "<", ">": ">", @@ -18,16 +27,67 @@ const HTML_ENTITIES: Record = { "'": "'", "'": "'", " ": " ", + // Dashes, quotes, and typographic punctuation "—": "—", "–": "–", "«": "«", "»": "»", + "‘": "‘", + "’": "’", + "“": "“", + "”": "”", "•": "•", "·": "·", "…": "…", + // Currency + "¢": "¢", + "£": "£", + "¥": "¥", + "€": "€", + // Legal / trademark "©": "©", "®": "®", "™": "™", + "§": "§", + "¶": "¶", + // Math and misc symbols + "°": "°", + "±": "±", + "×": "×", + "÷": "÷", + "µ": "µ", + "¼": "¼", + "½": "½", + "¾": "¾", + // Latin-1 accented letters (common in European names/words) + "ß": "ß", + "à": "à", + "á": "á", + "â": "â", + "ã": "ã", + "ä": "ä", + "å": "å", + "æ": "æ", + "ç": "ç", + "è": "è", + "é": "é", + "ê": "ê", + "ë": "ë", + "ì": "ì", + "í": "í", + "î": "î", + "ï": "ï", + "ñ": "ñ", + "ò": "ò", + "ó": "ó", + "ô": "ô", + "õ": "õ", + "ö": "ö", + "ø": "ø", + "ù": "ù", + "ú": "ú", + "û": "û", + "ü": "ü", }; /**