Skip to content

Commit 7f87c5d

Browse files
programadclaude
andcommitted
fix: 🐛 defer HTML entity decoding to leaf renderers
User-typed text in mrkdwn payloads contains Slack-escaped `<`, `>`, `&` chars as `&lt;`, `&gt;`, `&amp;`. Decoding these in `text_object.tsx` BEFORE markdown_parser was incorrect: a literal `<@u123>` typed by a user (delivered as `&lt;@u123&gt;`) decoded to `<@u123>` and got tokenized as a real user mention, firing `hooks.user` and producing a chip when Slack itself would render literal `<@u123>` text. Slack's own renderer tokenizes the raw payload first and decodes entities only at render time — confirmed empirically in section/mrkdwn side-by-side. This change matches that: - Remove entity decoding from text_object.tsx. - Decode in the leaf renderers that emit user-visible text: Text, HTML, InlineCode (and its plain-code path), the fenced Code element, and Link (for the href; label children go through Text). - New tests cover escaped `<@u123>`, `<#C123>`, `<!here>` staying literal, `&amp;` `&lt;` `&gt;` decoding in plain text, and entity decoding inside inline and fenced code. The `&gt;` → "> " trailing-space hack (which existed to make blockquote detection survive entity escaping) is removed. Slack itself does not render blockquotes in section/mrkdwn, so user-typed `> quote` (delivered as `&gt; quote`) no longer renders as a blockquote — which better matches Slack's behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c158e4a commit 7f87c5d

9 files changed

Lines changed: 87 additions & 20 deletions

File tree

.changeset/fix-mrkdwn-directives.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"slack-blocks-to-jsx": patch
33
---
44

5-
Resolve Slack directive atoms (`<@U…>`, `<#C…>`, `<!subteam^…>`, `<!channel|here|everyone>`, `<!date^…>`) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the `rich_text` path. `verbatim: true` matches Slack's empirical behavior — it suppresses bare-form `@here` / `@channel` / `@everyone` interpolation but is otherwise a no-op (markdown sugar, code spans, angle-bracket URLs, and structured `<!…>` directives all render the same in both modes). `&amp;` is now decoded alongside `&gt;` / `&lt;` so escaped ampersands don't leak into hrefs or visible text.
5+
Resolve Slack directive atoms (`<@U…>`, `<#C…>`, `<!subteam^…>`, `<!channel|here|everyone>`, `<!date^…>`) in `section`/`mrkdwn` text and other mrkdwn-typed text. Directives now fire the same hooks as the `rich_text` path. `verbatim: true` matches Slack's empirical behavior — it suppresses bare-form `@here` / `@channel` / `@everyone` interpolation but is otherwise a no-op (markdown sugar, code spans, angle-bracket URLs, and structured `<!…>` directives all render the same in both modes). HTML entity decoding (`&lt;`, `&gt;`, `&amp;`) is deferred from input pre-processing to leaf renderers, so escaped sequences like `&lt;@U123&gt;` (user typed `<@U123>` literally) stay literal instead of being incorrectly resolved as a user mention — matching Slack's renderer.

src/components/composition_objects/text_object.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,19 @@ export const TextObject = (props: TextObjectProps) => {
1212
const { className = "" } = props;
1313
const { channels, users, hooks } = useGlobalData();
1414

15-
// Order matters: decode `&gt;` and `&lt;` before `&amp;` so a literal escaped `&gt;`
16-
// (which arrives as `&amp;gt;`) doesn't get double-decoded.
17-
// The `&gt;` → `"> "` trailing space is intentional — it keeps blockquote line detection
18-
// (`^>` in parser.tsx) working when Slack escapes the `>` of a quote line.
19-
const parsed = text.replace(/&gt;/g, "> ").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
20-
15+
// HTML entity decoding is deferred to the leaf renderers (Text, HTML, InlineCode, Code,
16+
// Link) so escaped sequences like `&lt;@U123&gt;` (user typed `<@U123>` literally) don't
17+
// get tokenized as directives. See decode_entities.ts.
2118
if (type === "plain_text")
2219
return (
2320
<div className={className + " dark:text-dark-text-primary"}>
24-
{markdown_parser(parsed, { markdown: false, verbatim, users, channels, hooks })}
21+
{markdown_parser(text, { markdown: false, verbatim, users, channels, hooks })}
2522
</div>
2623
);
2724

2825
return (
2926
<div className={className + " dark:text-dark-text-primary"}>
30-
{markdown_parser(parsed, { markdown: true, verbatim, users, channels, hooks })}
27+
{markdown_parser(text, { markdown: true, verbatim, users, channels, hooks })}
3128
</div>
3229
);
3330
};

