` ``` fences → ``
* `~~struck~~` → `` `# Heading` → a bold line
* `[label](url)` → ``
*
* Tables and images are deliberately unsupported: Teams renders neither in a
* chat, so pretending otherwise would produce worse output than plain text.
* Everything else is escaped and kept as written.
*/
const HTML_ESCAPES: Record = {
"&": "&",
"<": "<",
">": ">",
};
function escapeHtml(text: string): string {
return text.replace(/[&<>]/g, (char) => HTML_ESCAPES[char] ?? char);
}
/**
* Inline markup of a single line.
*
* Order matters: code spans are protected first (so `**` inside them stays
* literal), then links, then emphasis. The underscore rule requires a boundary
* on both sides so `snake_case_name` survives untouched.
*/
function inline(raw: string): string {
let text = escapeHtml(raw);
text = text.replace(/`([^`]+)`/g, "$1");
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
'$1',
);
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
text = text.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1$2");
text = text.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,;:!?])/g, "$1$2");
text = text.replace(/~~([^~]+)~~/g, "$1");
return text;
}
const BULLET = /^\s*[-*+]\s+(.*)$/;
const NUMBERED = /^\s*\d+[.)]\s+(.*)$/;
const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
/**
* Convert a message body to Teams HTML.
*
* Blank lines separate paragraphs, single newlines become `
` inside one —
* which is what a person typing into the chat box gets, and the safest
* assumption for text that was generated, not composed.
*
* Teams renders `` (and lists) in a chat bubble with no margin at all, so
* `
a
b
` looks exactly like `a
b` — every blank line the model
* wrote disappears and a digest arrives as one block. The Teams client itself
* writes an empty paragraph for a blank line, so blocks are joined with one.
*/
export function markdownToTeamsHtml(text: string): string {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const blocks: string[] = [];
let paragraph: string[] = [];
let list: { ordered: boolean; items: string[] } | undefined;
let code: string[] | undefined;
const flushParagraph = () => {
if (paragraph.length === 0) return;
blocks.push(`${paragraph.map(inline).join("
")}
`);
paragraph = [];
};
const flushList = () => {
if (!list) return;
const tag = list.ordered ? "ol" : "ul";
const items = list.items.map((item) => `- ${inline(item)}
`).join("");
blocks.push(`<${tag}>${items}${tag}>`);
list = undefined;
};
const flushBlock = () => {
flushParagraph();
flushList();
};
for (const line of lines) {
// A fence swallows everything until the next one, verbatim.
if (/^\s*```/.test(line)) {
if (code) {
blocks.push(`${escapeHtml(code.join("\n"))}`);
code = undefined;
} else {
flushBlock();
code = [];
}
continue;
}
if (code) {
code.push(line);
continue;
}
if (line.trim() === "") {
flushBlock();
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushBlock();
blocks.push(`${inline(heading[1])}
`);
continue;
}
const bullet = BULLET.exec(line);
if (bullet) {
flushParagraph();
if (list && list.ordered) flushList();
list ??= { ordered: false, items: [] };
list.items.push(bullet[1]);
continue;
}
const numbered = NUMBERED.exec(line);
if (numbered) {
flushParagraph();
if (list && !list.ordered) flushList();
list ??= { ordered: true, items: [] };
list.items.push(numbered[1]);
continue;
}
flushList();
paragraph.push(line);
}
// An unterminated fence is still code, not lost text.
if (code) blocks.push(`${escapeHtml(code.join("\n"))}`);
flushBlock();
return blocks.join(BLOCK_SPACER);
}
/** What the Teams client writes for an empty line between two blocks. */
export const BLOCK_SPACER = "
";