Slack: add mrkdwn formatter for proper bold/italic/strikethrough rendering

This commit is contained in:
Austin Mudd
2026-01-09 10:34:10 -08:00
committed by Peter Steinberger
parent 909c14d443
commit 8ae0429162
3 changed files with 263 additions and 1 deletions

90
src/slack/format.test.ts Normal file
View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { markdownToSlackMrkdwn } from "./format.js";
describe("markdownToSlackMrkdwn", () => {
it("converts bold from double asterisks to single", () => {
const res = markdownToSlackMrkdwn("**bold text**");
expect(res).toBe("*bold text*");
});
it("preserves italic underscore format", () => {
const res = markdownToSlackMrkdwn("_italic text_");
expect(res).toBe("_italic text_");
});
it("converts strikethrough from double tilde to single", () => {
const res = markdownToSlackMrkdwn("~~strikethrough~~");
expect(res).toBe("~strikethrough~");
});
it("renders basic inline formatting together", () => {
const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`");
expect(res).toBe("hi _there_ *boss* `code`");
});
it("renders inline code", () => {
const res = markdownToSlackMrkdwn("use `npm install`");
expect(res).toBe("use `npm install`");
});
it("renders fenced code blocks", () => {
const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```");
expect(res).toBe("```\nconst x = 1;\n```");
});
it("renders links with URL in parentheses", () => {
const res = markdownToSlackMrkdwn("see [docs](https://example.com)");
expect(res).toBe("see docs (https://example.com)");
});
it("escapes unsafe characters", () => {
const res = markdownToSlackMrkdwn("a & b < c > d");
expect(res).toBe("a &amp; b &lt; c &gt; d");
});
it("escapes raw HTML", () => {
const res = markdownToSlackMrkdwn("<b>nope</b>");
expect(res).toBe("&lt;b&gt;nope&lt;/b&gt;");
});
it("renders paragraphs with blank lines", () => {
const res = markdownToSlackMrkdwn("first\n\nsecond");
expect(res).toBe("first\n\nsecond");
});
it("renders bullet lists", () => {
const res = markdownToSlackMrkdwn("- one\n- two");
expect(res).toBe("• one\n• two");
});
it("renders ordered lists with numbering", () => {
const res = markdownToSlackMrkdwn("2. two\n3. three");
expect(res).toBe("2. two\n3. three");
});
it("renders headings as bold text", () => {
const res = markdownToSlackMrkdwn("# Title");
expect(res).toBe("*Title*");
});
it("renders blockquotes with escaped angle bracket", () => {
const res = markdownToSlackMrkdwn("> Quote");
expect(res).toBe("&gt; Quote");
});
it("handles adjacent list items", () => {
const res = markdownToSlackMrkdwn("- item\n - nested");
// markdown-it treats indented items as continuation, not nesting
expect(res).toBe("• item • nested");
});
it("handles complex message with multiple elements", () => {
const res = markdownToSlackMrkdwn(
"**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second",
);
expect(res).toBe(
"*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second",
);
});
});

170
src/slack/format.ts Normal file
View File

