import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; // Escape special characters for Slack mrkdwn format. // Preserve Slack's angle-bracket tokens so mentions and links stay intact. function escapeSlackMrkdwnSegment(text: string): string { return text.replace(/&/g, "&").replace(//g, ">"); } const SLACK_ANGLE_TOKEN_RE = /<[^>\n]+>/g; function isAllowedSlackAngleToken(token: string): boolean { if (!token.startsWith("<") || !token.endsWith(">")) return false; const inner = token.slice(1, -1); return ( inner.startsWith("@") || inner.startsWith("#") || inner.startsWith("!") || inner.startsWith("mailto:") || inner.startsWith("tel:") || inner.startsWith("http://") || inner.startsWith("https://") || inner.startsWith("slack://") ); } function escapeSlackMrkdwnContent(text: string): string { if (!text.includes("&") && !text.includes("<") && !text.includes(">")) { return text; } SLACK_ANGLE_TOKEN_RE.lastIndex = 0; const out: string[] = []; let lastIndex = 0; for ( let match = SLACK_ANGLE_TOKEN_RE.exec(text); match; match = SLACK_ANGLE_TOKEN_RE.exec(text) ) { const matchIndex = match.index ?? 0; out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex, matchIndex))); const token = match[0] ?? ""; out.push(isAllowedSlackAngleToken(token) ? token : escapeSlackMrkdwnSegment(token)); lastIndex = matchIndex + token.length; } out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex))); return out.join(""); } function escapeSlackMrkdwnText(text: string): string { if (!text.includes("&") && !text.includes("<") && !text.includes(">")) { return text; } return text .split("\n") .map((line) => { if (line.startsWith("> ")) { return `> ${escapeSlackMrkdwnContent(line.slice(2))}`; } return escapeSlackMrkdwnContent(line); }) .join("\n"); } function buildSlackLink(link: MarkdownLinkSpan, text: string) { const href = link.href.trim(); if (!href) return null; const label = text.slice(link.start, link.end); const trimmedLabel = label.trim(); const comparableHref = href.startsWith("mailto:") ? href.slice("mailto:".length) : href; const useMarkup = trimmedLabel.length > 0 && trimmedLabel !== href && trimmedLabel !== comparableHref; if (!useMarkup) return null; const safeHref = escapeSlackMrkdwnSegment(href); return { start: link.start, end: link.end, open: `<${safeHref}|`, close: ">", }; } export function markdownToSlackMrkdwn(markdown: string): string { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", }); return renderMarkdownWithMarkers(ir, { styleMarkers: { bold: { open: "*", close: "*" }, italic: { open: "_", close: "_" }, strikethrough: { open: "~", close: "~" }, code: { open: "`", close: "`" }, code_block: { open: "```\n", close: "```" }, }, escapeText: escapeSlackMrkdwnText, buildLink: buildSlackLink, }); } export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => renderMarkdownWithMarkers(chunk, { styleMarkers: { bold: { open: "*", close: "*" }, italic: { open: "_", close: "_" }, strikethrough: { open: "~", close: "~" }, code: { open: "`", close: "`" }, code_block: { open: "```\n", close: "```" }, }, escapeText: escapeSlackMrkdwnText, buildLink: buildSlackLink, }), ); }