src/utils/markdown_parser/__tests__/directives.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,56 @@ describe("escaped entities", () => {
201201
expect(anchor!.getAttribute("href")).toBe("https://example.test/?a=1&b=2");
202202
expect(anchor!.textContent).toBe("report");
203203
});
204+
205+
// Slack escapes literal `<`, `>`, `&` in user-typed text. If the user typed `<@U123>`
206+
// literally (not as a directive), Slack delivers `&lt;@U123&gt;` in the payload. Our
207+
// renderer must keep this as literal text — NOT resolve it as a user mention. Slack's
208+
// own renderer behaves the same way (empirically verified — the "escaped" row in the PR
209+
// description's side-by-side stays as literal `<@U123>`).
210+
it("does NOT resolve a user-typed escaped directive as a mention", () => {
211+
const user = vi.fn();
212+
const { container } = renderMrkdwn(`escaped: &lt;@U123&gt;`, false, {
213+
hooks: { user },
214+
data: { users: [{ id: "U123", name: "alice" }] },
215+
});
216+
expect(user).not.toHaveBeenCalled();
217+
expect(container.textContent).toContain("<@U123>");
218+
});
219+
220+
it("does NOT resolve a user-typed escaped channel mention", () => {
221+
const channel = vi.fn();
222+
const { container } = renderMrkdwn(`escaped: &lt;#C123&gt;`, false, {
223+
hooks: { channel },
224+
data: { channels: [{ id: "C123", name: "general" }] },
225+
});
226+
expect(channel).not.toHaveBeenCalled();
227+
expect(container.textContent).toContain("<#C123>");
228+
});
229+
230+
it("does NOT resolve a user-typed escaped broadcast", () => {
231+
const atHere = vi.fn();
232+
const { container } = renderMrkdwn(`escaped: &lt;!here&gt;`, false, {
233+
hooks: { atHere },
234+
});
235+
expect(atHere).not.toHaveBeenCalled();
236+
expect(container.textContent).toContain("<!here>");
237+
});
238+
239+
it("decodes escaped entities in plain text rendering", () => {
240+
const { container } = renderMrkdwn(`a &amp; b &lt; c &gt; d`, false);
241+
expect(container.textContent).toContain("a & b < c > d");
242+
});
243+
244+
it("decodes escaped entities inside inline code", () => {
245+
const { container } = renderMrkdwn("`&lt;script&gt;alert(1)&lt;/script&gt;`", false);
246+
expect(container.querySelector("code")?.textContent).toBe("<script>alert(1)</script>");
247+
});
248+
249+
it("decodes escaped entities inside fenced code", () => {
250+
const { container } = renderMrkdwn("```\n&lt;script&gt;\n```", false);
251+
const code = container.querySelector("code");
252+
expect(code?.textContent).toContain("<script>");
253+
});
204254
});
205255

