/** * Custom copy handler for Python console (pycon) code blocks. * Strips >>> and ... prompts when copying code examples. */ document.addEventListener("DOMContentLoaded", function () { const COPY_BUTTON_SELECTOR = ".md-clipboard, .md-code__button"; function handleCopyButtonClick(event) { const copyButton = event.target.closest(COPY_BUTTON_SELECTOR); if (!copyButton) return; const codeBlock = findCodeBlockForCopyButton(copyButton); if (!codeBlock) return; const rawText = codeBlock.textContent || ""; if (!shouldStripPrompts(codeBlock, rawText)) return; const strippedText = stripPythonPrompts(rawText); primeClipboardButton(copyButton, strippedText); } function handleCopyButtonPointerDown(event) { const copyButton = event.target.closest(COPY_BUTTON_SELECTOR); if (!copyButton) return; const codeBlock = findCodeBlockForCopyButton(copyButton); if (!codeBlock) return; const rawText = codeBlock.textContent || ""; if (!shouldStripPrompts(codeBlock, rawText)) return; const strippedText = stripPythonPrompts(rawText); primeClipboardButton(copyButton, strippedText); } function handleSelectionCopy(event) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const anchorNode = range.commonAncestorContainer; const codeBlock = anchorNode.nodeType === Node.ELEMENT_NODE ? anchorNode.closest("code") : anchorNode.parentElement?.closest("code"); if (!codeBlock) return; const rawText = selection.toString(); if (!shouldStripPrompts(codeBlock, rawText)) return; event.preventDefault(); event.stopPropagation(); const strippedText = stripPythonPrompts(rawText); event.clipboardData?.setData("text/plain", strippedText); } function bindCopyButtons(root) { root .querySelectorAll(COPY_BUTTON_SELECTOR) .forEach((button) => { button.removeEventListener("click", handleCopyButtonClick, true); button.addEventListener("click", handleCopyButtonClick, true); button.removeEventListener( "pointerdown", handleCopyButtonPointerDown, true ); button.addEventListener( "pointerdown", handleCopyButtonPointerDown, true ); }); } function observeDynamicCopyButtons() { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== "childList") continue; mutation.addedNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.matches?.(COPY_BUTTON_SELECTOR)) { bindCopyButtons(node.parentElement || document); return; } if (node.querySelectorAll) { const hasButtons = node.querySelectorAll(COPY_BUTTON_SELECTOR); if (hasButtons.length > 0) { bindCopyButtons(node); } } }); } }); observer.observe(document.body, { childList: true, subtree: true }); } document.addEventListener("click", handleCopyButtonClick, true); document.addEventListener("pointerdown", handleCopyButtonPointerDown, true); document.addEventListener("copy", handleSelectionCopy, true); bindCopyButtons(document); observeDynamicCopyButtons(); }); function primeClipboardButton(copyButton, strippedText) { copyButton.setAttribute("data-clipboard-text", strippedText); copyButton.removeAttribute("data-clipboard-target"); copyButton.setAttribute("data-md-clipboard", "true"); } function shouldStripPrompts(codeBlock, rawText) { const hasReplPrompts = /(^|\n)[ \t]*(>>>|\.\.\.)/.test(rawText); return ( hasReplPrompts || codeBlock.classList.contains("language-pycon") || codeBlock.closest("pre")?.classList.contains("pycon") || codeBlock.closest(".pycon") !== null || codeBlock.closest(".highlight")?.classList.contains("pycon") ); } function findCodeBlockForCopyButton(copyButton) { const targetSelector = copyButton.getAttribute("data-clipboard-target"); if (targetSelector) { const target = document.querySelector(targetSelector); const targetCode = target?.querySelector?.("code") || target; if (targetCode?.tagName?.toLowerCase() === "code") { return targetCode; } } return ( copyButton.closest("pre")?.querySelector("code") || copyButton.parentElement?.querySelector("pre code") || copyButton .closest(".highlight, .codehilite, .md-typeset__scrollwrap, .md-typeset") ?.querySelector("pre code") || copyButton .closest(".highlight, .codehilite, .md-typeset__scrollwrap, .md-typeset") ?.querySelector("code") ); } /** * Strips Python REPL prompts (>>> and ...) from code text. * Also removes output lines (lines that don't start with >>> or ...). * * NOTE: This is a best-effort parser. It preserves unprompted lines inside * triple-quoted strings, but it does not fully model Python's tokenizer. */ function stripPythonPrompts(text) { const lines = text.split("\n"); const codeLines = []; let inTripleQuotedString = false; function toggleTripleQuoteState(sourceLine) { const tripleQuotePattern = /("""|''')/g; const matches = sourceLine.match(tripleQuotePattern); if (!matches) return; if (matches.length % 2 === 1) { inTripleQuotedString = !inTripleQuotedString; } } for (const line of lines) { const trimmedLine = line.trimEnd(); // Primary prompt: ">>> " if (trimmedLine.startsWith(">>> ")) { const stripped = trimmedLine.slice(4); codeLines.push(stripped); toggleTripleQuoteState(stripped); } // Continuation prompt: "... " else if (trimmedLine.startsWith("... ")) { const stripped = trimmedLine.slice(4); codeLines.push(stripped); toggleTripleQuoteState(stripped); } // Handle prompts without space after (edge case) else if (trimmedLine === ">>>") { codeLines.push(""); } else if (trimmedLine === "...") { codeLines.push(""); } else if (inTripleQuotedString) { codeLines.push(trimmedLine); toggleTripleQuoteState(trimmedLine); } // Skip output lines (lines that don't start with prompts) // This intentionally excludes output like "1.0" from the copied text } return codeLines.join("\n").trim(); }