Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script>` pattern lazily to every later
closing tag): 100k characters of `<` in a message box's details held the
renderer for seconds. A lone `<` is now kept as text.
- **Terminal renderer** (`renderers/terminal/`, export
`@abap2ui5/protocol/renderers/terminal`, CLI `abap2ui5-tui <url> [--app
<CLASS>] [--user u --password p | --cookie c] [--print] [--no-color]`):
Expand Down
9 changes: 9 additions & 0 deletions renderers/adaptive-cards/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ node renderers/adaptive-cards/demo.mjs --file response.json # a response o
- **Errors** are shown verbatim in a `RichTextBlock` `TextRun` - not
markdown, so nothing of an error body is interpreted
([spec/errors.md](../../spec/errors.md#what-a-frontend-does-with-it)).
- **App text is never Markdown.** A `TextBlock` and a `Fact` render their
text as Markdown, and their text is the app's: a model value
`[click me](https://evil.example)` was a live link in Teams. A text with
Markdown syntax (`* _ [ ] # ~` and backticks, a backslash, a list or quote
marker at a line start) is a `RichTextBlock` of one `TextRun` instead,
with the weight, size, color and subtlety it had (`maxLines` and the
heading style are the `TextBlock`'s own); a `FactSet` with such a fact is
a `Container` of one `RichTextBlock` per fact. Plain text stays a
`TextBlock`.
- **Tolerance.** An element outside the profile becomes a placeholder
`TextBlock` and an entry of `unsupported` (`{ slot, control, id?, reason
}`), as do event wires a card cannot raise (`change`, `liveChange`,
Expand Down
12 changes: 8 additions & 4 deletions renderers/adaptive-cards/golden/nest.card.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@
"id": "slot-NEST",
"items": [
{
"type": "TextBlock",
"text": "[NEST view - not rendered]",
"isSubtle": true,
"wrap": true
"type": "RichTextBlock",
"inlines": [
{
"type": "TextRun",
"text": "[NEST view - not rendered]",
"isSubtle": true
}
]
}
]
}
Expand Down
12 changes: 8 additions & 4 deletions renderers/adaptive-cards/golden/sampler.card.json
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,14 @@
]
},
{
"type": "TextBlock",
"text": "[com.example.unknown.Gadget - not rendered]",
"isSubtle": true,
"wrap": true
"type": "RichTextBlock",
"inlines": [
{
"type": "TextRun",
"text": "[com.example.unknown.Gadget - not rendered]",
"isSubtle": true
}
]
},
{
"type": "ActionSet",
Expand Down
35 changes: 35 additions & 0 deletions renderers/adaptive-cards/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,45 @@ export function renderCard(state, { messages = [], error = null, typed = null }
if (slot !== "MAIN") c.style = "emphasis";
body.push(c);
}
verbatim(body);
const card = { type: "AdaptiveCard", $schema: CARD_SCHEMA, version: CARD_VERSION, body };
return { card, unsupported };
}

/* A TextBlock renders its text as Markdown, and its text is the app's: a
* customer named "[click me](https://evil.example)" or a toast with
* "**[verify your account](...)**" was a live link in Teams and Copilot,
* where the UI5 frontend shows the text as it is (a Fact the same). A text with Markdown
* syntax becomes a RichTextBlock of one TextRun - never Markdown, as the
* error container already is - keeping weight, size, color and subtlety;
* a plain text stays a TextBlock (maxLines and the heading style are
* TextBlock's own). */
const MARKDOWN = /[\\*_[\]`~#]|^\s*(?:[-+>]|\d+[.)])\s/m;
const RUN_PROPS = ["weight", "size", "color", "isSubtle", "fontType"];
const KEPT_PROPS = ["id", "spacing", "separator", "isVisible", "horizontalAlignment", "height"];

function verbatim(body) {
walk(body, (e) => {
// a Fact's title and value are Markdown too: a FactSet with any becomes
// a Container of one RichTextBlock per fact, the title bolder
if (e.type === "FactSet" && Array.isArray(e.facts) && e.facts.some((f) => MARKDOWN.test(`${f.title}\n${f.value}`))) {
const items = e.facts.map((f) => ({ type: "RichTextBlock", inlines: [{ type: "TextRun", text: `${f.title} `, weight: "Bolder" }, { type: "TextRun", text: String(f.value) }] }));
const kept = {};
for (const k of KEPT_PROPS) if (e[k] !== undefined) kept[k] = e[k];
for (const k of Object.keys(e)) delete e[k];
Object.assign(e, { type: "Container", ...kept, items });
return;
}
if (e.type !== "TextBlock" || typeof e.text !== "string" || !MARKDOWN.test(e.text)) return;
const run = { type: "TextRun", text: e.text };
for (const k of RUN_PROPS) if (e[k] !== undefined) run[k] = e[k];
const block = { type: "RichTextBlock", inlines: [run] };
for (const k of KEPT_PROPS) if (e[k] !== undefined) block[k] = e[k];
for (const k of Object.keys(e)) delete e[k];
Object.assign(e, block);
});
}

/** The text a card shows (for tests and the conformance adapter). */
export function cardText(items) {
const out = [];
Expand Down
38 changes: 33 additions & 5 deletions renderers/common/view.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,42 @@ export function parseSlot(xml) {
return /^(View|FragmentDefinition)$/.test(doc.local) ? doc.children : [doc];
}

/* <script> and <style> blocks with their content, each to its first closing
* tag; one without a closing tag stays (its tag goes with the other tags),
* and so does every later one of that name - none can close either. */
function dropBlocks(s) {
const open = /<(script|style)\b/gi;
const unclosed = new Set();
let out = "";
let at = 0;
let m;
while ((m = open.exec(s))) {
const name = m[1].toLowerCase();
if (unclosed.has(name)) continue;
const close = new RegExp(`</${name}\\s*>`, "gi");
close.lastIndex = open.lastIndex;
const c = close.exec(s);
if (!c) {
unclosed.add(name);
continue;
}
out += `${s.slice(at, m.index)} `;
at = close.lastIndex;
open.lastIndex = at;
}
return out + s.slice(at);
}

/** The text of HTML (a message box's details, core:HTML): tags dropped,
* entities decoded - nothing of the markup is rendered. */
export function htmlToText(html) {
return String(html || "")
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, " ")
.replace(/<(br|\/p|\/li|\/div|\/h[1-6])\b[^>]*>/gi, "\n")
.replace(/<li\b[^>]*>/gi, "- ")
.replace(/<[^>]*>/g, "")
// no pattern here scans past the next "<": /<[^>]*>/ and the lazy
// <script>...</script> 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(/<li\b[^<>]*>/gi, "- ")
.replace(/<[^<>]*>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/g, "&")
.split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
Expand Down
37 changes: 36 additions & 1 deletion test/adaptive-cards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = '<mvc:View xmlns="sap.m" xmlns:mvc="sap.ui.core.mvc"><Page title="T">'
+ '<Text text="{/C}"/><Title text="**bold** title"/><Text text="plain"/>'
+ '<List items="{/L}"><StandardListItem title="{T}" description="{D}"/></List></Page></mvc:View>';
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("<p>a</p><script>x<b>y</b></script><STYLE>z</style >b<br>c<br><ul><li>d</li></ul>"), "a\nb\nc\n- d");
assert.equal(htmlToText("a < b <i>c</i>"), "a < b c", "a lone < is text");
assert.equal(htmlToText("<script>never closed <b>x</b>"), "never closed x", "an unclosed block stays");
for (const flood of ["<".repeat(100000), "<script".repeat(20000), "<br".repeat(30000)]) {
const t0 = Date.now();
htmlToText(flood);
assert.ok(Date.now() - t0 < 500, `${flood.slice(0, 8)}...: ${Date.now() - t0} ms`);
}
});

// ---------------------------------------------------- the reverse step ----

test("an Action.Submit payload becomes the next request: changed inputs as the model delta", () => {
Expand Down
Loading