type MarkdownContainer = DocumentFragment | HTMLElement;
interface InlineToken {
end: number;
kind: "code" | "emphasis" | "link" | "strong";
href?: string;
start: number;
value: string;
}
const UNORDERED_LIST_PATTERN = /^\s*[-+*]\s+(.+)$/;
const ORDERED_LIST_PATTERN = /^\s*\d+[.)]\s+(.+)$/;
const HEADING_PATTERN = /^\s{0,3}(#{1,3})\s+(.+)$/;
/**
* Renders the limited Markdown emitted by the assistant using DOM nodes only.
* Model-provided HTML always remains inert text.
*/
export function createMessageMarkdown(markdown: string): DocumentFragment {
const fragment = document.createDocumentFragment();
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
let lineIndex = 0;
while (lineIndex < lines.length) {
const line = lines[lineIndex] ?? "";
if (line.trim().length === 0) {
lineIndex += 1;
continue;
}
const heading = HEADING_PATTERN.exec(line);
if (heading !== null) {
appendHeading(fragment, heading[1]!.length, heading[2]!);
lineIndex += 1;
continue;
}
const unorderedItem = UNORDERED_LIST_PATTERN.exec(line);
if (unorderedItem !== null) {
lineIndex = appendList(
fragment,
lines,
lineIndex,
"ul",
UNORDERED_LIST_PATTERN,
);
continue;
}
const orderedItem = ORDERED_LIST_PATTERN.exec(line);
if (orderedItem !== null) {
lineIndex = appendList(
fragment,
lines,
lineIndex,
"ol",
ORDERED_LIST_PATTERN,
);
continue;
}
lineIndex = appendParagraph(fragment, lines, lineIndex);
}
return fragment;
}
function appendHeading(
container: MarkdownContainer,
markdownLevel: number,
content: string,
): void {
const tagName =
markdownLevel === 1 ? "h3" : markdownLevel === 2 ? "h4" : "h5";
const heading = document.createElement(tagName);
appendInlineMarkdown(heading, content);
container.append(heading);
}
function appendList(
container: MarkdownContainer,
lines: string[],
startIndex: number,
tagName: "ol" | "ul",
itemPattern: RegExp,
): number {
const list = document.createElement(tagName);
let lineIndex = startIndex;
while (lineIndex < lines.length) {
const item = itemPattern.exec(lines[lineIndex] ?? "");
if (item === null) break;
const listItem = document.createElement("li");
appendInlineMarkdown(listItem, item[1]!);
list.append(listItem);
lineIndex += 1;
}
container.append(list);
return lineIndex;
}
function appendParagraph(
container: MarkdownContainer,
lines: string[],
startIndex: number,
): number {
const paragraph = document.createElement("p");
let lineIndex = startIndex;
while (lineIndex < lines.length) {
const line = lines[lineIndex] ?? "";
if (
line.trim().length === 0 ||
HEADING_PATTERN.test(line) ||
UNORDERED_LIST_PATTERN.test(line) ||
ORDERED_LIST_PATTERN.test(line)
) {
break;
}
if (paragraph.childNodes.length > 0)
paragraph.append(document.createElement("br"));
appendInlineMarkdown(paragraph, line);
lineIndex += 1;
}
container.append(paragraph);
return lineIndex;
}
function appendInlineMarkdown(container: HTMLElement, content: string): void {
let cursor = 0;
while (cursor < content.length) {
const token = findNextInlineToken(content, cursor);
if (token === undefined) {
container.append(document.createTextNode(content.slice(cursor)));
return;
}
if (token.start > cursor) {
container.append(
document.createTextNode(content.slice(cursor, token.start)),
);
}
if (token.kind === "link" && token.href !== undefined) {
const anchor = document.createElement("a");
anchor.href = token.href;
anchor.target = "_blank";
anchor.rel = "noopener noreferrer";
appendInlineMarkdown(anchor, token.value);
container.append(anchor);
cursor = token.end;
continue;
}
const element = document.createElement(
token.kind === "strong"
? "strong"
: token.kind === "emphasis"
? "em"
: "code",
);
if (token.kind === "code") {
element.textContent = token.value;
} else {
appendInlineMarkdown(element, token.value);
}
container.append(element);
cursor = token.end;
}
}
function findNextInlineToken(
content: string,
fromIndex: number,
): InlineToken | undefined {
const candidates = [
findLinkToken(content, fromIndex),
findDelimitedToken(content, fromIndex, "**", "strong"),
findDelimitedToken(content, fromIndex, "__", "strong"),
findDelimitedToken(content, fromIndex, "`", "code"),
findDelimitedToken(content, fromIndex, "*", "emphasis"),
findDelimitedToken(content, fromIndex, "_", "emphasis"),
].filter((token): token is InlineToken => token !== undefined);
return candidates.sort(
(left, right) =>
left.start - right.start ||
tokenPriority(left.kind) - tokenPriority(right.kind),
)[0];
}
function findLinkToken(
content: string,
fromIndex: number,
): InlineToken | undefined {
const start = content.indexOf("[", fromIndex);
if (start === -1) return undefined;
const labelEnd = content.indexOf("](", start + 1);
if (labelEnd === -1) return undefined;
const urlEnd = content.indexOf(")", labelEnd + 2);
if (urlEnd === -1) return undefined;
const href = content.slice(labelEnd + 2, urlEnd);
let url: URL;
try {
url = new URL(href);
} catch {
return undefined;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
return {
end: urlEnd + 1,
kind: "link",
start,
value: content.slice(start + 1, labelEnd),
href: url.toString(),
};
}
function findDelimitedToken(
content: string,
fromIndex: number,
delimiter: string,
kind: InlineToken["kind"],
): InlineToken | undefined {
const start = content.indexOf(delimiter, fromIndex);
if (start === -1) return undefined;
const valueStart = start + delimiter.length;
const closingDelimiter = content.indexOf(delimiter, valueStart);
if (closingDelimiter <= valueStart) return undefined;
return {
end: closingDelimiter + delimiter.length,
kind,
start,
value: content.slice(valueStart, closingDelimiter),
};
}
function tokenPriority(kind: InlineToken["kind"]): number {
return kind === "link" ? 0 : kind === "code" ? 1 : kind === "strong" ? 2 : 3;
}