From 63b5edd901e6343903aea8f609630ddc669bd22c Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 24 Sep 2026 21:13:24 +0800 Subject: [PATCH] fix(runtime): use jQuery, not bare $, in the lazily loaded clipboard lib WordPress runs jQuery in noConflict mode, so bare $ is undefined. The clipboard module called $(this) on every click and $.isFunction on the copy result, so any click on a [data-clipboard-text] button threw "$ is not a function" and copied nothing. This was latent: the only prior emitter was the paid card, and the click path only ran when a user actually clicked it. Exposing the copy button on the download box made it reachable on every resource, surfacing the error. Bind through jQuery explicitly, guard notyf so a missing toast lib cannot also throw, and drop the _win.bd dependency (that payload only gains .bd once main.js runs). Contract-lock the module under simulated noConflict. Co-Authored-By: Claude Sonnet 4.6 --- assets/js/runtime/libs/clipboard.js | 8 +- assets/js/runtime/libs/clipboard.min.js | 2 +- .../clipboard-noconflict-contract.test.cjs | 99 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/widget-system/clipboard-noconflict-contract.test.cjs diff --git a/assets/js/runtime/libs/clipboard.js b/assets/js/runtime/libs/clipboard.js index 1d2b846f..f9db22f5 100644 --- a/assets/js/runtime/libs/clipboard.js +++ b/assets/js/runtime/libs/clipboard.js @@ -38,14 +38,14 @@ function copyText(text, success, error, _this) { } } -_win.bd.on('click', "[data-clipboard-text]", 'clipboard', function (e) { - var _this = $(this); +window.jQuery('body').on('click', "[data-clipboard-text]", 'clipboard', function (e) { + var _this = jQuery(this); var text = _this.attr('data-clipboard-text'); var tag = _this.attr('data-clipboard-tag') || '内容'; copyText(text, function () { - notyf(tag + "已复制") + typeof notyf === 'function' && notyf(tag + "已复制") }, function () { - notyf(tag + "复制失败,请手动复制", 'danger') + typeof notyf === 'function' && notyf(tag + "复制失败,请手动复制", 'danger') }, this); }) \ No newline at end of file diff --git a/assets/js/runtime/libs/clipboard.min.js b/assets/js/runtime/libs/clipboard.min.js index 3785b7d7..5443d79b 100644 --- a/assets/js/runtime/libs/clipboard.min.js +++ b/assets/js/runtime/libs/clipboard.min.js @@ -1 +1 @@ -function copyText(t,e,n,a){function c(t,e,n){if(t.createTextRange){var a=t.createTextRange();a.collapse(!0),a.moveStart("character",e),a.moveEnd("character",n-e),a.select()}else t.setSelectionRange(e,n),t.select()}var o=t.toString(),i=document.querySelector("#copy-input");i||(i=document.createElement("input"),i.id="copy-input",i.readOnly="readOnly",i.style.position="fixed",i.style.left="-2000px",i.style.zIndex="-1000",a.parentNode.appendChild(i)),i.value=o,c(i,0,o.length),document.execCommand("copy")?$.isFunction(e)&&e():$.isFunction(n)&&n(),i.blur()}_win.bd.on("click","[data-clipboard-text]","clipboard",function(t){var e=$(this),n=e.attr("data-clipboard-text"),a=e.attr("data-clipboard-tag")||"内容";copyText(n,function(){notyf(a+"已复制")},function(){notyf(a+"复制失败,请手动复制","danger")},this)}); \ No newline at end of file +function copyText(t,e,n,a){function c(t,e,n){if(t.createTextRange){var a=t.createTextRange();a.collapse(!0),a.moveStart("character",e),a.moveEnd("character",n-e),a.select()}else{t.setSelectionRange(e,n),t.select()}}var o=t.toString(),i=document.querySelector("#copy-input");if(!i){i=document.createElement("input");i.id="copy-input";i.readOnly="readOnly";i.style.position="fixed";i.style.left="-2000px";i.style.zIndex="-1000";a.parentNode.appendChild(i)}i.value=o;c(i,0,o.length);if(document.execCommand("copy")){if(typeof e==="function")e()}else{if(typeof n==="function")n()}i.blur()}window.jQuery("body").on("click","[data-clipboard-text]","clipboard",function(t){var e=jQuery(this),n=e.attr("data-clipboard-text"),a=e.attr("data-clipboard-tag")||"内容";copyText(n,function(){if(typeof notyf==="function")notyf(a+"已复制")},function(){if(typeof notyf==="function")notyf(a+"复制失败,请手动复制","danger")},this)}); diff --git a/tests/widget-system/clipboard-noconflict-contract.test.cjs b/tests/widget-system/clipboard-noconflict-contract.test.cjs new file mode 100644 index 00000000..18aca670 --- /dev/null +++ b/tests/widget-system/clipboard-noconflict-contract.test.cjs @@ -0,0 +1,99 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +// Strip string literals so a "$" inside a message is not mistaken for a call. +const stripStrings = (src) => + src.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''"); + +// A jQuery stand-in exposing only what clipboard.min.js touches. Bare $ is +// deliberately NOT provided, matching WordPress's noConflict mode. +function makeSandbox() { + const handlers = []; + const created = []; + const toasts = []; + + const body = { + on(evt, childSel, ns, fn) { + handlers.push({ evt, childSel, ns, fn }); + return this; + }, + }; + + const documentStub = { + querySelector: (sel) => (sel === '#copy-input' ? created[created.length - 1] || null : null), + createElement: () => { + const el = { + style: {}, value: '', id: '', readOnly: '', + setSelectionRange() {}, select() {}, blur() {}, + }; + created.push(el); + return el; + }, + execCommand: () => true, + }; + + const jq = (sel) => { + if (sel === 'body') return body; + if (sel && sel.attrs) return { attr: (n) => sel.attrs[n] }; + return { on() { return this; } }; + }; + jq.__isElement = true; + + return { sandbox: { window: {}, document: documentStub, jQuery: jq, notyf: (m, t) => toasts.push({ m, t }) }, handlers, created, toasts, body }; +} + +test('the lazily loaded clipboard lib survives WordPress noConflict', () => { + const src = read('assets/js/runtime/libs/clipboard.min.js'); + + // No bare $ calls or $. usage anywhere in the module. + const bare = stripStrings(src); + assert.doesNotMatch(bare, /(^|[^.\w$])\$\(/, 'clipboard.min.js uses a bare $('); + assert.doesNotMatch(bare, /\$\.\w+/, 'clipboard.min.js uses a bare $.'); + + // It must bind through jQuery explicitly on the element that always exists. + assert.match(src, /jQuery\("body"\)\.on\("click",\s*"\[data-clipboard-text\]"/); + + const { sandbox, handlers, created, toasts } = makeSandbox(); + sandbox.window.jQuery = sandbox.jQuery; + + // Evaluating with no global $ must not throw. + assert.doesNotThrow(() => { + new Function('window', 'document', 'jQuery', 'notyf', '"use strict";\n' + src)( + sandbox.window, sandbox.document, sandbox.jQuery, sandbox.notyf + ); + }); + + assert.equal(handlers.length, 1); + assert.equal(handlers[0].evt, 'click'); + assert.equal(handlers[0].childSel, '[data-clipboard-text]'); + + // Clicking must copy the copy content and toast the copy name. + const el = { + attrs: { 'data-clipboard-text': 'a1b2', 'data-clipboard-tag': '提取码' }, + parentNode: { appendChild() {} }, + }; + assert.doesNotThrow(() => handlers[0].fn.call(el, {})); + assert.equal(created[created.length - 1].value, 'a1b2'); + assert.deepEqual(toasts, [{ m: '提取码已复制', t: undefined }]); +}); + +test('the readable clipboard source agrees with the loaded bundle', () => { + const src = read('assets/js/runtime/libs/clipboard.js'); + const bare = stripStrings(src); + assert.doesNotMatch(bare, /(^|[^.\w$])\$\(/, 'clipboard.js uses a bare $('); + assert.match(src, /jQuery\('body'\)\.on\('click'/); + assert.match(src, /typeof notyf === 'function'/); +}); + +test('bootstrap is unaffected because it receives jQuery as an IIFE argument', () => { + const src = read('assets/js/runtime/libs/bootstrap.min.js'); + assert.match(src, /!function\(\w\)\{"use strict"/); + assert.match(src, /requires jQuery/); +});