Skip to content
Source
Sign inSign up

markdown-clipboard.ts

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