1/**
2 * Converts a DOM selection inside rendered chat markdown back into markdown
3 * source so highlight-and-copy keeps formatting (links, emphasis, lists,
4 * fences, tables) instead of flattening to plain text. The `text/plain`
5 * clipboard flavor carries the markdown; `text/html` carries a sanitized
6 * copy of the rendered fragment for rich-paste targets.
7 */
8
9const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);
10const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];
11const SANITIZED_HTML_SELECTOR = [
12 "button",
13 "input",
14 "script",
15 "style",
16 "svg",
17 '[aria-hidden="true"]',
18 ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`),
19].join(", ");
20
21export interface MarkdownClipboardPayload {
22 text: string;
23 html: string;
24}
25
26function isSkippedElement(element: Element): boolean {
27 if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true;
28 if (element.getAttribute("aria-hidden") === "true") return true;
29 return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className));
30}
31
32/** Hoists surrounding whitespace outside the markers: "` bold `" → " **bold** ". */
33function wrapInlineMarker(content: string, marker: string): string {
34 const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content);
35 const core = match?.[2] ?? "";
36 if (!core) return content;
37 return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
38}
39
40/**
41 * A code element whose pre wrapper fell outside the copied range is still
42 * block code, recognizable by its highlighter line spans or embedded
43 * newlines. Wrapping it like inline code produces backtick-surrounded
44 * shell commands on paste.
45 */
46function isBlockCodeElement(element: Element, content: string): boolean {
47 if (content.includes("\n")) return true;
48 for (const child of element.childNodes) {
49 if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {
50 return true;
51 }
52 }
53 return false;
54}
55
56function wrapInlineCode(code: string): string {
57 const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
58 (max, run) => Math.max(max, run.length),
59 0,
60 );
61 const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0)));
62 const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";
63 return `${fence}${pad}${code}${pad}${fence}`;
64}
65
66function codeFenceFor(code: string): string {
67 const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce(
68 (max, run) => Math.max(max, run.length),
69 0,
70 );
71 return "`".repeat(Math.max(3, longestRun + 1));
72}
73
74function resolveCodeBlockLanguage(pre: Element): string | null {
75 const declared =
76 pre.closest("[data-language]")?.getAttribute("data-language") ??
77 /(?:^|\s)language-(\S+)/.exec(pre.querySelector("code")?.className ?? "")?.[1] ??
78 null;
79 return declared && declared !== "text" ? declared : null;
80}
81
82function serializeCodeBlock(pre: Element): string {
83 const code = (pre.textContent ?? "").replace(/\n$/, "");
84 const fence = codeFenceFor(code);
85 return `${fence}${resolveCodeBlockLanguage(pre) ?? ""}\n${code}\n${fence}\n\n`;
86}
87
88function serializeTableCell(cell: Element): string {
89 return serializeChildren(cell).replace(/\n+/g, " ").trim().replaceAll("|", "\\|");
90}
91
92function tableSeparatorFor(headerCells: Element[]): string {
93 const markers = headerCells.map((cell) => {
94 const align = (cell as HTMLElement).style?.textAlign ?? cell.getAttribute("align") ?? "";
95 if (align === "center") return ":---:";
96 if (align === "right") return "---:";
97 return "---";
98 });
99 return `| ${markers.join(" | ")} |`;
100}
101
102function serializeTable(table: Element): string {
103 const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];
104 if (rows.length === 0) return "";
105 const lines: string[] = [];
106 let emittedSeparator = false;
107 for (const row of rows) {
108 const cells = [...row.children].filter(
109 (cell) => cell.tagName === "TH" || cell.tagName === "TD",
110 );
111 if (cells.length === 0) continue;
112 lines.push(`| ${cells.map((cell) => serializeTableCell(cell)).join(" | ")} |`);
113 if (!emittedSeparator) {
114 lines.push(tableSeparatorFor(cells));
115 emittedSeparator = true;
116 }
117 }
118 return `${lines.join("\n")}\n\n`;
119}
120
121function serializeListItem(item: Element, ordered: boolean, index: number): string {
122 const checkbox = item.querySelector(
123 ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]',
124 );
125 const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";
126 const marker = ordered ? `${index}. ${task}` : `- ${task}`;
127 let content = serializeChildren(item)
128 .replace(/\n{3,}/g, "\n\n")
129 .trim();
130 // Tight list items (no paragraph children) keep nested lists on adjacent lines.
131 if (!item.querySelector(":scope > p")) {
132 content = content.replace(/\n{2,}/g, "\n");
133 }
134 const continuationIndent = " ".repeat(marker.length);
135 const [first = "", ...rest] = content.split("\n");
136 return [
137 `${marker}${first}`,
138 ...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)),
139 ].join("\n");
140}
141
142function serializeList(list: Element, ordered: boolean): string {
143 const start = Number.parseInt(list.getAttribute("start") ?? "1", 10) || 1;
144 const items = [...list.children].filter((child) => child.tagName === "LI");
145 if (items.length === 0) return "";
146 return `${items.map((item, index) => serializeListItem(item, ordered, start + index)).join("\n")}\n\n`;
147}
148
149function serializeBlockquote(quote: Element): string {
150 const content = serializeChildren(quote)
151 .replace(/\n{3,}/g, "\n\n")
152 .trim();
153 if (!content) return "";
154 const quoted = content
155 .split("\n")
156 .map((line) => (line.length > 0 ? `> ${line}` : ">"))
157 .join("\n");
158 return `${quoted}\n\n`;
159}
160
161function serializeDetails(details: Element): string {
162 const summary =
163 details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ??
164 "Details";
165 const contentNode = details.querySelector(":scope > * [data-markdown-details-content]");
166 const content = contentNode ? serializeChildren(contentNode).trim() : "";
167 const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : "";
168 return `<details${open}>\n<summary>${summary}</summary>${content ? `\n\n${content}` : ""}\n</details>\n\n`;
169}
170
171function serializeAnchor(anchor: Element): string {
172 const markdownCopy = anchor.getAttribute("data-markdown-copy");
173 if (markdownCopy !== null) return markdownCopy;
174 const content = serializeChildren(anchor);
175 const href = anchor.getAttribute("href") ?? "";
176 if (!/^https?:\/\//i.test(href)) return content;
177 const label = content.trim();
178 if (!label) return "";
179 if (label === href) return href;
180 return `[${label}](${href})`;
181}
182
183function serializeChildren(node: Node): string {
184 let out = "";
185 for (const child of node.childNodes) {
186 out += serializeNode(child);
187 }
188 return out;
189}
190
191function serializeNode(node: Node): string {
192 if (node.nodeType === Node.TEXT_NODE) {
193 const text = node.textContent ?? "";
194 // Inter-block formatting whitespace from the renderer collapses to a
195 // newline; real inline whitespace passes through untouched.
196 if (text.includes("\n") && text.trim().length === 0) return "\n";
197 return text;
198 }
199 if (node.nodeType !== Node.ELEMENT_NODE) return "";
200 const element = node as Element;
201 if (element.hasAttribute("data-markdown-details")) {
202 return serializeDetails(element);
203 }
204 const markdownCopy = element.getAttribute("data-markdown-copy");
205 if (markdownCopy !== null) return markdownCopy;
206 if (isSkippedElement(element)) return "";
207
208 const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];
209 if (headingLevel) {
210 return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element).trim()}\n\n`;
211 }
212
213 switch (element.tagName) {
214 case "BR":
215 return "\n";
216 case "HR":
217 return "---\n\n";
218 case "P":
219 return `${serializeChildren(element).trim()}\n\n`;
220 case "PRE":
221 return serializeCodeBlock(element);
222 case "CODE": {
223 const content = element.textContent ?? "";
224 return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
225 }
226 case "STRONG":
227 case "B":
228 return wrapInlineMarker(serializeChildren(element), "**");
229 case "EM":
230 case "I":
231 return wrapInlineMarker(serializeChildren(element), "*");
232 case "DEL":
233 case "S":
234 return wrapInlineMarker(serializeChildren(element), "~~");
235 case "A":
236 return serializeAnchor(element);
237 case "IMG": {
238 const alt = element.getAttribute("alt") ?? "";
239 const src = element.getAttribute("src") ?? "";
240 return alt && src ? `![${alt}](${src})` : "";
241 }
242 case "UL":
243 return serializeList(element, false);
244 case "OL":
245 return serializeList(element, true);
246 case "BLOCKQUOTE":
247 return serializeBlockquote(element);
248 case "TABLE":
249 return serializeTable(element);
250 case "DIV":
251 case "SECTION":
252 case "ARTICLE": {
253 const content = serializeChildren(element);
254 return content && !content.endsWith("\n") ? `${content}\n` : content;
255 }
256 default:
257 return serializeChildren(element);
258 }
259}
260
261/**
262 * Tracks whether a fragment carries exactly one code block and nothing else a
263 * reader would see.
264 */
265interface SoleCodeBlockScan {
266 pre: Element | null;
267 other: boolean;
268}
269
270function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void {
271 for (const child of node.childNodes) {
272 if (scan.other) return;
273 if (child.nodeType === Node.TEXT_NODE) {
274 if ((child.textContent ?? "").trim().length > 0) scan.other = true;
275 continue;
276 }
277 if (child.nodeType !== Node.ELEMENT_NODE) continue;
278 const element = child as Element;
279 // Mirrors serializeNode's order: an element carrying markdown of its own
280 // still contributes it even when its tag is otherwise skipped, as a file
281 // chip rendered as a button does.
282 if (element.hasAttribute("data-markdown-details")) {
283 scan.other = true;
284 continue;
285 }
286 const markdownCopy = element.getAttribute("data-markdown-copy");
287 if (markdownCopy !== null) {
288 if (markdownCopy.trim().length > 0) scan.other = true;
289 continue;
290 }
291 if (isSkippedElement(element)) continue;
292 if (element.tagName === "PRE") {
293 if (scan.pre) scan.other = true;
294 else scan.pre = element;
295 continue;
296 }
297 if (element.tagName === "IMG" || element.tagName === "HR") {
298 scan.other = true;
299 continue;
300 }
301 if (element.tagName === "LI") {
302 // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item,
303 // so an item that does not hold the block carries content of its own even
304 // when it renders no text. An item that wraps the block is just the
305 // structure around it, and a pre-only selection would drop the marker too.
306 const preBeforeItem = scan.pre;
307 scanForSoleCodeBlock(element, scan);
308 if (!scan.other && scan.pre === preBeforeItem) scan.other = true;
309 continue;
310 }
311 scanForSoleCodeBlock(element, scan);
312 }
313}
314
315/**
316 * A drag that ends on a block's final newline pulls the closing `pre` into the
317 * range, so the fragment holds the whole block even though the user only
318 * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose
319 * only visible content is one code block copies as plain code, matching a
320 * selection that never left the `pre`.
321 */
322function soleCodeBlock(container: Node): Element | null {
323 const scan: SoleCodeBlockScan = { pre: null, other: false };
324 scanForSoleCodeBlock(container, scan);
325 return scan.other ? null : scan.pre;
326}
327
328/** Collapses serializer spacing artifacts without touching fenced code content. */
329function tidyMarkdown(markdown: string): string {
330 return markdown
331 .split(/(```[\s\S]*?(?:```|$))/)
332 .map((part, index) =>
333 index % 2 === 1 ? part : part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n"),
334 )
335 .join("")
336 .trim();
337}
338
339export function serializeRenderedMarkdownFragment(container: Node): string {
340 const codeBlock = soleCodeBlock(container);
341 if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, "");
342 return tidyMarkdown(serializeChildren(container));
343}
344
345export function serializeTableElementToMarkdown(table: Element): string {
346 return serializeTable(table).trim();
347}
348
349function csvCell(value: string): string {
350 const normalized = value.replace(/\s+/g, " ").trim();
351 return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized;
352}
353
354export function serializeTableElementToCsv(table: Element): string {
355 const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];
356 const lines: string[] = [];
357 for (const row of rows) {
358 const cells = [...row.children].filter(
359 (cell) => cell.tagName === "TH" || cell.tagName === "TD",
360 );
361 if (cells.length === 0) continue;
362 lines.push(cells.map((cell) => csvCell(cell.textContent ?? "")).join(","));
363 }
364 return lines.join("\n");
365}
366
367function sanitizedHtmlFrom(container: Element): string {
368 for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) {
369 if (
370 node.classList.contains("chat-markdown-file-link") ||
371 node.closest(".chat-markdown-file-link")
372 ) {
373 if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") {
374 node.remove();
375 }
376 continue;
377 }
378 node.remove();
379 }
380 return `<meta charset="utf-8">${container.innerHTML}`;
381}
382
383export function chatMarkdownClipboardPayload(
384 selection: Selection,
385): MarkdownClipboardPayload | null {
386 const texts: string[] = [];
387 const htmls: string[] = [];
388 for (let index = 0; index < selection.rangeCount; index += 1) {
389 const range = selection.getRangeAt(index);
390 if (range.collapsed) continue;
391 const container = document.createElement("div");
392 container.appendChild(range.cloneContents());
393 const ancestor = range.commonAncestorContainer;
394 const ancestorElement =
395 ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement;
396 if (ancestorElement?.closest("pre")) {
397 const text = range.toString();
398 if (text) {
399 texts.push(text);
400 htmls.push(sanitizedHtmlFrom(container));
401 }
402 continue;
403 }
404 const text = serializeRenderedMarkdownFragment(container);
405 if (!text) continue;
406 texts.push(text);
407 htmls.push(sanitizedHtmlFrom(container));
408 }
409 if (texts.length === 0) return null;
410 return { text: texts.join("\n\n"), html: htmls.join("") };
411}