From 66ca2e3444d71bd9e1466c2898fa508d8c756e5e Mon Sep 17 00:00:00 2001 From: baldurk Date: Fri, 3 Jul 2026 11:13:21 +0100 Subject: [PATCH] Add copy button to code snippets in sphinx with sphinx_copybutton ext --- docs/conf.py | 2 +- docs/sphinx_exts/sphinx_copybutton/LICENSE | 21 +++ docs/sphinx_exts/sphinx_copybutton/README.md | 56 ++++++ .../sphinx_exts/sphinx_copybutton/__init__.py | 99 ++++++++++ .../sphinx_copybutton/_static/check-solid.svg | 4 + .../_static/clipboard.min.js | 7 + .../sphinx_copybutton/_static/copy-button.svg | 5 + .../sphinx_copybutton/_static/copybutton.css | 94 ++++++++++ .../sphinx_copybutton/_static/copybutton.js_t | 175 ++++++++++++++++++ .../_static/copybutton_funcs.js | 73 ++++++++ 10 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 docs/sphinx_exts/sphinx_copybutton/LICENSE create mode 100644 docs/sphinx_exts/sphinx_copybutton/README.md create mode 100644 docs/sphinx_exts/sphinx_copybutton/__init__.py create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/check-solid.svg create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/clipboard.min.js create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/copy-button.svg create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/copybutton.css create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/copybutton.js_t create mode 100644 docs/sphinx_exts/sphinx_copybutton/_static/copybutton_funcs.js diff --git a/docs/conf.py b/docs/conf.py index 4b3e34b3d..87de0eb87 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,7 +58,7 @@ sys.path.insert(0, os.path.abspath('sphinx_exts')) # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', 'sphinx_paramlinks', 'sphinxcontrib_jquery'] +extensions = ['sphinx.ext.autodoc', 'sphinx_paramlinks', 'sphinxcontrib_jquery', 'sphinx_copybutton'] if tags.has('spelling'): # type: ignore extensions.append('sphinxcontrib.spelling') diff --git a/docs/sphinx_exts/sphinx_copybutton/LICENSE b/docs/sphinx_exts/sphinx_copybutton/LICENSE new file mode 100644 index 000000000..dab6ba48f --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Chris Holdgraf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/sphinx_exts/sphinx_copybutton/README.md b/docs/sphinx_exts/sphinx_copybutton/README.md new file mode 100644 index 000000000..714e5e229 --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/README.md @@ -0,0 +1,56 @@ +# sphinx-copybutton + +[![PyPI](https://img.shields.io/pypi/v/sphinx-copybutton.svg)](https://pypi.org/project/sphinx_copybutton/) | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/sphinx-copybutton.svg)](https://anaconda.org/conda-forge/sphinx-copybutton) | [![Documentation](https://readthedocs.org/projects/sphinx-copybutton/badge/?version=latest)](https://sphinx-copybutton.readthedocs.io/en/latest/?badge=latest) + +A small sphinx extension to add a "copy" button to code blocks. + +See [the sphinx-copybutton documentation](https://sphinx-copybutton.readthedocs.io/en/latest/) for more details! + +![Copy Button Demo](https://user-images.githubusercontent.com/1839645/150200219-73663c59-08fd-4185-b157-62f3769c02ac.gif) + +## Installation + +You can install `sphinx-copybutton` with `pip`: + +```bash +pip install sphinx-copybutton +``` + +Or with `conda` via `conda-forge`: + +```bash +conda install -c conda-forge sphinx-copybutton +``` + + +## Usage + +In your `conf.py` configuration file, add `sphinx_copybutton` to your extensions list. +E.g.: + +```python +extensions = [ + ... + 'sphinx_copybutton' + ... +] +``` + +When you build your site, your code blocks should now have little copy buttons to their +right. Clicking the button will copy the code inside! + +## Customization + +If you'd like to customize the look of the copy buttons, you can over-write any of the +CSS rules specified in the Sphinx-CopyButton CSS file ([link](sphinx_copybutton/_static/copybutton.css)) + +## Development + +Development should principally adhere to the [EBP Developer Conventions](https://github.com/executablebooks/.github/blob/master/CONTRIBUTING.md) + +Sphinx-Copybutton is [hosted on the pypi repository](https://pypi.org/project/sphinx-copybutton/). +After a release - following the [EBP release instructions](https://github.com/executablebooks/.github/blob/master/CONTRIBUTING.md#releases-and-change-logs) - confirm that the new version of Sphinx-Copybutton [is posted to pypi](https://pypi.org/project/sphinx-copybutton/). + +## Acknowledgements + +Many thanks to the excellent [clipboard.js library](https://clipboardjs.com/) for the lightweight javascript code that powers the copy button! diff --git a/docs/sphinx_exts/sphinx_copybutton/__init__.py b/docs/sphinx_exts/sphinx_copybutton/__init__.py new file mode 100644 index 000000000..953e7f34d --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/__init__.py @@ -0,0 +1,99 @@ +"""A small sphinx extension to add "copy" buttons to code blocks.""" +from pathlib import Path +from sphinx.util import logging + +__version__ = "0.5.2" + +logger = logging.getLogger(__name__) + + +def scb_static_path(app): + app.config.html_static_path.append( + str(Path(__file__).parent.joinpath("_static").absolute()) + ) + + +def add_to_context(app, config): + # Update the global context + config.html_context.update( + {"copybutton_prompt_text": config.copybutton_prompt_text} + ) + config.html_context.update( + {"copybutton_prompt_is_regexp": config.copybutton_prompt_is_regexp} + ) + config.html_context.update( + {"copybutton_only_copy_prompt_lines": config.copybutton_only_copy_prompt_lines} + ) + config.html_context.update( + {"copybutton_remove_prompts": config.copybutton_remove_prompts} + ) + config.html_context.update( + {"copybutton_copy_empty_lines": config.copybutton_copy_empty_lines} + ) + config.html_context.update( + { + "copybutton_line_continuation_character": ( + config.copybutton_line_continuation_character + ) + } + ) + config.html_context.update( + {"copybutton_here_doc_delimiter": config.copybutton_here_doc_delimiter} + ) + + # Old image path deprecation + # REMOVE after next release + if config.copybutton_image_path: + path = Path(app.srcdir) / config.copybutton_image_path + logger.warning("copybutton_image_path is deprecated, use copybutton_image_svg") + if not path.exists(): + raise ValueError("copybutton_img_path does not exist") + if not path.suffix == ".svg": + raise ValueError("copybutton_img_path must be an SVG") + config.copybutton_image_svg = path.read_text() + + config.html_context.update({"copybutton_image_svg": config.copybutton_image_svg}) + config.html_context.update({"copybutton_selector": config.copybutton_selector}) + config.html_context.update( + { + "copybutton_format_func": Path(__file__) + .parent.joinpath("_static", "copybutton_funcs.js") + .read_text() + .replace("export function", "function") + } + ) + config.html_context.update({"copybutton_exclude": config.copybutton_exclude}) + + +def setup(app): + logger.verbose("Adding copy buttons to code blocks...") + # Add our static path + app.connect("builder-inited", scb_static_path) + + # configuration for this tool + app.add_config_value("copybutton_prompt_text", "", "html") + app.add_config_value("copybutton_prompt_is_regexp", False, "html") + app.add_config_value("copybutton_only_copy_prompt_lines", True, "html") + app.add_config_value("copybutton_remove_prompts", True, "html") + app.add_config_value("copybutton_copy_empty_lines", True, "html") + app.add_config_value("copybutton_line_continuation_character", "", "html") + app.add_config_value("copybutton_here_doc_delimiter", "", "html") + app.add_config_value("copybutton_image_svg", "", "html") + app.add_config_value("copybutton_selector", "div.highlight pre", "html") + app.add_config_value("copybutton_exclude", ".linenos", "html") + + # DEPRECATE THIS AFTER THE NEXT RELEASE + app.add_config_value("copybutton_image_path", "", "html") + + # Add configuration value to the template + app.connect("config-inited", add_to_context) + + # Add relevant code to headers + app.add_css_file("copybutton.css") + app.add_js_file("clipboard.min.js") + app.add_js_file("copybutton.js") + return { + "version": __version__, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/sphinx_exts/sphinx_copybutton/_static/check-solid.svg b/docs/sphinx_exts/sphinx_copybutton/_static/check-solid.svg new file mode 100644 index 000000000..92fad4b5c --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/_static/check-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/sphinx_exts/sphinx_copybutton/_static/clipboard.min.js b/docs/sphinx_exts/sphinx_copybutton/_static/clipboard.min.js new file mode 100644 index 000000000..54b3c4638 --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/_static/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.8 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return o}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),c=n.n(e);function a(t){try{return document.execCommand(t)}catch(t){return}}var f=function(t){t=c()(t);return a("cut"),t};var l=function(t){var e,n,o,r=1 + + + + diff --git a/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.css b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.css new file mode 100644 index 000000000..f1916ec7d --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.css @@ -0,0 +1,94 @@ +/* Copy buttons */ +button.copybtn { + position: absolute; + display: flex; + top: .3em; + right: .3em; + width: 1.7em; + height: 1.7em; + opacity: 0; + transition: opacity 0.3s, border .3s, background-color .3s; + user-select: none; + padding: 0; + border: none; + outline: none; + border-radius: 0.4em; + /* The colors that GitHub uses */ + border: #1b1f2426 1px solid; + background-color: #f6f8fa; + color: #57606a; +} + +button.copybtn.success { + border-color: #22863a; + color: #22863a; +} + +button.copybtn svg { + stroke: currentColor; + width: 1.5em; + height: 1.5em; + padding: 0.1em; +} + +div.highlight { + position: relative; +} + +/* Show the copybutton */ +.highlight:hover button.copybtn, button.copybtn.success { + opacity: 1; +} + +.highlight button.copybtn:hover { + background-color: rgb(235, 235, 235); +} + +.highlight button.copybtn:active { + background-color: rgb(187, 187, 187); +} + +/** + * A minimal CSS-only tooltip copied from: + * https://codepen.io/mildrenben/pen/rVBrpK + * + * To use, write HTML like the following: + * + *

