128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
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, "<").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,
|
|
}),
|
|
);
|
|
}
|