Slack: add mrkdwn formatter for proper bold/italic/strikethrough rendering
This commit is contained in:
committed by
Peter Steinberger
parent
909c14d443
commit
8ae0429162
170
src/slack/format.ts
Normal file
170
src/slack/format.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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 = () => "> ";
|
||||
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 &, <, >
|
||||
*/
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user