diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6761a1..322c245 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+- **Adaptive Cards: app text is never Markdown.** A `TextBlock` or `Fact`
+ whose text has Markdown syntax is a `RichTextBlock` `TextRun` now: a
+ model value or toast like `[verify your account](https://evil.example)`
+ was a live link in Teams and Copilot.
+- **`htmlToText` is linear.** Its patterns scanned from every `<` to the
+ end of the text (and the ` tried every "<" to the end of the text - quadratic,
+ // 100k characters of "<" held the renderer for seconds
+ return dropBlocks(String(html || ""))
+ .replace(/<(br|\/p|\/li|\/div|\/h[1-6])\b[^<>]*>/gi, "\n")
+ .replace(/
]*>/gi, "- ")
+ .replace(/<[^<>]*>/g, "")
.replace(/ /g, " ")
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&")
.split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
diff --git a/test/adaptive-cards.test.mjs b/test/adaptive-cards.test.mjs
index b28a737..0223490 100644
--- a/test/adaptive-cards.test.mjs
+++ b/test/adaptive-cards.test.mjs
@@ -8,7 +8,7 @@ import fs from "node:fs";
import path from "node:path";
import { test } from "node:test";
import {
- renderResponses, submitToRequest, createCardHost, cardText, walk, CONTROLS, TOLERATED, CARD_VERSION,
+ renderResponses, submitToRequest, createCardHost, cardText, walk, htmlToText, CONTROLS, TOLERATED, CARD_VERSION,
} from "../renderers/adaptive-cards/index.mjs";
import { renderMapping, apply, README } from "../scripts/render-adaptive-cards.mjs";
import { startMock } from "../conformance/frontend/index.mjs";
@@ -127,6 +127,41 @@ test("the cards say what the views say", () => {
assert.deepEqual(unsupported.map((u) => u.control), ["com.example.unknown.Gadget"]);
});
+test("app text is never Markdown: a text or fact with Markdown syntax is a TextRun", () => {
+ const link = "Customer [click me](https://evil.example)";
+ const xml = ''
+ + ''
+ + '
';
+ const response = {
+ S_FRONT: { ID: "1", APP: "Z_T", S_ACTION: { T_SYSTEM: [["VIEW_SLOTS", "display", "MAIN", xml]] } },
+ MODEL: { C: link, L: [{ T: "a", D: "[x](https://evil.example)" }] },
+ };
+ const toast = "**[verify your account](https://evil.example/login)**";
+ const { card } = renderResponses([response], { messages: [{ kind: "toast", text: toast }] });
+ assert.deepEqual(cardProblems(card), []);
+ const blocks = [];
+ const runs = [];
+ walk(card.body, (e) => {
+ if (e.type === "TextBlock") blocks.push(e.text);
+ if (e.type === "FactSet") blocks.push(...e.facts.flatMap((f) => [f.title, f.value]));
+ if (e.type === "RichTextBlock") runs.push(...e.inlines.map((i) => i.text));
+ });
+ assert.ok(blocks.includes("plain"), "a plain text stays a TextBlock");
+ assert.ok(!blocks.some((t) => /\]\(|\*\*/.test(t)), `no Markdown reaches a TextBlock or a Fact: ${blocks.join(" | ")}`);
+ for (const t of [link, "**bold** title", toast, "[x](https://evil.example)"]) assert.ok(runs.includes(t), `${t} as a TextRun`);
+});
+
+test("htmlToText: script and style dropped to their closing tag, and linear on unclosed markup", () => {
+ assert.equal(htmlToText("a
b
c
"), "a\nb\nc\n- d");
+ assert.equal(htmlToText("a < b c"), "a < b c", "a lone < is text");
+ assert.equal(htmlToText("