@@ -0,0 +1,170 @@
import MarkdownIt from "markdown-it";
type ListState = {
type: "bullet" | "ordered";
index: number;
};
type RenderEnv = {
slackListStack?: ListState[];
slackLinkStack?: { href: string }[];
};
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: false,
typographer: false,
});
md.enable("strikethrough");
/**
* Escape special characters for Slack mrkdwn format.
* Slack requires escaping &, <, > to prevent injection.
*/
function escapeSlackMrkdwn(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getListStack(env: RenderEnv): ListState[] {
if (!env.slackListStack) env.slackListStack = [];
return env.slackListStack;
}
function getLinkStack(env: RenderEnv): { href: string }[] {
if (!env.slackLinkStack) env.slackLinkStack = [];
return env.slackLinkStack;
}
md.renderer.rules.text = (tokens, idx) =>
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
md.renderer.rules.softbreak = () => "\n";
md.renderer.rules.hardbreak = () => "\n";
md.renderer.rules.paragraph_open = () => "";
md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
return stack.length ? "" : "\n\n";
};
md.renderer.rules.heading_open = () => "*";
md.renderer.rules.heading_close = () => "*\n\n";
md.renderer.rules.blockquote_open = () => "&gt; ";
md.renderer.rules.blockquote_close = () => "\n";
md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).push({ type: "bullet", index: 0 });
return "";
};
md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => {
const start = Number(tokens[idx]?.attrGet("start") ?? "1");
getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 });
return "";
};
md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => {
getListStack(env as RenderEnv).pop();
return "";
};
md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => {
const stack = getListStack(env as RenderEnv);
const top = stack[stack.length - 1];
if (!top) return "";
top.index += 1;
const indent = " ".repeat(Math.max(0, stack.length - 1));
const prefix = top.type === "ordered" ? `${top.index}. ` : "• ";
return `${indent}${prefix}`;
};
md.renderer.rules.list_item_close = () => "\n";
// Slack mrkdwn uses _text_ for italic (same as markdown)
md.renderer.rules.em_open = () => "_";
md.renderer.rules.em_close = () => "_";
// Slack mrkdwn uses *text* for bold (single asterisk, not double)
md.renderer.rules.strong_open = () => "*";
md.renderer.rules.strong_close = () => "*";
// Slack mrkdwn uses ~text~ for strikethrough (single tilde)
md.renderer.rules.s_open = () => "~";
md.renderer.rules.s_close = () => "~";
md.renderer.rules.code_inline = (tokens, idx) =>
`\`${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\``;
md.renderer.rules.code_block = (tokens, idx) =>
`\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`;
md.renderer.rules.fence = (tokens, idx) =>
`\`\`\`\n${escapeSlackMrkdwn(tokens[idx]?.content ?? "")}\`\`\`\n`;
// Slack links use <url|text> format
md.renderer.rules.link_open = (tokens, idx, _opts, env) => {
const href = tokens[idx]?.attrGet("href") ?? "";
const stack = getLinkStack(env as RenderEnv);
stack.push({ href });
return "";
};
md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => {
const stack = getLinkStack(env as RenderEnv);
const link = stack.pop();
if (link?.href) {
return ` (${escapeSlackMrkdwn(link.href)})`;
}
return "";
};
md.renderer.rules.image = (tokens, idx) => {
const alt = tokens[idx]?.content ?? "";
return escapeSlackMrkdwn(alt);
};
md.renderer.rules.html_block = (tokens, idx) =>
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) =>
escapeSlackMrkdwn(tokens[idx]?.content ?? "");
md.renderer.rules.table_open = () => "";
md.renderer.rules.table_close = () => "";
md.renderer.rules.thead_open = () => "";
md.renderer.rules.thead_close = () => "";
md.renderer.rules.tbody_open = () => "";
md.renderer.rules.tbody_close = () => "";
md.renderer.rules.tr_open = () => "";
md.renderer.rules.tr_close = () => "\n";
md.renderer.rules.th_open = () => "";
md.renderer.rules.th_close = () => "\t";
md.renderer.rules.td_open = () => "";
md.renderer.rules.td_close = () => "\t";
md.renderer.rules.hr = () => "\n";
/**
* Convert standard Markdown to Slack mrkdwn format.
*
* Slack mrkdwn differences from standard Markdown:
* - Bold: *text* (single asterisk, not double)
* - Italic: _text_ (same)
* - Strikethrough: ~text~ (single tilde)
* - Code: `code` (same)
* - Links: <url|text> or plain URL
* - Escape &, <, > as &amp;, &lt;, &gt;
*/
export function markdownToSlackMrkdwn(markdown: string): string {
const env: RenderEnv = {};
const rendered = md.render(markdown ?? "", env);
return rendered
.replace(/[ \t]+\n/g, "\n")
.replace(/\t+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
}

View File

@@ -9,6 +9,7 @@ import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { markdownToSlackMrkdwn } from "./format.js";
import { resolveSlackBotToken } from "./token.js";
const SLACK_TEXT_LIMIT = 4000;
@@ -169,7 +170,8 @@ export async function sendMessageSlack(
const { channelId } = await resolveChannelId(client, recipient);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const slackFormatted = markdownToSlackMrkdwn(trimmedMessage);
const chunks = chunkMarkdownText(slackFormatted, chunkLimit);
const mediaMaxBytes =
typeof account.config.mediaMaxMb === "number"
? account.config.mediaMaxMb * 1024 * 1024