Skip to content

Commit bb2b269

Browse files
Merge pull request #74 from themashcodee/claude/pensive-goldberg-DV4yC
Add emoji interpolation to markdown blocks
2 parents bdbc73c + 4dadd71 commit bb2b269

8 files changed

Lines changed: 233 additions & 5 deletions

File tree

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ jobs:
2121
cache: "pnpm"
2222

2323
- run: pnpm install --frozen-lockfile
24-
- run: pnpm run lint && pnpm run build
24+
- run: pnpm run lint && pnpm run build && pnpm test

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ scripts
33
RELEASING.md
44
node_modules
55
src
6+
test
67
.env
78
.prettierrc
89
CHANELOG.md

KNOWLEDGE_BASE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ All block components live in `src/components/blocks/`. 17 block types are suppor
325325
| ---------- | -------------------- | --------------------------------------------------------------------------------------------------------------- |
326326
| `context` | `context.tsx` | Up to 10 mixed text + image elements |
327327
| `table` | `table.tsx` | `cells: Cell[][]`, optional `size`; cell alignment; bordered |
328-
| `markdown` | `markdown_block.tsx` | Renders **standard** GFM via `react-markdown` + `remark-gfm`; tables, task lists, code blocks, syntax highlight |
328+
| `markdown` | `markdown_block.tsx` | Renders **standard** GFM via `react-markdown` + `remark-gfm`; tables, task lists, code blocks, syntax highlight. Emoji shortcodes are interpolated by the `remarkSlackEmoji` plugin, reusing the same `<SlackEmoji>` component (and `hooks.emoji`) as the mrkdwn parser |
329329

330330
### 4.5 AI/workflow blocks
331331

@@ -752,6 +752,7 @@ slack blocks to jsx library/
752752
│ ├── is_accessory_stacked.ts
753753
│ ├── merge_classes.ts
754754
│ ├── numbers.ts
755+
│ ├── remark_slack_emoji.ts # emoji rule for the markdown block (react-markdown/mdast)
755756
│ ├── sanitize_for_slack.ts
756757
│ ├── emojis/{parser.ts, list.ts}
757758
│ └── markdown_parser/

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"dev:css": "postcss ./src/style.css -o ./dist/style.css --watch",
2626
"dev": "tsup --watch",
2727
"lint": "tsc",
28-
"test": "tsc",
28+
"test": "node --test \"test/**/*.test.mjs\"",
2929
"release": "node scripts/release.mjs",
3030
"release:dry": "node scripts/release.mjs --dry-run",
3131
"release:beta": "node scripts/release.mjs prerelease --preid=beta",

playground/src/fixtures.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,36 @@ export const FIXTURES: Fixture[] = [
206206
},
207207
],
208208
},
209+
{
210+
id: "markdown-block",
211+
label: "Markdown block",
212+
blocks: [
213+
{
214+
type: "markdown",
215+
text: [
216+
"## :rocket: Deploy — api v2.4.0",
217+
"",
218+
"Out to production :tada: Thanks for the reviews this week :clap::skin-tone-3:",
219+
"",
220+
"**What changed**",
221+
"",
222+
"- Carousel and card blocks :sparkles:",
223+
"- Faster cold starts :zap:",
224+
"- Dark mode contrast fixes :bug:",
225+
"",
226+
"**Rollout checklist**",
227+
"",
228+
"- [x] Migrations applied",
229+
"- [x] Smoke tests passing :white_check_mark:",
230+
"- [ ] Changelog updated :thumbsup:",
231+
"",
232+
"Roll back with `kubectl rollout undo deploy/api` if error rates spike :warning:",
233+
"",
234+
"```bash",
235+
"curl -s https://api.example.com/healthz | jq .status",
236+
"```",
237+
].join("\n"),
238+
},
239+
],
240+
},
209241
];

src/components/blocks/markdown_block.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1-
import Markdown from "react-markdown";
1+
import Markdown, { Components } from "react-markdown";
22
import remarkGfm from "remark-gfm";
33
import { MarkdownBlock } from "../../types";
4+
import { SlackEmoji } from "../../utils/markdown_parser/sub_elements/slack_emoji";
5+
import { remarkSlackEmoji, SLACK_EMOJI_TAG } from "../../utils/remark_slack_emoji";
46

