/**
* Converts a DOM selection inside rendered chat markdown back into markdown
* source so highlight-and-copy keeps formatting (links, emphasis, lists,
* fences, tables) instead of flattening to plain text. The `text/plain`
* clipboard flavor carries the markdown; `text/html` carries a sanitized
* copy of the rendered fragment for rich-paste targets.
*/
const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);
const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];
const SANITIZED_HTML_SELECTOR = [
"button",
"input",
"script",
"style",
"svg",
'[aria-hidden="true"]',
...SKIPPED_CLASS_NAMES.map((className) => `.${className}`),
].join(", ");
export interface MarkdownClipboardPayload {
text: string;
html: string;
}
function isSkippedElement(element: Element): boolean {
if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true;
if (element.getAttribute("aria-hidden") === "true") return true;
return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className));
}
/** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */
function wrapInlineMarker(content: string, marker: string): string {
const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content);
const core = match?.[2] ?? "";
if (!core) return content;
return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
}
/**
* A code element whose pre wrapper fell outside the copied range is still
* block code, recognizable by its highlighter line spans or embedded
* newlines. Wrapping it like inline code produces backtick-surrounded
* shell commands on paste.
*/
function isBlockCodeElement(element: Element, content: string): boolean {
if (content.includes("\n")) return true;
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {
return true;
}
}
return false;
}
function wrapInlineCode(code: string): string {
const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
(max, run) => Math.max(max, run.length),
0,
);
const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0)));
const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";
return `${fence}${pad}${code}${pad}${fence}`;
}
function codeFenceFor(code: string): string {
const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce(
(max, run) => Math.max(max, run.length),
0,
);
return "`".repeat(Math.max(3, longestRun + 1));
}
function resolveCodeBlockLanguage(pre: Element): string | null {
const declared =
pre.closest("[data-language]")?.getAttribute("data-language") ??
/(?:^|\s)language-(\S+)/.exec(pre.querySelector("code")?.className ?? "")?.[1] ??
null;
return declared && declared !== "text" ? declared : null;
}
function serializeCodeBlock(pre: Element): string {
const code = (pre.textContent ?? "").replace(/\n$/, "");
const fence = codeFenceFor(code);
return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`;
}
function serializeTableCell(cell: Element): string {
return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|");
}
function tableSeparatorFor(headerCells: Element[]): string {
const markers = headerCells.map((cell) => {
const align = (cell as HTMLElement).style?.textAlign ?? cell.getAttribute("align") ?? "";
if (align === "center") return ":---:";
if (align === "right") return "---:";
return "---";
});
return `| ${markers.join(" | ")} |`;
}
function serializeTable(table: Element): string {
const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];
if (rows.length === 0) return "";
const lines: string[] = [];
let emittedSeparator = false;
for (const row of rows) {
const cells = [...row.children].filter(
(cell) => cell.tagName === "TH" || cell.tagName === "TD",
);
if (cells.length === 0) continue;
lines.push(`| ${cells.map((cell) => serializeTableCell(cell)).join(" | ")} |`);
if (!emittedSeparator) {
lines.push(tableSeparatorFor(cells));
emittedSeparator = true;
}
}
return `${lines.join("\n")}\n\n`;
}
function serializeListItem(item: Element, ordered: boolean, index: number): string {
const checkbox = item.querySelector('input[type="checkbox"]');
const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";
const marker = ordered ? `${index}. ${task}` : `- ${task}`;
let content = serializeChildren(item)
.replace(/\n{3,}/g, "\n\n")
.trim();
// Tight list items (no paragraph children) keep nested lists on adjacent lines.
if (!item.querySelector(":scope > p")) {
content = content.replace(/\n{2,}/g, "\n");
}
const continuationIndent = " ".repeat(marker.length);
const [first = "", ...rest] = content.split("\n");
return [
`${marker}${first}`,
...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)),
].join("\n");
}
function serializeList(list: Element, ordered: boolean): string {
const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1;
const items = [...list.children].filter((child) => child.tagName === "LI");
if (items.length === 0) return "";
return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`;
}
function serializeBlockquote(quote: Element): string {
const content = serializeChildren(quote)
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!content) return "";
const quoted = content
.split("\n")
.map((line) => (line.length > 0 ? `> ${line}` : ">"))
.join("\n");
return `${quoted}\n\n`;
}
function serializeDetails(details: Element): string {
const summary =
details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ??
"Details";
const contentNode = details.querySelector(":scope > * [data-markdown-details-content]");
const content = contentNode ? serializeChildren(contentNode).trim() : "";
const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : "";
return `\n${summary}
${content ? `\n\n${content}` : ""}\n \n\n`;
}
function serializeAnchor(anchor: Element): string {
const markdownCopy = anchor.getAttribute("data-markdown-copy");
if (markdownCopy !== null) return markdownCopy;
const content = serializeChildren(anchor);
const href = anchor.getAttribute("href") ?? "";
if (!/^https?:\/\//i.test(href)) return content;
const label = content.trim();
if (!label) return "";
if (label === href) return href;
return `[${label}](${href})`;
}
function serializeChildren(node: Node): string {
let out = "";
for (const child of node.childNodes) {
out += serializeNode(child);
}
return out;
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? "";
// Inter-block formatting whitespace from the renderer collapses to a
// newline; real inline whitespace passes through untouched.
if (text.includes("\n") && text.trim().length === 0) return "\n";
return text;
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as Element;
if (element.hasAttribute("data-markdown-details")) {
return serializeDetails(element);
}
const markdownCopy = element.getAttribute("data-markdown-copy");
if (markdownCopy !== null) return markdownCopy;
if (isSkippedElement(element)) return "";
const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];
if (headingLevel) {
return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`;
}
switch (element.tagName) {
case "BR":
return "\n";
case "HR":
return "---\n\n";
case "P":
return `${serializeChildren(element).trim()}\n\n`;
case "PRE":
return serializeCodeBlock(element);
case "CODE": {
const content = element.textContent ?? "";
return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
}
case "STRONG":
case "B":
return wrapInlineMarker(serializeChildren(element), "**");
case "EM":
case "I":
return wrapInlineMarker(serializeChildren(element), "*");
case "DEL":
case "S":
return wrapInlineMarker(serializeChildren(element), "~~");
case "A":
return serializeAnchor(element);
case "IMG": {
const alt = element.getAttribute("alt") ?? "";
const src = element.getAttribute("src") ?? "";
return alt && src ? `` : "";
}
case "UL":
return serializeList(element, false);
case "OL":
return serializeList(element, true);
case "BLOCKQUOTE":
return serializeBlockquote(element);
case "TABLE":
return serializeTable(element);
case "DIV":
case "SECTION":
case "ARTICLE": {
const content = serializeChildren(element);
return content && !content.endsWith("\n") ? `${content}\n` : content;
}
default:
return serializeChildren(element);
}
}
/** Collapses serializer spacing artifacts without touching fenced code content. */
function tidyMarkdown(markdown: string): string {
return markdown
.split(/(```[\s\S]*?(?:```|$))/)
.map((part, index) =>
index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"),
)
.join("")
.trim();
}
export function serializeRenderedMarkdownFragment(container: Node): string {
return tidyMarkdown(serializeChildren(container));
}
export function serializeTableElementToMarkdown(table: Element): string {
return serializeTable(table).trim();
}
function csvCell(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized;
}
export function serializeTableElementToCsv(table: Element): string {
const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];
const lines: string[] = [];
for (const row of rows) {
const cells = [...row.children].filter(
(cell) => cell.tagName === "TH" || cell.tagName === "TD",
);
if (cells.length === 0) continue;
lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(","));
}
return lines.join("\n");
}
function sanitizedHtmlFrom(container: Element): string {
for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) {
if (
node.classList.contains("chat-markdown-file-link") ||
node.closest(".chat-markdown-file-link")
) {
if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") {
node.remove();
}
continue;
}
node.remove();
}
return `${container.innerHTML}`;
}
export function chatMarkdownClipboardPayload(
selection: Selection,
): MarkdownClipboardPayload | null {
const texts: string[] = [];
const htmls: string[] = [];
for (let index = 0; index < selection.rangeCount; index += 1) {
const range = selection.getRangeAt(index);
if (range.collapsed) continue;
const container = document.createElement("div");
container.appendChild(range.cloneContents());
const ancestor = range.commonAncestorContainer;
const ancestorElement =
ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement;
if (ancestorElement?.closest("pre")) {
const text = range.toString();
if (text) {
texts.push(text);
htmls.push(sanitizedHtmlFrom(container));
}
continue;
}
const text = serializeRenderedMarkdownFragment(container);
if (!text) continue;
texts.push(text);
htmls.push(sanitizedHtmlFrom(container));
}
if (texts.length === 0) return null;
return { text: texts.join("\n\n"), html: htmls.join("") };
}