Skip to content

Commit 581e80e

Browse files
committed
mcp(docs[_static]): re-attach prompt-admonition copy buttons after gp-sphinx SPA swap
``copybutton_selector`` in ``docs/conf.py`` is ``"div.highlight pre, div.admonition.prompt > p:last-child"`` — we copy *prompt text* as well as code. On full-page load, ``sphinx-copybutton`` iterates that selector and inserts a ``.copybtn`` after every match, then binds ``new ClipboardJS('.copybtn', ...)``; ClipboardJS uses delegated listening on ``document.body``, so those clicks keep working across SPA DOM swaps. On SPA navigation, however, gp-sphinx's ``spa-nav.js::addCopyButtons`` iterates ``"div.highlight pre"`` only — it does NOT re-attach buttons to ``.admonition.prompt > p:last-child``. After an SPA swap, prompt-heavy pages like ``/recipes/`` (no code blocks, eight ``.admonition.prompt``) render naked: no copy affordance at all. This is the user-visible regression introduced by the gp-sphinx migration (``c822f02``). This shim fills the gap: * Capture the first live ``.copybtn`` that appears anywhere in the document as a reusable template, so ``sphinx-copybutton``'s locale-specific tooltip and tabler-copy SVG are preserved exactly. ``FALLBACK_COPYBTN_HTML`` covers the rare case where the user's first page has no ``.copybtn`` anywhere (landing with no code and no prompts) before any SPA nav. * On every SPA swap, re-insert ``.copybtn`` siblings after any ``.admonition.prompt > p:last-child`` lacking one, assigning the ``<p>`` a ``mcp-promptcell-N`` id and wiring the button's ``data-clipboard-target``. The inserted element plugs into ClipboardJS's existing body delegation *and* the project's capture-phase ``prompt-copy.js`` delegation transparently — no extra click handlers required. Initial-load ordering: at deferred-script execution time ``document.readyState`` is ``"interactive"``, not ``"loading"``, so a naive readyState check would run our initial pass eagerly — *before* sphinx-copybutton's DOMContentLoaded handler fires, letting our fallback template beat sphinx-copybutton's proper one. We gate on ``"complete"`` instead, so we always register a DOMContentLoaded listener (which fires after sphinx-copybutton's, registered earlier when ``readyState`` was ``"loading"``) unless the document is already fully loaded. Verified end-to-end in real Chromium via Playwright: * Fresh load of ``/recipes/``: 8 buttons, all with sphinx-copybutton's ``#codecell{N}`` targets and tabler-copy SVG — shim is inert. * SPA-nav away then back to ``/recipes/``: 8 buttons re-inserted with ``#mcp-promptcell-{N}`` targets. * Click on a shim-inserted button: ``prompt-copy.js``'s capture-phase delegation fires, markdown-preserving ``navigator.clipboard.writeText`` succeeds, ``.success`` class + ``"Copied!"`` tooltip + tabler-check SVG swap — identical feedback to a fresh-load click. * ``/topics/prompting/`` (code-only, no prompts): 4 buttons re-added by gp-sphinx, clicks still fire through ClipboardJS delegation. * Multiple SPA navs in a row: idempotent, no double-insertion. The correct upstream fix is in gp-sphinx — its ``addCopyButtons`` should iterate the full ``copybutton_selector`` (or dispatch a ``spa-nav-complete`` event that consumers like ``sphinx-copybutton`` can hook). Until then, this project-local shim keeps the docs behaving.
1 parent bd0c1ac commit 581e80e

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Re-attach copy buttons to ``.admonition.prompt > p:last-child`` after
3+
* gp-sphinx's SPA DOM swap.
4+
*
5+
* Context:
6+
*
7+
* - ``copybutton_selector`` in ``docs/conf.py`` is
8+
* ``"div.highlight pre, div.admonition.prompt > p:last-child"`` — we copy
9+
* *prompt text* as well as code.
10+
* - On full-page load, ``sphinx-copybutton`` iterates that selector and
11+
* inserts a ``.copybtn`` after every match, then binds
12+
* ``new ClipboardJS('.copybtn', ...)``. ClipboardJS uses delegated
13+
* listening on ``document.body``, so those clicks keep working across
14+
* SPA DOM swaps.
15+
* - On SPA navigation, gp-sphinx's ``spa-nav.js::addCopyButtons`` iterates
16+
* ``"div.highlight pre"`` only — it does NOT re-attach buttons to
17+
* ``.admonition.prompt > p:last-child``. After an SPA swap, pages like
18+
* ``/recipes/`` (prompt-heavy, no code blocks) render naked: no copy
19+
* affordance at all.
20+
*
21+
* This shim: capture the first ``.copybtn`` that appears anywhere in the
22+
* document as a reusable template (so we pick up ``sphinx-copybutton``'s
23+
* locale-specific tooltip and icon exactly), then after every SPA swap
24+
* re-insert buttons on prompt-admonition ``<p>`` elements that lack a
25+
* ``.copybtn`` sibling. Because the inserted elements have
26+
* ``class="copybtn"`` and a ``data-clipboard-target`` pointing to a
27+
* ``<p>`` with a matching ``id``, they plug into ClipboardJS's
28+
* body-delegated listener transparently and behave identically to
29+
* initially-rendered buttons.
30+
*
31+
* ``FALLBACK_COPYBTN_HTML`` covers the rare case where the user's first
32+
* page has no ``.copybtn`` anywhere (e.g. a landing page with no code
33+
* blocks and no prompt admonitions) — the fallback button is a bare
34+
* ``.copybtn`` with the same MDI "content-copy" icon upstream
35+
* ``sphinx-copybutton`` ships. Ugly if tooltip styling needs the exact
36+
* template but functional for clicks.
37+
*
38+
* The correct upstream fix is in gp-sphinx — its ``addCopyButtons``
39+
* should iterate the full ``copybutton_selector`` (or dispatch a
40+
* ``spa-nav-complete`` event that consumers like ``sphinx-copybutton``
41+
* can hook). Until then, this project-local shim keeps the docs
42+
* behaving.
43+
*/
44+
(function () {
45+
"use strict";
46+
47+
if (!window.MutationObserver) return;
48+
49+
var PROMPT_TARGET = ".admonition.prompt > p:last-child";
50+
var FALLBACK_COPYBTN_HTML =
51+
'<button class="copybtn o-tooltip--left" data-tooltip="Copy">' +
52+
'<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">' +
53+
'<title>Copy</title>' +
54+
'<path fill="currentColor" d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/>' +
55+
"</svg></button>";
56+
57+
var copyBtnTemplate = null;
58+
var idCounter = 0;
59+
60+
function ensureTemplate() {
61+
if (copyBtnTemplate) return true;
62+
var live = document.querySelector(".copybtn");
63+
if (live) {
64+
copyBtnTemplate = live.cloneNode(true);
65+
copyBtnTemplate.classList.remove("success");
66+
copyBtnTemplate.removeAttribute("data-clipboard-target");
67+
return true;
68+
}
69+
// Fallback: no live .copybtn on page — fabricate from known markup.
70+
var holder = document.createElement("div");
71+
holder.innerHTML = FALLBACK_COPYBTN_HTML;
72+
copyBtnTemplate = holder.firstChild;
73+
return true;
74+
}
75+
76+
function ensurePromptButtons() {
77+
if (!ensureTemplate()) return;
78+
document.querySelectorAll(PROMPT_TARGET).forEach(function (p) {
79+
var next = p.nextElementSibling;
80+
if (next && next.classList && next.classList.contains("copybtn")) {
81+
return;
82+
}
83+
if (!p.id) {
84+
p.id = "mcp-promptcell-" + idCounter;
85+
idCounter += 1;
86+
}
87+
var btn = copyBtnTemplate.cloneNode(true);
88+
btn.classList.remove("success");
89+
btn.setAttribute("data-clipboard-target", "#" + p.id);
90+
p.insertAdjacentElement("afterend", btn);
91+
});
92+
}
93+
94+
// Observer has two jobs:
95+
// (a) capture the template the instant sphinx-copybutton inserts its
96+
// first ``.copybtn`` (happens at DOMContentLoaded, regardless of
97+
// listener-registration order vs our own);
98+
// (b) detect SPA-swap completion (a subtree addition that contains a
99+
// ``.admonition.prompt``) and re-insert prompt buttons.
100+
new MutationObserver(function (records) {
101+
var sawCopybtn = false;
102+
var sawArticle = false;
103+
for (var i = 0; i < records.length; i += 1) {
104+
var added = records[i].addedNodes;
105+
for (var j = 0; j < added.length; j += 1) {
106+
var n = added[j];
107+
if (n.nodeType !== 1) continue;
108+
var cls = n.classList;
109+
if (cls && cls.contains("copybtn")) sawCopybtn = true;
110+
if (cls && cls.contains("admonition") && cls.contains("prompt")) {
111+
sawArticle = true;
112+
}
113+
if (n.querySelector) {
114+
if (!sawCopybtn && n.querySelector(".copybtn")) sawCopybtn = true;
115+
if (!sawArticle && n.querySelector(".admonition.prompt")) {
116+
sawArticle = true;
117+
}
118+
}
119+
}
120+
}
121+
if (sawCopybtn) ensureTemplate();
122+
if (sawArticle) ensurePromptButtons();
123+
}).observe(document.body, { childList: true, subtree: true });
124+
125+
// Initial-load pass — MUST run after sphinx-copybutton has had its own
126+
// DOMContentLoaded handler attach its buttons, otherwise our fallback
127+
// template beats sphinx-copybutton's localized one to the punch on
128+
// prompt-only pages like ``/recipes/``. At deferred-script execution
129+
// time ``readyState`` is ``"interactive"`` (parse done, DOMContentLoaded
130+
// not yet fired), so register a listener instead of running eagerly.
131+
// ``"complete"`` means everything has already fired — safe to run now.
132+
if (document.readyState === "complete") {
133+
ensurePromptButtons();
134+
} else {
135+
document.addEventListener("DOMContentLoaded", ensurePromptButtons);
136+
}
137+
})();

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def setup(app: Sphinx) -> None:
129129
_gp_setup(app)
130130
app.connect("autodoc-process-docstring", _convert_md_xrefs)
131131
app.add_js_file("js/prompt-copy.js", loading_method="defer")
132+
app.add_js_file("js/spa-copybutton-reinit.js", loading_method="defer")
132133
app.add_css_file("css/project-admonitions.css")
133134

134135

0 commit comments

Comments
 (0)