diff --git a/src/__tests__/fixtures/orders/real/dd-bull-entity.html b/src/__tests__/fixtures/orders/real/dd-bull-entity.html new file mode 100644 index 0000000..7ebc40a --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-bull-entity.html @@ -0,0 +1,3 @@ + DoorDash
DOORDASH
+ + \ No newline at end of file diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts index fc7aa68..58839b9 100644 --- a/src/__tests__/unit/order-ingestion.test.ts +++ b/src/__tests__/unit/order-ingestion.test.ts @@ -488,3 +488,36 @@ describe("payment legs", () => { expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true); }); }); + +describe("HTML entities in line items", () => { + it("splits options on an encoded bullet instead of swallowing it", () => { + // "Sweet & Sour Crunch (…)
• Sweet & Sour + // Crunch 12 Pieces". • was not in the decode table, and + // parseDoorDashLineItems splits on the literal "•" — so the option never + // separated and the entity was rendered raw in the Order details panel. + const o = parseOrderHTML( + html("dd-bull-entity"), + meta({ subject: "Order Confirmation for Siddharth from Red Rooster" }) + ); + const all = JSON.stringify(o.line_items); + expect(all).not.toContain("•"); + expect(all).not.toContain("&"); + + const item = o.line_items.find((i) => i.description.startsWith("Sweet")); + expect(item).toBeDefined(); + // The ampersand decodes, and the bullet becomes a boundary, not text. + expect(item!.description).toContain("Sweet & Sour Crunch"); + expect(item!.description).not.toContain("•"); + expect(item!.options.length).toBeGreaterThan(0); + }); + + it("leaves a literal &bull; alone rather than turning it into a bullet", () => { + // Why & resolves last: decoding it first would rewrite text that was + // deliberately escaped. + const o = parseOrderHTML( + html("dd-01").replace("Mad Mex", "A&amp;bull;B"), + meta() + ); + expect(JSON.stringify(o)).not.toContain("A•B".replace("•", "•")); + }); +}); diff --git a/src/lib/order-parse.ts b/src/lib/order-parse.ts index 113980c..cd08bcc 100644 --- a/src/lib/order-parse.ts +++ b/src/lib/order-parse.ts @@ -101,14 +101,35 @@ export class OrderParseError extends Error { const stripTags = (s: string) => s.replace(/<[^>]+>/g, " "); +/** + * Entity decoding, ordered so `&` resolves last. + * + * `•` was missing, and it is not a cosmetic omission: DoorDash separates + * an item's name from its options with a bullet, and parseDoorDashLineItems + * splits on the literal "•". Left encoded, the split never happens and the + * whole line collapses into the description — "Fire Extinguisher (Chicken + * Burgers) • Regular" instead of a name plus one option. So the entity + * showed up on screen AND the structure behind it was lost. + * + * Numeric entities are decoded generically rather than one at a time, which is + * how $ came to be listed individually while its neighbours were not. + * + * `&` goes last because decoding it first turns a literal "&bull;" — + * text that should stay as written — into a bullet. + */ const decodeEntities = (s: string) => s .replace(/ /gi, " ") - .replace(/&/gi, "&") .replace(/'|'/gi, "'") .replace(/"/gi, '"') - .replace(/$/g, "$") - .replace(/…/gi, "…"); + .replace(/…/gi, "…") + .replace(/•/gi, "•") + .replace(/·/gi, "·") + .replace(/–/gi, "–") + .replace(/—/gi, "—") + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10))) + .replace(/&/gi, "&"); const collapse = (s: string) => s.replace(/\s+/g, " ").trim();