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; }