Short

+ */ + .o-tooltip--left { + position: relative; + } + + .o-tooltip--left:after { + opacity: 0; + visibility: hidden; + position: absolute; + content: attr(data-tooltip); + padding: .2em; + font-size: .8em; + left: -.2em; + background: grey; + color: white; + white-space: nowrap; + z-index: 2; + border-radius: 2px; + transform: translateX(-102%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); +} + +.o-tooltip--left:hover:after { + display: block; + opacity: 1; + visibility: visible; + transform: translateX(-100%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); + transition-delay: .5s; +} + +/* By default the copy button shouldn't show up when printing a page */ +@media print { + button.copybtn { + display: none; + } +} diff --git a/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.js_t b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.js_t new file mode 100644 index 000000000..ec91f2096 --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton.js_t @@ -0,0 +1,175 @@ +// Localization support +const messages = { + 'en': { + 'copy': 'Copy', + 'copy_to_clipboard': 'Copy to clipboard', + 'copy_success': 'Copied!', + 'copy_failure': 'Failed to copy', + }, + 'es' : { + 'copy': 'Copiar', + 'copy_to_clipboard': 'Copiar al portapapeles', + 'copy_success': '¡Copiado!', + 'copy_failure': 'Error al copiar', + }, + 'de' : { + 'copy': 'Kopieren', + 'copy_to_clipboard': 'In die Zwischenablage kopieren', + 'copy_success': 'Kopiert!', + 'copy_failure': 'Fehler beim Kopieren', + }, + 'fr' : { + 'copy': 'Copier', + 'copy_to_clipboard': 'Copier dans le presse-papier', + 'copy_success': 'Copié !', + 'copy_failure': 'Échec de la copie', + }, + 'ru': { + 'copy': 'Скопировать', + 'copy_to_clipboard': 'Скопировать в буфер', + 'copy_success': 'Скопировано!', + 'copy_failure': 'Не удалось скопировать', + }, + 'zh-CN': { + 'copy': '复制', + 'copy_to_clipboard': '复制到剪贴板', + 'copy_success': '复制成功!', + 'copy_failure': '复制失败', + }, + 'it' : { + 'copy': 'Copiare', + 'copy_to_clipboard': 'Copiato negli appunti', + 'copy_success': 'Copiato!', + 'copy_failure': 'Errore durante la copia', + } +} + +let locale = 'en' +if( document.documentElement.lang !== undefined + && messages[document.documentElement.lang] !== undefined ) { + locale = document.documentElement.lang +} + +let doc_url_root = DOCUMENTATION_OPTIONS.URL_ROOT; +if (doc_url_root == '#') { + doc_url_root = ''; +} + +/** + * SVG files for our copy buttons + */ +let iconCheck = ` + ${messages[locale]['copy_success']} + + +` + +// If the user specified their own SVG use that, otherwise use the default +let iconCopy = `{{ copybutton_image_svg }}`; +if (!iconCopy) { + iconCopy = ` + ${messages[locale]['copy_to_clipboard']} + + + +` +} + +/** + * Set up copy/paste for code blocks + */ + +const runWhenDOMLoaded = cb => { + if (document.readyState != 'loading') { + cb() + } else if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', cb) + } else { + document.attachEvent('onreadystatechange', function() { + if (document.readyState == 'complete') cb() + }) + } +} + +const codeCellId = index => `codecell${index}` + +// Clears selected text since ClipboardJS will select the text when copying +const clearSelection = () => { + if (window.getSelection) { + window.getSelection().removeAllRanges() + } else if (document.selection) { + document.selection.empty() + } +} + +// Changes tooltip text for a moment, then changes it back +// We want the timeout of our `success` class to be a bit shorter than the +// tooltip and icon change, so that we can hide the icon before changing back. +var timeoutIcon = 2000; +var timeoutSuccessClass = 1500; + +const temporarilyChangeTooltip = (el, oldText, newText) => { + el.setAttribute('data-tooltip', newText) + el.classList.add('success') + // Remove success a little bit sooner than we change the tooltip + // So that we can use CSS to hide the copybutton first + setTimeout(() => el.classList.remove('success'), timeoutSuccessClass) + setTimeout(() => el.setAttribute('data-tooltip', oldText), timeoutIcon) +} + +// Changes the copy button icon for two seconds, then changes it back +const temporarilyChangeIcon = (el) => { + el.innerHTML = iconCheck; + setTimeout(() => {el.innerHTML = iconCopy}, timeoutIcon) +} + +const addCopyButtonToCodeCells = () => { + // If ClipboardJS hasn't loaded, wait a bit and try again. This + // happens because we load ClipboardJS asynchronously. + if (window.ClipboardJS === undefined) { + setTimeout(addCopyButtonToCodeCells, 250) + return + } + + // Add copybuttons to all of our code cells + const COPYBUTTON_SELECTOR = '{{ copybutton_selector }}'; + const codeCells = document.querySelectorAll(COPYBUTTON_SELECTOR) + codeCells.forEach((codeCell, index) => { + const id = codeCellId(index) + codeCell.setAttribute('id', id) + + const clipboardButton = id => + `` + codeCell.insertAdjacentHTML('afterend', clipboardButton(id)) + }) + +{{ copybutton_format_func }} + +var copyTargetText = (trigger) => { + var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); + + // get filtered text + let exclude = '{{ copybutton_exclude }}'; + + let text = filterText(target, exclude); + return formatCopyText(text, {{ "{!r}".format(copybutton_prompt_text) }}, {{ copybutton_prompt_is_regexp | lower }}, {{ copybutton_only_copy_prompt_lines | lower }}, {{ copybutton_remove_prompts | lower }}, {{ copybutton_copy_empty_lines | lower }}, {{ "{!r}".format(copybutton_line_continuation_character) }}, {{ "{!r}".format(copybutton_here_doc_delimiter) }}) +} + + // Initialize with a callback so we can modify the text before copy + const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText}) + + // Update UI with error/success messages + clipboard.on('success', event => { + clearSelection() + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_success']) + temporarilyChangeIcon(event.trigger) + }) + + clipboard.on('error', event => { + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_failure']) + }) +} + +runWhenDOMLoaded(addCopyButtonToCodeCells) diff --git a/docs/sphinx_exts/sphinx_copybutton/_static/copybutton_funcs.js b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton_funcs.js new file mode 100644 index 000000000..dbe1aaad7 --- /dev/null +++ b/docs/sphinx_exts/sphinx_copybutton/_static/copybutton_funcs.js @@ -0,0 +1,73 @@ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Removes excluded text from a Node. + * + * @param {Node} target Node to filter. + * @param {string} exclude CSS selector of nodes to exclude. + * @returns {DOMString} Text from `target` with text removed. + */ +export function filterText(target, exclude) { + const clone = target.cloneNode(true); // clone as to not modify the live DOM + if (exclude) { + // remove excluded nodes + clone.querySelectorAll(exclude).forEach(node => node.remove()); + } + return clone.innerText; +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +export function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +}