Skip to content
Source
Sign inSign up

markdown-clipboard.ts

488 lines17.4 KB

1const SKIPPED_TAGS = new Set(["BUTTON", "INPUT", "SCRIPT", "STYLE", "TEMPLATE"]);
2const SKIPPED_CLASS_NAMES = ["select-none", "sr-only"];
3const SANITIZED_HTML_SELECTOR = [
4 "button",
5 "input",
6 "script",
7 "style",
8 "svg",
9 '[aria-hidden="true"]',
10 ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`),
11].join(", ");
12​
13export interface MarkdownClipboardPayload {
14 text: string;
15 html: string;
17​
18function isSkippedElement(element: Element): boolean {
19 if (SKIPPED_TAGS.has(element.tagName) || element.localName === "svg") return true;
20 if (element.getAttribute("aria-hidden") === "true") return true;
21 return SKIPPED_CLASS_NAMES.some((className) => element.classList.contains(className));
23​
24function wrapInlineMarker(content: string, marker: string): string {
25 const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(content);
26 const core = match?.[2] ?? "";
27 if (!core) return content;
28 return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
30​
31// Highlighter spans or newlines identify block code even when its pre wrapper was not copied.
32function isBlockCodeElement(element: Element, content: string): boolean {
33 if (content.includes("\n")) return true;
34 for (const child of element.childNodes) {
35 if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {
36 return true;
37 }
38 }
39 return false;
41​
42function wrapInlineCode(code: string): string {
43 const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
44 (max, run) => Math.max(max, run.length),
45 0,
46 );
47 const fence = "`".repeat(Math.max(1, longestRun + (longestRun > 0 ? 1 : 0)));
48 const pad = code.startsWith("`") || code.endsWith("`") ? " " : "";
49 return `${fence}${pad}${code}${pad}${fence}`;
51​
52function codeFenceFor(code: string): string {
53 const longestRun = [...(code.match(/`{3,}/g) ?? [])].reduce(
54 (max, run) => Math.max(max, run.length),
55 0,
56 );
57 return "`".repeat(Math.max(3, longestRun + 1));
59​
60export function serializeCodeBlockToMarkdown(source: string, language: string | null): string {
61 const code = source.replace(/\n$/, "");
62 const fence = codeFenceFor(code);
63 return `${fence}${language ?? ""}\n${code}\n${fence}\n\n`;
65​
66// Brackets copied code so tidyMarkdown leaves its whitespace alone. Code is
67// copied as shown, without fences; DOM text from rendered markdown carries no NUL.
68const CODE_BOUNDARY = "\u0000";
69​
70function verbatimBlock(code: string): string {
71 return `${CODE_BOUNDARY}${code}${CODE_BOUNDARY}\n\n`;
73​
74// Odd parts are code and keep their markers, so outer passes still skip them.
75function outsideCode(markdown: string, tidy: (prose: string) => string): string {
76 return markdown
77 .split(CODE_BOUNDARY)
78 .map((part, index) =>
79 index % 2 === 1 ? `${CODE_BOUNDARY}${part}${CODE_BOUNDARY}` : tidy(part),
80 )
81 .join("");
83​
84function serializeCodeBlock(pre: Element): string {
85 return verbatimBlock((pre.textContent ?? "").replace(/\n$/, ""));
87​
88function serializeTableCell(cell: Element): string {
89 return serializeChildren(cell, true).replace(/\n+/g, " ").trim();
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(" | ")} |`;
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`;
120​
121function serializeListItem(
122 item: Element,
123 ordered: boolean,
124 index: number,
125 inTableCell: boolean,
126): string {
127 const checkbox = item.querySelector(
128 ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]',
129 );
130 const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";
131 const bullet = ordered ? `${index}. ` : "- ";
132 const marker = `${bullet}${task}`;
133 let content = outsideCode(serializeChildren(item, inTableCell), (prose) =>
134 prose.replace(/\n{3,}/g, "\n\n"),
135 ).trim();
136 if (!item.querySelector(":scope > p")) {
137 content = outsideCode(content, (prose) => prose.replace(/\n{2,}/g, "\n"));
138 }
139 // Continuations align after the bullet; task boxes belong to the item text.
140 const continuationIndent = " ".repeat(bullet.length);
141 const [first = "", ...rest] = content.split("\n");
142 return [
143 `${marker}${first}`,
144 ...rest.map((line) => (line.length > 0 ? `${continuationIndent}${line}` : line)),
145 ].join("\n");
147​
148function serializeList(list: Element, ordered: boolean, inTableCell: boolean): string {
149 const start = Number.parseInt(list.getAttribute("start") ?? "1", 10);
150 const items = [...list.children].filter((child) => child.tagName === "LI");
151 if (items.length === 0) return "";
152 let next = Number.isNaN(start) ? 1 : start;
153 return `${items
154 .map((item) => {
155 const value = Number.parseInt(item.getAttribute("value") ?? "", 10);
156 if (ordered && !Number.isNaN(value)) next = value;
157 return serializeListItem(item, ordered, next++, inTableCell);
158 })
159 .join("\n")}\n\n`;
161​
162function serializeBlockquote(quote: Element, inTableCell: boolean): string {
163 const content = outsideCode(serializeChildren(quote, inTableCell), (prose) =>
164 prose.replace(/\n{3,}/g, "\n\n"),
165 ).trim();
166 if (!content) return "";
167 const quoted = content
168 .split("\n")
169 .map((line) => (line.length > 0 ? `> ${line}` : ">"))
170 .join("\n");
171 return `${quoted}\n\n`;
173​
174function serializeDetails(details: Element): string {
175 const summary =
176 details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ??
177 "Details";
178 const contentNode = details.querySelector(":scope > * [data-markdown-details-content]");
179 const content = contentNode ? serializeChildren(contentNode).trim() : "";
180 const open = details.getAttribute("data-markdown-details-open") === "true" ? " open" : "";
181 return `<details${open}>\n<summary>${summary}</summary>${content ? `\n\n${content}` : ""}\n</details>\n\n`;
183​
184function serializeAnchor(anchor: Element, inTableCell: boolean): string {
185 const markdownCopy = anchor.getAttribute("data-markdown-copy");
186 if (markdownCopy !== null) return escapeTablePipes(markdownCopy, inTableCell);
187 const content = serializeChildren(anchor, inTableCell);
188 const href = escapeTablePipes(anchor.getAttribute("href") ?? "", inTableCell);
189 if (!/^https?:\/\//i.test(href)) return content;
190 const label = content.trim();
191 if (!label) return "";
192 if (label === href) return href;
193 return `[${label}](${href})`;
195​
196/**
197 * GFM splits a table row at every unescaped pipe, but its math-aware parsing
198 * keeps a formula's pipes in the cell, so formula source is copied unescaped.
199 */
200function escapeTablePipes(text: string, inTableCell: boolean): string {
201 return inTableCell ? text.replaceAll("|", "\\|") : text;
203​
204function serializeChildren(node: Node, inTableCell = false): string {
205 let out = "";
206 for (const child of node.childNodes) {
207 out += serializeNode(child, inTableCell);
208 }
209 return out;
211​
212function serializeNode(node: Node, inTableCell = false): string {
213 if (node.nodeType === Node.TEXT_NODE) {
214 const text = node.textContent ?? "";
215 if (text.includes("\n") && text.trim().length === 0) return "\n";
216 return escapeTablePipes(text, inTableCell);
217 }
218 if (node.nodeType !== Node.ELEMENT_NODE) return "";
219 const element = node as Element;
220 if (element.hasAttribute("data-markdown-details")) {
221 return escapeTablePipes(serializeDetails(element), inTableCell);
222 }
223 const markdownCopy = element.getAttribute("data-markdown-copy");
224 if (markdownCopy !== null) {
225 // A diagram copies as its fenced source, which is code too.
226 if (element.hasAttribute("data-markdown-mermaid")) {
227 return verbatimBlock(escapeTablePipes(markdownCopy.trimEnd(), inTableCell));
228 }
229 return element.hasAttribute("data-markdown-math")
230 ? markdownCopy
231 : escapeTablePipes(markdownCopy, inTableCell);
232 }
233 if (isSkippedElement(element)) return "";
234​
235 const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1];
236 if (headingLevel) {
237 return `${"#".repeat(Number(headingLevel))} ${serializeChildren(element, inTableCell).trim()}\n\n`;
238 }
239​
240 switch (element.tagName) {
241 case "BR":
242 return "\n";
243 case "HR":
244 return "---\n\n";
245 case "P":
246 return `${serializeChildren(element, inTableCell).trim()}\n\n`;
247 case "PRE":
248 return escapeTablePipes(serializeCodeBlock(element), inTableCell);
249 case "CODE": {
250 const content = escapeTablePipes(element.textContent ?? "", inTableCell);
251 return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
252 }
253 case "STRONG":
254 case "B":
255 return wrapInlineMarker(serializeChildren(element, inTableCell), "**");
256 case "EM":
257 case "I":
258 return wrapInlineMarker(serializeChildren(element, inTableCell), "*");
259 case "DEL":
260 case "S":
261 return wrapInlineMarker(serializeChildren(element, inTableCell), "~~");
262 case "A":
263 return serializeAnchor(element, inTableCell);
264 case "IMG": {
265 const alt = escapeTablePipes(element.getAttribute("alt") ?? "", inTableCell);
266 const src = escapeTablePipes(element.getAttribute("src") ?? "", inTableCell);
267 return alt && src ? `![${alt}](${src})` : "";
268 }
269 case "UL":
270 return serializeList(element, false, inTableCell);
271 case "OL":
272 return serializeList(element, true, inTableCell);
273 case "BLOCKQUOTE":
274 return serializeBlockquote(element, inTableCell);
275 case "TABLE":
276 return escapeTablePipes(serializeTable(element), inTableCell);
277 case "DIV":
278 case "SECTION":
279 case "ARTICLE": {
280 const content = serializeChildren(element, inTableCell);
281 return content && !content.endsWith("\n") ? `${content}\n` : content;
282 }
283 default:
284 return serializeChildren(element, inTableCell);
285 }
287​
288interface SoleCodeBlockScan {
289 pre: Element | null;
290 other: boolean;
292​
293function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void {
294 for (const child of node.childNodes) {
295 if (scan.other) return;
296 if (child.nodeType === Node.TEXT_NODE) {
297 if ((child.textContent ?? "").trim().length > 0) scan.other = true;
298 continue;
299 }
300 if (child.nodeType !== Node.ELEMENT_NODE) continue;
301 const element = child as Element;
302 // Explicit markdown still counts on skipped tags such as file-chip buttons.
303 if (element.hasAttribute("data-markdown-details")) {
304 scan.other = true;
305 continue;
306 }
307 const markdownCopy = element.getAttribute("data-markdown-copy");
308 if (markdownCopy !== null) {
309 if (markdownCopy.trim().length > 0) scan.other = true;
310 continue;
311 }
312 if (isSkippedElement(element)) continue;
313 if (element.tagName === "PRE") {
314 if (scan.pre) scan.other = true;
315 else scan.pre = element;
316 continue;
317 }
318 if (element.tagName === "IMG" || element.tagName === "HR") {
319 scan.other = true;
320 continue;
321 }
322 if (element.tagName === "LI") {
323 const preBeforeItem = scan.pre;
324 scanForSoleCodeBlock(element, scan);
325 if (!scan.other && scan.pre === preBeforeItem) scan.other = true;
326 continue;
327 }
328 scanForSoleCodeBlock(element, scan);
329 }
331​
332// Selecting a block's final newline pulls in its closing pre even when only code was highlighted.
333function soleCodeBlock(container: Node): Element | null {
334 const scan: SoleCodeBlockScan = { pre: null, other: false };
335 scanForSoleCodeBlock(container, scan);
336 return scan.other ? null : scan.pre;
338​
339function tidyMarkdown(markdown: string): string {
340 // Odd parts are code, kept verbatim, so a selection that starts or ends inside
341 // code keeps that code's leading and trailing whitespace.
342 const parts = markdown.split(CODE_BOUNDARY);
343 const last = parts.length - 1;
344 return parts
345 .map((part, index) => {
346 if (index % 2 === 1) return part;
347 let prose = part.replace(/[ \t]+(?=\n)/g, "").replace(/\n{3,}/g, "\n\n");
348 if (index === 0) prose = prose.trimStart();
349 if (index === last) prose = prose.trimEnd();
350 return prose;
351 })
352 .join("");
354​
355export function serializeRenderedMarkdownFragment(container: Node): string {
356 const codeBlock = soleCodeBlock(container);
357 if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, "");
358 return tidyMarkdown(serializeChildren(container));
360​
361export function serializeTableElementToMarkdown(table: Element): string {
362 // Raw HTML can put a pre in a cell; with no tidy pass here, drop its markers.
363 return serializeTable(table).replaceAll(CODE_BOUNDARY, "").trim();
365​
366// The wrapper MarkdownMath puts on every formula it renders. The sanitizer drops
367// the attribute from authored HTML.
368const MATH_SELECTOR = "[data-markdown-math]";
369​
370export function mathWrapperOf(element: Element | null): Element | null {
371 return element?.closest(MATH_SELECTOR) ?? null;
373​
374/** A cell's text, with each formula as its TeX. */
375function csvCellText(cell: Element): string {
376 if (!cell.querySelector(MATH_SELECTOR)) return cell.textContent ?? "";
377 const copy = cell.cloneNode(true) as Element;
378 for (const wrapper of copy.querySelectorAll(MATH_SELECTOR)) {
379 wrapper.replaceWith(wrapper.getAttribute("data-markdown-copy")?.trim() ?? "");
380 }
381 return copy.textContent ?? "";
383​
384function csvCell(value: string): string {
385 const normalized = value.replace(/\s+/g, " ").trim();
386 return /[",\n]/.test(normalized) ? `"${normalized.replaceAll('"', '""')}"` : normalized;
388​
389export function serializeTableElementToCsv(table: Element): string {
390 const rows = [...table.querySelectorAll(":scope > thead > tr, :scope > tbody > tr, :scope > tr")];
391 const lines: string[] = [];
392 for (const row of rows) {
393 const cells = [...row.children].filter(
394 (cell) => cell.tagName === "TH" || cell.tagName === "TD",
395 );
396 if (cells.length === 0) continue;
397 lines.push(cells.map((cell) => csvCell(csvCellText(cell))).join(","));
398 }
399 return lines.join("\n");
401​
402function mermaidSourceHtml(source: string) {
403 const pre = document.createElement("pre");
404 const code = document.createElement("code");
405 code.textContent = source.trim();
406 pre.appendChild(code);
407 return pre;
409​
410function mathSourceElement(wrapper: Element): Element {
411 const code = document.createElement("code");
412 code.textContent = wrapper.getAttribute("data-markdown-copy")?.trim() ?? "";
413 if (wrapper.getAttribute("data-markdown-math") !== "display") return code;
414 const pre = document.createElement("pre");
415 pre.append(code);
416 return pre;
418​
419function sanitizedHtmlFrom(container: Element): string {
420 for (const diagram of container.querySelectorAll("[data-markdown-mermaid]")) {
421 const source = diagram.getAttribute("data-markdown-copy");
422 if (source !== null) diagram.replaceChildren(mermaidSourceHtml(source));
423 }
424 for (const wrapper of container.querySelectorAll(MATH_SELECTOR)) {
425 wrapper.replaceWith(mathSourceElement(wrapper));
426 }
427 for (const node of container.querySelectorAll(SANITIZED_HTML_SELECTOR)) {
428 if (
429 node.classList.contains("chat-markdown-file-link") ||
430 node.closest(".chat-markdown-file-link")
431 ) {
432 if (node.getAttribute("aria-hidden") === "true" || node.localName === "svg") {
433 node.remove();
434 }
435 continue;
436 }
437 node.remove();
438 }
439 return `<meta charset="utf-8">${container.innerHTML}`;
441​
442export function chatMarkdownClipboardPayload(
443 selection: Selection,
444): MarkdownClipboardPayload | null {
445 const texts: string[] = [];
446 const htmls: string[] = [];
447 for (let index = 0; index < selection.rangeCount; index += 1) {
448 const range = selection.getRangeAt(index);
449 if (range.collapsed) continue;
450 const container = document.createElement("div");
451 container.appendChild(range.cloneContents());
452 const ancestor = range.commonAncestorContainer;
453 const ancestorElement =
454 ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement;
455 const diagramSource = ancestorElement
456 ?.closest("[data-markdown-mermaid]")
457 ?.getAttribute("data-markdown-copy");
458 if (diagramSource != null) {
459 const text = diagramSource.trim();
460 if (!text) continue;
461 texts.push(text);
462 container.replaceChildren(mermaidSourceHtml(diagramSource));
463 htmls.push(sanitizedHtmlFrom(container));
464 continue;
465 }
466 // A range inside a formula would clone only KaTeX's glyphs, not the wrapper holding the TeX.
467 const math = mathWrapperOf(ancestorElement);
468 if (math) {
469 texts.push(math.getAttribute("data-markdown-copy")?.trim() ?? "");
470 htmls.push(`<meta charset="utf-8">${mathSourceElement(math).outerHTML}`);
471 continue;
472 }
473 if (ancestorElement?.closest("pre")) {
474 const text = range.toString();
475 if (text) {
476 texts.push(text);
477 htmls.push(sanitizedHtmlFrom(container));
478 }
479 continue;
480 }
481 const text = serializeRenderedMarkdownFragment(container);
482 if (!text) continue;
483 texts.push(text);
484 htmls.push(sanitizedHtmlFrom(container));
485 }
486 if (texts.length === 0) return null;
487 return { text: texts.join("\n\n"), html: htmls.join("") };