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
| | | | Thanks for your order, Siddharth | | The estimated delivery time for your order is 1:12 pm - 1:24 pm. Track your order in the DoorDash app or website. | | | | | | | |  | | | | Paid with credits Red Rooster | | Total: $0.00 | | | Your receipt | | 19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia | | - For: Siddharth Bose -
| 1x | Sweet & Sour Crunch (NEW! Sweet & Sour Crunch) • Sweet & Sour Crunch 12 Pieces (15991 kJ).
| $41.95 |
| | | | | | | | | | | | | | | | | | |  | | |
+
+
\ 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 • 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•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 "•" —
+ * 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(/([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();