Add copy button to code snippets in sphinx with sphinx_copybutton ext

This commit is contained in:
baldurk
2026-08-13 21:05:12 +01:00
parent 45d22e3f0d
commit 66ca2e3444
10 changed files with 535 additions and 1 deletions
+1 -1
View File
@@ -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')
@@ -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.
@@ -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!
@@ -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,
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-check" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="#22863a" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 12l5 5l10 -10" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" />
</svg>

After

Width:  |  Height:  |  Size: 411 B

@@ -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:
*
* <p class="o-tooltip--left" data-tooltip="Hey">Short</p>
*/
.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;
}
}
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-check" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="#22863a" fill="none" stroke-linecap="round" stroke-linejoin="round">
<title>${messages[locale]['copy_success']}</title>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M5 12l5 5l10 -10" />
</svg>`
// If the user specified their own SVG use that, otherwise use the default
let iconCopy = `{{ copybutton_image_svg }}`;
if (!iconCopy) {
iconCopy = `<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-copy" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<title>${messages[locale]['copy_to_clipboard']}</title>
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2" />
</svg>`
}
/**
* 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 =>
`<button class="copybtn o-tooltip--left" data-tooltip="${messages[locale]['copy']}" data-clipboard-target="#${id}">
${iconCopy}
</button>`
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)
@@ -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
}