206256
describe("regression — non-directive markdown", () => {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Slack escapes literal `&`, `<`, `>` in user-typed text as `&amp;`, `&lt;`, `&gt;` before the
2+
// mrkdwn payload reaches us. We MUST decode them for display, but only at render time —
3+
// decoding earlier would let escaped sequences like `&lt;@U123&gt;` (which a user typed
4+
// literally) get tokenized as a real `<@U123>` user mention. Slack's own renderer behaves the
5+
// same way: it tokenizes the raw payload first and decodes entities only when emitting text.
6+
//
7+
// Order matters: decode `&lt;` and `&gt;` first, then `&amp;`. If a literal `&gt;` was itself
8+
// escaped (it arrives as `&amp;gt;`), this order keeps it as `&gt;` instead of double-decoding
9+
// to `>`.
10+
export const decodeEntities = (s: string): string =>
11+
s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");

src/utils/markdown_parser/elements/code.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { decodeEntities } from "../decode_entities";
12
import { CodeElement } from "../types";
23

34
type Props = {
@@ -7,5 +8,5 @@ type Props = {
78
export const Code = (props: Props) => {
89
const { element } = props;
910

10-
return <code className="slack_code block p-2 text-xs whitespace-pre-wrap break-words rounded-[3px] border border-black-primary/[0.13] dark:border-dark-code-border bg-black-primary/[0.04] dark:bg-dark-code-bg dark:text-dark-text-primary w-full my-1 font-mono">{element.value}</code>;
11+
return <code className="slack_code block p-2 text-xs whitespace-pre-wrap break-words rounded-[3px] border border-black-primary/[0.13] dark:border-dark-code-border bg-black-primary/[0.04] dark:bg-dark-code-bg dark:text-dark-text-primary w-full my-1 font-mono">{decodeEntities(element.value)}</code>;
1112
};

src/utils/markdown_parser/sub_elements/html.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { decodeEntities } from "../decode_entities";
12
import { HTMLSubElement } from "../types";
23

34
type Props = {
@@ -6,13 +7,14 @@ type Props = {
67

78
export const HTML = (props: Props) => {
89
const { element } = props;
10+
const value = decodeEntities(element.value);
911

10-
if (!element.value) return <span>{element.value}</span>;
11-
if (element.value === " ") return <span>&nbsp;</span>;
12+
if (!value) return <span>{value}</span>;
13+
if (value === " ") return <span>&nbsp;</span>;
1214

1315
return (
1416
<span>
15-
{element.value.split("LBKS").map((line, index) => {
17+
{value.split("LBKS").map((line, index) => {
1618
if (line === "") {
1719
return <span key={index} className="block h-2"></span>;
1820
}

src/utils/markdown_parser/sub_elements/inline_code.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ReactNode } from "react";
2+
import { decodeEntities } from "../decode_entities";
23
import { InlineCodeSubElement } from "../types";
34

45
type Props = {
@@ -62,7 +63,8 @@ function parseInlineCodeValue(value: string): ParsedPart[] {
6263

6364
export const InlineCode = (props: Props) => {
6465
const { element } = props;
65-
const parts = parseInlineCodeValue(element.value);
66+
const value = decodeEntities(element.value);
67+
const parts = parseInlineCodeValue(value);
6668

6769
// If the entire value is just a single link, render without code wrapper
6870
const firstPart = parts[0];
@@ -83,7 +85,7 @@ export const InlineCode = (props: Props) => {
8385
if (parts.every((part) => part.type === "text")) {
8486
return (
8587
<code className="slack_code_inline inline-block px-1 text-xs whitespace-pre-wrap break-words rounded-[3px] border border-black-primary/[0.13] dark:border-dark-code-border bg-black-primary/[0.04] dark:bg-dark-code-bg text-red-primary dark:text-dark-text-primary font-mono">
86-
{element.value}
88+
{value}
8789
</code>
8890
);
8991
}

src/utils/markdown_parser/sub_elements/link.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useGlobalData } from "../../../store";
2+
import { decodeEntities } from "../decode_entities";
23
import { LinkSubElement } from "../types";
34
import { Delete } from "./delete";
45
import { Emphasis } from "./emphasis";
@@ -13,12 +14,13 @@ type Props = {
1314
export const Link = (props: Props) => {
1415
const { element } = props;
1516
const { hooks } = useGlobalData();
17+
const href = decodeEntities(element.url);
1618

1719
if (hooks.link) {
1820
return (
1921
<>
2022
{hooks.link({
21-
href: element.url,
23+
href,
2224
children: (
2325
<>
2426
{element.children.map((child, i) => {
@@ -38,7 +40,7 @@ export const Link = (props: Props) => {
3840
}
3941

4042
return (
41-
<a href={element.url} className="slack_link">
43+
<a href={href} className="slack_link">
4244
{element.children.map((child, i) => {
4345
if (child.type === "delete") return <Delete key={i} element={child} />;
4446
if (child.type === "emphasis") return <Emphasis key={i} element={child} />;

src/utils/markdown_parser/sub_elements/text.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { decodeEntities } from "../decode_entities";
12
import { TextSubElement } from "../types";
23

34
type Props = {
@@ -6,13 +7,14 @@ type Props = {
67

78
export const Text = (props: Props) => {
89
const { element } = props;
10+
const value = decodeEntities(element.value);
911

10-
if (!element.value) return <span>{element.value}</span>;
11-
if (element.value === " ") return <span>&nbsp;</span>;
12+
if (!value) return <span>{value}</span>;
13+
if (value === " ") return <span>&nbsp;</span>;
1214

1315
return (
1416
<span>
15-
{element.value.split("LBKS").map((line, index) => {
17+
{value.split("LBKS").map((line, index) => {
1618
if (line === "") {
1719
return <span key={index} className="block h-2"></span>;
1820
}

0 commit comments

Comments
 (0)