57
type MarkdownBlockProps = {
68
data: MarkdownBlock;
79
};
810

11+
// Renders the placeholder emitted by remarkSlackEmoji through the same
12+
// <SlackEmoji> component the mrkdwn parser uses (so custom/standard/alias/
13+
// skin-tone emoji match). The emoji name comes from the hast node properties.
14+
const MarkdownEmoji = ({ node }: { node?: { properties?: { name?: unknown } } }) => {
15+
const name = typeof node?.properties?.name === "string" ? node.properties.name : "";
16+
return <SlackEmoji element={{ type: "slack_emoji", value: name }} />;
17+
};
18+
19+
// react-markdown's `Components` type only allows known HTML tag names as keys,
20+
// so the custom `slack-emoji` element is registered through a cast.
21+
const emojiComponents = { [SLACK_EMOJI_TAG]: MarkdownEmoji } as Components;
22+
923
export const MarkdownBlockComponent = (props: MarkdownBlockProps) => {
1024
const { text, block_id } = props.data;
1125

@@ -18,8 +32,9 @@ export const MarkdownBlockComponent = (props: MarkdownBlockProps) => {
1832
className="mt-2 mb-1 text-primary text-black-primary dark:text-dark-text-primary slack_blocks_to_jsx__markdown_block"
1933
>
2034
<Markdown
21-
remarkPlugins={[remarkGfm]}
35+
remarkPlugins={[remarkGfm, remarkSlackEmoji]}
2236
components={{
37+
...emojiComponents,
2338
h1: ({ children }) => (
2439
<h1 className="text-header font-semibold text-black-primary dark:text-dark-text-primary mb-0.5">
2540
{children}

src/utils/remark_slack_emoji.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// remark/mdast plugin for the `markdown` block (react-markdown + remark-gfm),
2+
// which — unlike every other block type — has no emoji rule. It splits text
3+
// nodes on `:shortcode:` into `SLACK_EMOJI_TAG` placeholders that markdown_block
4+
// renders via the same <SlackEmoji> component the mrkdwn parser uses, so custom,
5+
// standard, alias and skin-tone emoji all match. Code spans/blocks are never split.
6+
7+
// Tag name emitted into the hast tree via `data.hName`. markdown_block.tsx maps
8+
// this through react-markdown's `components` prop.
9+
export const SLACK_EMOJI_TAG = "slack-emoji";
10+
11+
// Mirrors the characters accepted by the mrkdwn slack_emoji tokenizer
12+
// (see utils/markdown_parser/tokenizers/slack_emoji/parse.ts).
13+
const EMOJI_REGEX = /:([a-zA-Z0-9_\-+']+):/g;
14+
15+
// Minimal mdast typings — `@types/mdast` is not a direct dependency of this
16+
// package, so we describe only the shape this plugin touches.
17+
interface MdastNode {
18+
type: string;
19+
value?: string;
20+
children?: MdastNode[];
21+
data?: {
22+
hName?: string;
23+
hProperties?: Record<string, unknown>;
24+
} & Record<string, unknown>;
25+
}
26+
27+
// Split a text node's value into alternating text / emoji nodes. Returns null
28+
// when the value contains no emoji shortcode (so the original node is kept).
29+
function splitOnEmoji(value: string): MdastNode[] | null {
30+
EMOJI_REGEX.lastIndex = 0;
31+
let match = EMOJI_REGEX.exec(value);
32+
if (!match) return null;
33+
34+
const nodes: MdastNode[] = [];
35+
let lastIndex = 0;
36+
37+
while (match) {
38+
const full = match[0] ?? "";
39+
const name = match[1] ?? "";
40+
const start = match.index;
41+
42+
if (start > lastIndex) {
43+
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
44+
}
45+
46+
nodes.push({
47+
type: "slackEmoji",
48+
value: full,
49+
data: { hName: SLACK_EMOJI_TAG, hProperties: { name } },
50+
});
51+
52+
lastIndex = start + full.length;
53+
match = EMOJI_REGEX.exec(value);
54+
}
55+
56+
if (lastIndex < value.length) {
57+
nodes.push({ type: "text", value: value.slice(lastIndex) });
58+
}
59+
60+
return nodes;
61+
}
62+
63+
function transform(node: MdastNode): void {
64+
const children = node.children;
65+
if (!children) return;
66+
67+
const next: MdastNode[] = [];
68+
for (const child of children) {
69+
// Never interpolate emoji inside code spans / fenced code blocks.
70+
if (child.type === "code" || child.type === "inlineCode") {
71+
next.push(child);
72+
continue;
73+
}
74+
75+
if (child.type === "text" && child.value) {
76+
const replacement = splitOnEmoji(child.value);
77+
if (replacement) {
78+
next.push(...replacement);
79+
continue;
80+
}
81+
}
82+
83+
transform(child);
84+
next.push(child);
85+
}
86+
87+
node.children = next;
88+
}
89+
90+
export function remarkSlackEmoji() {
91+
return (tree: MdastNode): void => {
92+
transform(tree);
93+
};
94+
}

test/markdown_emoji.test.mjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Emoji interpolation in the `markdown` block — it renders through react-markdown
2+
// (not the mrkdwn parser) and previously showed `:shortcode:` as literal text.
3+
// These lock in parity with mrkdwn blocks: standard, aliases, skin tones, custom
4+
// emoji via hooks, and code-span exclusion. Run against the built dist/ via Node's
5+
// built-in runner (no test framework needed); CI builds before testing.
6+
7+
import test from "node:test";
8+
import assert from "node:assert/strict";
9+
import React from "react";
10+
import ReactDOMServer from "react-dom/server";
11+
import { Message } from "../dist/index.mjs";
12+
13+
const render = (text, hooks) =>
14+
ReactDOMServer.renderToStaticMarkup(
15+
React.createElement(Message, {
16+
logo: "logo.png",
17+
name: "Tester",
18+
theme: "light",
19+
hooks,
20+
blocks: [{ type: "markdown", text }],
21+
}),
22+
);
23+
24+
test("standard shortcodes render as unicode", () => {
25+
const out = render(":tada:");
26+
assert.match(out, /🎉/);
27+
// the literal shortcode must be gone — that was the bug.
28+
assert.doesNotMatch(out, /:tada:/);
29+
});
30+
31+
test("surrounding text and multiple emoji are preserved", () => {
32+
const out = render("ship it :tada: nice :smile:");
33+
assert.match(out, /ship it/);
34+
assert.match(out, /nice/);
35+
assert.match(out, /🎉/);
36+
assert.match(out, /😄/);
37+
});
38+
39+
test("shortcode aliases resolve via the fallback map", () => {
40+
// `thumbsup` isn't a node-emoji name; it resolves through the library's
41+
// missing-emoji map, exactly like it does in mrkdwn blocks.
42+
assert.match(render(":thumbsup:"), /👍/);
43+
});
44+
45+
test("skin-tone modifiers render", () => {
46+
const out = render(":wave::skin-tone-3:");
47+
assert.match(out, /👋/);
48+
assert.match(out, /🏼/);
49+
});
50+
51+
test("custom emoji go through hooks.emoji while standard emoji fall back to parse()", () => {
52+
const hooks = {
53+
emoji: ({ name }, parse) => {
54+
if (name === "blob-dance") {
55+
return React.createElement("img", {
56+
className: "custom-emoji",
57+
"data-name": name,
58+
src: `https://cdn.example.com/${name}.png`,
59+
alt: name,
60+
});
61+
}
62+
return parse({ name });
63+
},
64+
};
65+
66+
const out = render("standard :tada: custom :blob-dance:", hooks);
67+
// custom name → consumer-provided image
68+
assert.match(out, /custom-emoji/);
69+
assert.match(out, /data-name="blob-dance"/);
70+
assert.doesNotMatch(out, /:blob-dance:/);
71+
// standard name → unicode via the parse() fallback
72+
assert.match(out, /🎉/);
73+
});
74+
75+
test("emoji inside code spans are left untouched", () => {
76+
const out = render("`:tada:`");
77+
assert.match(out, /:tada:/);
78+
assert.doesNotMatch(out, /🎉/);
79+
});
80+
81+
test("emoji inside fenced code blocks are left untouched", () => {
82+
const out = render("```\n:tada:\n```");
83+
assert.match(out, /:tada:/);
84+
assert.doesNotMatch(out, /🎉/);
85+
});

0 commit comments

Comments
 (0)