From 1a86781f5bea0ef925564e6a29cac339db3fb33e Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 7 May 2026 15:45:26 +0100 Subject: [PATCH 1/4] feat: add padToggle for native-style pad-wide + user settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins built on toggle() get only a User Settings checkbox stored in a per-user cookie — they never appear in the Pad Wide Settings panel and can't ride enforceSettings. That's the wrong half of Etherpad's native model: every core toggle (sticky chat, line numbers, etc.) renders in both panels and broadcasts pad-wide changes to every connected client. padToggle emits parallel checkboxes in both panels with one config object, stashes the pad-wide value at pad.padOptions[pluginName] so it rides the existing padoptions COLLABROOM rail, and honors enforce by locking the user-side checkbox when the pad creator turns it on. Capability-detects the ep_* passthrough patch via PluginCapabilities; on older cores the pad-wide block silently no-ops and the user-side cookie toggle keeps working, so plugins built on padToggle are backward-compatible. i18n is mandatory (no hardcoded English labels) — the helper requires an l10nId and emits only data-l10n-id, leaving translations to the plugin's own locales/. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 59 ++++++++++- index.js | 6 ++ pad-toggle.js | 254 +++++++++++++++++++++++++++++++++++++++++++++ test/pad-toggle.js | 131 +++++++++++++++++++++++ 4 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 pad-toggle.js create mode 100644 test/pad-toggle.js diff --git a/README.md b/README.md index ee38acc..d2b6d19 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ relay.get('option') // specific key ### Toggle -Checkbox in the settings panel with cookie persistence. +Checkbox in the User Settings panel with cookie persistence (per-user, per-pad). ```js const {toggle} = require('ep_plugin_helpers'); @@ -180,6 +180,62 @@ const state = myToggle.init(); // reads cookie, binds checkbox // state.enabled tracks current value ``` +### PadToggle + +Parallel checkboxes in **both** the User Settings panel and the Pad Wide Settings panel — matching how native settings (sticky chat, line numbers, etc.) work. The pad-wide value rides Etherpad's existing `padoptions` broadcast/persist rail, so changes propagate to every connected client and are remembered across reloads. The pad creator can `enforceSettings` to lock the user-side checkbox for everyone. + +Requires Etherpad with the `ep_*` padOptions passthrough patch (>= 2.7.4). On older cores the pad-wide column is hidden automatically and the user-side cookie toggle keeps working — plugins built on this helper run everywhere. + +```js +const {padToggle} = require('ep_plugin_helpers'); + +const t = padToggle({ + pluginName: 'ep_myplugin', // must match /^ep_[a-z0-9_]+$/ + settingId: 'my-feature', // → ids: options-my-feature, padsettings-options-my-feature + l10nId: 'ep_myplugin.myFeature', // i18n key (no hardcoded English label) + defaultEnabled: false, // overridable via settings.json[pluginName].defaultEnabled +}); + +// Server-side hooks +exports.loadSettings = t.loadSettings; +exports.clientVars = t.clientVars; +exports.eejsBlock_mySettings = t.eejsBlock_mySettings; +exports.eejsBlock_padSettings = t.eejsBlock_padSettings; + +// Client-side hooks +exports.postAceInit = (hook, ctx) => { + const state = t.init({ + onChange: (enabled) => { + // fires on initial load AND whenever the effective value changes + enabled ? myFeature.enable() : myFeature.disable(); + }, + }); + // state.getEnabled() returns the current effective value +}; +exports.handleClientMessage_CLIENT_MESSAGE = t.handleClientMessage_CLIENT_MESSAGE; +``` + +The plugin's `ep.json` must list each hook on the right side: + +```json +{ + "hooks": { + "loadSettings": "ep_myplugin", + "clientVars": "ep_myplugin", + "eejsBlock_mySettings": "ep_myplugin", + "eejsBlock_padSettings": "ep_myplugin" + }, + "client_hooks": { + "postAceInit": "ep_myplugin/static/js/index", + "handleClientMessage_CLIENT_MESSAGE": "ep_myplugin/static/js/index" + } +} +``` + +**Effective value rules** (returned by `init`'s `onChange` and `getEnabled`): +- `enforceSettings` on → use the pad-wide value +- `enforceSettings` off → use the user cookie value, falling back to pad-wide, falling back to `defaultEnabled` + ### Message Relay Intercept and relay real-time COLLABROOM messages. @@ -245,6 +301,7 @@ Old function names still work as aliases: | `rawHTML` | `eejsBlock.raw` | | `settings` | `createSettingsRelay` | | `toggle` | `createSettingsToggle` | +| `padToggle` | `createPadToggle` | | `messageRelay` | `createMessageRelay` | | `logger` | `createLogger` | diff --git a/index.js b/index.js index 3b71459..115593c 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,12 @@ module.exports = { // Toggle — checkbox in settings panel with cookie persistence get toggle() { return require('./settings-toggle').toggle; }, + // PadToggle — parallel User Settings + Pad Wide Settings checkboxes, + // matching native Etherpad behavior. Pad-wide value rides the existing + // padoptions broadcast/persist rail; degrades gracefully on cores that + // lack the ep_* passthrough patch (Etherpad < 2.7.4). + get padToggle() { return require('./pad-toggle').padToggle; }, + // Messages — intercept and relay real-time messages get messageRelay() { return require('./message-relay').messageRelay; }, diff --git a/pad-toggle.js b/pad-toggle.js new file mode 100644 index 0000000..4d14050 --- /dev/null +++ b/pad-toggle.js @@ -0,0 +1,254 @@ +'use strict'; + +// padToggle — emit parallel checkboxes in the User Settings (mySettings) and +// Pad Wide Settings (padSettings) panels, mirroring how native settings like +// stickychat / lineNumbers / disablechat work. The pad-wide value rides the +// existing padoptions COLLABROOM rail (the helper's value lives at +// pad.padOptions[pluginName] = {enabled: bool}), so broadcast, persistence, +// creator-only-write, and enforceSettings semantics all come for free — +// provided Etherpad core ships the ep_* passthrough patch (since 2.7.4). +// +// On older cores (no PluginCapabilities module), the pad-wide block is a +// no-op and the user-side cookie toggle still works. Plugins built on this +// helper continue to function everywhere; only the pad-wide column is gone. + +const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/; + +let padOptionsPluginPassthrough = false; +try { + // Server-only. Wrapped because the Settings module pulls in node deps + // (fs, path) that don't exist in the esbuild-bundled client. The require + // also fails on Etherpad versions before the passthrough patch shipped. + // eslint-disable-next-line global-require + const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities'); + padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true; +} catch (_e) { /* older core or client bundle — leave as false */ } + +const padToggle = (config) => { + if (!config || typeof config !== 'object') { + throw new Error('padToggle requires a config object'); + } + const {pluginName, settingId, l10nId, defaultEnabled = true} = config; + if (!PLUGIN_NAME_RE.test(pluginName || '')) { + throw new Error( + `padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`); + } + if (!settingId || typeof settingId !== 'string') { + throw new Error('padToggle requires settingId (string)'); + } + if (!l10nId || typeof l10nId !== 'string') { + throw new Error('padToggle requires l10nId (string) — never hardcode a label'); + } + + const userCheckboxId = `options-${settingId}`; + const padCheckboxId = `padsettings-options-${settingId}`; + let cachedDefaultEnabled = !!defaultEnabled; + + // ---------- Server hooks ---------- + + const loadSettings = async (hookName, args) => { + const ps = (args && args.settings && args.settings[pluginName]) || {}; + if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled; + }; + + const clientVars = async (hookName, ctx) => { + let initialPadEnabled = cachedDefaultEnabled; + try { + const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function' + ? ctx.pad.getPadSettings() : null; + const stored = padSettings && padSettings[pluginName]; + if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled; + } catch (_e) { /* leave initialPadEnabled at instance default */ } + + const helperBlock = { + [pluginName]: { + padWideSupported: padOptionsPluginPassthrough, + settingId, + l10nId, + defaultEnabled: cachedDefaultEnabled, + initialPadEnabled, + }, + }; + return {ep_plugin_helpers: {padToggle: helperBlock}}; + }; + + const renderCheckbox = (idPrefix) => + `

` + + `` + + `` + + `

`; + + const eejsBlock_mySettings = (hookName, args, cb) => { + args.content += renderCheckbox(''); + return cb(); + }; + + const eejsBlock_padSettings = (hookName, args, cb) => { + if (!padOptionsPluginPassthrough) return cb(); + args.content += renderCheckbox('padsettings-'); + return cb(); + }; + + // ---------- Client-side state (closed over by init/handleClientMessage) ---------- + + let onChangeCallback = () => {}; + let lastEffective = null; + + const getPad = () => { + if (typeof window === 'undefined') return null; + // Try the AMD pad module first (preferred), then the global. + try { + // eslint-disable-next-line global-require + const m = require('ep_etherpad-lite/static/js/pad'); + if (m && m.pad) return m.pad; + } catch (_e) { /* fall through */ } + return window.pad || (window.top && window.top.pad) || null; + }; + + const getCookie = () => { + try { + // eslint-disable-next-line global-require + return require('ep_etherpad-lite/static/js/pad_cookie').padcookie; + } catch (_e) { return null; } + }; + + const getClientVars = () => { + if (typeof window === 'undefined') return null; + return window.clientVars || (window.top && window.top.clientVars) || null; + }; + + const isSupportedClient = () => { + const cv = getClientVars(); + const block = cv && cv.ep_plugin_helpers && cv.ep_plugin_helpers.padToggle && + cv.ep_plugin_helpers.padToggle[pluginName]; + return !!(block && block.padWideSupported); + }; + + const readPadValue = () => { + const pad = getPad(); + if (!pad || typeof pad.getPadOptions !== 'function') return undefined; + const opts = pad.getPadOptions(); + const v = opts && opts[pluginName]; + return (v && typeof v.enabled === 'boolean') ? v.enabled : undefined; + }; + + const readUserValue = () => { + const cookie = getCookie(); + if (!cookie) return undefined; + const pref = cookie.getPref(settingId); + return (pref === true || pref === false) ? pref : undefined; + }; + + const isEnforced = () => { + const pad = getPad(); + return !!(pad && typeof pad.isPadSettingsEnforcedForMe === 'function' && + pad.isPadSettingsEnforcedForMe()); + }; + + const getEffective = () => { + if (isEnforced()) { + const padVal = readPadValue(); + return padVal != null ? padVal : cachedDefaultEnabled; + } + const userVal = readUserValue(); + if (userVal != null) return userVal; + const padVal = readPadValue(); + return padVal != null ? padVal : cachedDefaultEnabled; + }; + + const refreshUI = () => { + const $u = window.$(`#${userCheckboxId}`); + const $p = window.$(`#${padCheckboxId}`); + const eff = getEffective(); + const padVal = readPadValue(); + if ($u.length) { + $u.prop('checked', eff); + $u.prop('disabled', isEnforced()); + } + if ($p.length && padVal != null) { + $p.prop('checked', padVal); + } + if (eff !== lastEffective) { + lastEffective = eff; + try { onChangeCallback(eff); } catch (e) { console.error(e); } + } + }; + + const init = (opts = {}) => { + onChangeCallback = typeof opts.onChange === 'function' ? opts.onChange : () => {}; + const pad = getPad(); + const cookie = getCookie(); + const $u = window.$(`#${userCheckboxId}`); + const $p = window.$(`#${padCheckboxId}`); + + // User-side checkbox: cookie-backed, mirrors the effective value when not + // enforced. Disabled visually + functionally when the pad creator has + // turned on enforceSettings. + if ($u.length) { + $u.prop('checked', getEffective()); + $u.prop('disabled', isEnforced()); + $u.on('change', () => { + if (isEnforced()) { + $u.prop('checked', getEffective()); + return; + } + const v = $u.is(':checked'); + if (cookie) cookie.setPref(settingId, v); + refreshUI(); + }); + } + + // Pad-wide checkbox: only present when the rendering hook ran (i.e. the + // server has the passthrough patch). changePadOption broadcasts and the + // local applyPadSettings updates pad.padOptions[pluginName] in-place. + if ($p.length && pad && typeof pad.changePadOption === 'function') { + const initial = readPadValue(); + if (initial != null) $p.prop('checked', initial); + $p.on('change', () => { + const v = $p.is(':checked'); + pad.changePadOption(pluginName, {enabled: v}); + refreshUI(); + }); + } else if (!isSupportedClient()) { + // Surface the degraded state once per pad load so the operator notices + // when their core lacks the passthrough patch but plugin authors expect + // pad-wide behavior. + if (typeof console !== 'undefined' && !init._warned) { + console.warn( + `[ep_plugin_helpers.padToggle ${pluginName}] pad-wide settings ` + + 'unavailable — server lacks ep_* passthrough patch (Etherpad < 2.7.4). ' + + 'Per-user cookie toggle still works.'); + init._warned = true; + } + } + + lastEffective = getEffective(); + try { onChangeCallback(lastEffective); } catch (e) { console.error(e); } + + return { + getEnabled: () => lastEffective, + refresh: refreshUI, + }; + }; + + // Etherpad dispatches handleClientMessage_ for every incoming + // COLLABROOM message. For pad-wide changes, the outer type is + // CLIENT_MESSAGE and the inner payload.type is padoptions. Plugins + // re-export this hook so the helper can refresh local state when another + // user toggles the pad-wide value. + const handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => { + if (!ctx || !ctx.payload) return; + if (ctx.payload.type === 'padoptions') refreshUI(); + }; + + return { + loadSettings, + clientVars, + eejsBlock_mySettings, + eejsBlock_padSettings, + init, + handleClientMessage_CLIENT_MESSAGE, + }; +}; + +module.exports = {padToggle, createPadToggle: padToggle}; diff --git a/test/pad-toggle.js b/test/pad-toggle.js new file mode 100644 index 0000000..acab8f9 --- /dev/null +++ b/test/pad-toggle.js @@ -0,0 +1,131 @@ +'use strict'; + +const assert = require('assert'); +const {padToggle} = require('../pad-toggle'); + +const baseConfig = () => ({ + pluginName: 'ep_test', + settingId: 'thing', + l10nId: 'ep_test.thing', +}); + +describe('padToggle', () => { + describe('config validation', () => { + it('throws when pluginName fails the ep_ namespace check', () => { + assert.throws(() => padToggle({...baseConfig(), pluginName: 'EP_SHOUTY'}), + /pluginName must match/); + assert.throws(() => padToggle({...baseConfig(), pluginName: 'ep-dashy'}), + /pluginName must match/); + assert.throws(() => padToggle({...baseConfig(), pluginName: 'no-prefix'}), + /pluginName must match/); + }); + + it('throws when settingId or l10nId is missing', () => { + assert.throws(() => padToggle({...baseConfig(), settingId: ''}), + /settingId/); + assert.throws(() => padToggle({...baseConfig(), l10nId: ''}), + /l10nId/); + }); + + it('returns the full hook surface for valid config', () => { + const t = padToggle(baseConfig()); + for (const k of [ + 'loadSettings', 'clientVars', + 'eejsBlock_mySettings', 'eejsBlock_padSettings', + 'init', 'handleClientMessage_CLIENT_MESSAGE', + ]) { + assert.strictEqual(typeof t[k], 'function', `missing hook: ${k}`); + } + }); + }); + + describe('eejsBlock_mySettings', () => { + it('emits a checkbox with namespaced id and data-l10n-id label', (done) => { + const t = padToggle(baseConfig()); + const args = {content: ''}; + t.eejsBlock_mySettings('hook', args, () => { + assert.match(args.content, /id="options-thing"/); + assert.match(args.content, /data-l10n-id="ep_test\.thing"/); + // No hardcoded English fallback inside the label tag. + assert.match(args.content, /data-l10n-id="ep_test\.thing"><\/label>/); + done(); + }); + }); + }); + + describe('eejsBlock_padSettings', () => { + it('is a no-op when the core lacks the passthrough patch', (done) => { + // Module-level capability detection ran at require time. In this test + // env the patched core is not installed, so it stays false — exactly + // the unsupported-server scenario the helper is meant to handle. + const t = padToggle(baseConfig()); + const args = {content: ''}; + t.eejsBlock_padSettings('hook', args, () => { + assert.strictEqual(args.content, '', + 'pad-wide block must not render without core support'); + done(); + }); + }); + }); + + describe('loadSettings', () => { + it('honors instance default from settings.json[pluginName].defaultEnabled', async () => { + const t = padToggle({...baseConfig(), defaultEnabled: false}); + await t.loadSettings('h', {settings: {ep_test: {defaultEnabled: true}}}); + // Verify by reading clientVars seeded value. + const cv = await t.clientVars('h', {pad: null}); + assert.strictEqual( + cv.ep_plugin_helpers.padToggle.ep_test.defaultEnabled, true); + }); + + it('falls back to the constructor default when settings.json is silent', async () => { + const t = padToggle({...baseConfig(), defaultEnabled: false}); + await t.loadSettings('h', {settings: {}}); + const cv = await t.clientVars('h', {pad: null}); + assert.strictEqual( + cv.ep_plugin_helpers.padToggle.ep_test.defaultEnabled, false); + }); + }); + + describe('clientVars', () => { + it('namespaces under ep_plugin_helpers.padToggle.', async () => { + const t = padToggle(baseConfig()); + const cv = await t.clientVars('h', {pad: null}); + assert.ok(cv.ep_plugin_helpers); + assert.ok(cv.ep_plugin_helpers.padToggle); + assert.ok(cv.ep_plugin_helpers.padToggle.ep_test); + }); + + it('reports padWideSupported=false in this test env (no patched core)', async () => { + const t = padToggle(baseConfig()); + const cv = await t.clientVars('h', {pad: null}); + assert.strictEqual( + cv.ep_plugin_helpers.padToggle.ep_test.padWideSupported, false); + }); + + it('reads stored pad-wide value from pad.getPadSettings()[pluginName]', async () => { + const t = padToggle({...baseConfig(), defaultEnabled: false}); + const fakePad = { + getPadSettings: () => ({ep_test: {enabled: true}}), + }; + const cv = await t.clientVars('h', {pad: fakePad}); + assert.strictEqual( + cv.ep_plugin_helpers.padToggle.ep_test.initialPadEnabled, true); + }); + + it('falls back to instance default when pad has no stored value', async () => { + const t = padToggle({...baseConfig(), defaultEnabled: true}); + const fakePad = {getPadSettings: () => ({})}; + const cv = await t.clientVars('h', {pad: fakePad}); + assert.strictEqual( + cv.ep_plugin_helpers.padToggle.ep_test.initialPadEnabled, true); + }); + }); + + describe('backwards-compat alias', () => { + it('createPadToggle still resolves', () => { + const {createPadToggle} = require('../pad-toggle'); + assert.strictEqual(typeof createPadToggle, 'function'); + }); + }); +}); From 1c2f20153e9e0a483505047b7e7f38854bec53cc Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 7 May 2026 15:47:42 +0100 Subject: [PATCH 2/4] chore: 0.3.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a79b42..7b3e508 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ep_plugin_helpers", - "version": "0.2.9", + "version": "0.3.0", "description": "Shared factory functions to eliminate boilerplate across Etherpad plugins", "author": { "name": "John McLear", From c409de55eecc29f6e966ee0eec1a18cebc6a3af6 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 7 May 2026 16:03:48 +0100 Subject: [PATCH 3/4] refactor(padToggle): split server/client to keep client bundle clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level requires of ep_etherpad-lite/node/* in pad-toggle.js were getting crawled by esbuild when plugin authors imported the helper from client code, even though the requires were inside try/catch and the chain only reached leaf modules. The bundler resolves the package and pulls in everything along the way. The existing convention (attributes.js client / attributes- server.js server) handles this cleanly — adopt the same split: - pad-toggle-server.js — server hooks (loadSettings, clientVars, eejsBlock_mySettings, eejsBlock_padSettings) + capability detection via PluginCapabilities. - pad-toggle.js — client hooks (init, handleClientMessage_CLIENT_MESSAGE) with no top-level node-only requires. Plugin authors: // server (index.js) const {padToggle} = require('ep_plugin_helpers/pad-toggle-server'); // client (static/js/postAceInit.js) const {padToggle} = require('ep_plugin_helpers/pad-toggle'); The top-level `padToggle` getter on ep_plugin_helpers' index.js still returns the server factory for callers who already use the index entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- index.js | 6 ++- pad-toggle-server.js | 100 ++++++++++++++++++++++++++++++++++++ pad-toggle.js | 118 +++++++++---------------------------------- test/pad-toggle.js | 18 ++++--- 4 files changed, 140 insertions(+), 102 deletions(-) create mode 100644 pad-toggle-server.js diff --git a/index.js b/index.js index 115593c..bcdf0e9 100644 --- a/index.js +++ b/index.js @@ -23,7 +23,11 @@ module.exports = { // matching native Etherpad behavior. Pad-wide value rides the existing // padoptions broadcast/persist rail; degrades gracefully on cores that // lack the ep_* passthrough patch (Etherpad < 2.7.4). - get padToggle() { return require('./pad-toggle').padToggle; }, + // + // Server side ONLY here. Client code must import the sub-path + // 'ep_plugin_helpers/pad-toggle' directly to avoid pulling settings-toggle + // and other server-only modules into the client bundle. + get padToggle() { return require('./pad-toggle-server').padToggle; }, // Messages — intercept and relay real-time messages get messageRelay() { return require('./message-relay').messageRelay; }, diff --git a/pad-toggle-server.js b/pad-toggle-server.js new file mode 100644 index 0000000..09facc0 --- /dev/null +++ b/pad-toggle-server.js @@ -0,0 +1,100 @@ +'use strict'; + +// padToggle (server side) — emits parallel checkboxes in the User Settings +// (mySettings) and Pad Wide Settings (padSettings) panels, mirroring native +// Etherpad behavior. Pad-wide values ride the existing padoptions COLLABROOM +// rail (stored at pad.padOptions[pluginName] = {enabled: bool}) when the core +// has the ep_* passthrough patch (Etherpad >= 2.7.4); on older cores the +// pad-wide block silently no-ops and the user-side cookie toggle alone still +// works. +// +// This module is intended for server-side import only. The companion +// `pad-toggle.js` provides the client-side init/handleClientMessage hooks. +// They share the same config; plugin authors should use identical +// `pluginName`, `settingId`, `l10nId`, and `defaultEnabled` on both sides so +// the checkbox ids and clientVars block line up. + +const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/; + +let padOptionsPluginPassthrough = false; +try { + // The require lands on a leaf module on patched cores (Etherpad >= 2.7.4) + // and throws on older cores. Server-only: this file is never bundled for + // the browser, so esbuild's static analysis does not run here. + // eslint-disable-next-line global-require + const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities'); + padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true; +} catch (_e) { /* older core — leave as false */ } + +const validateConfig = (config) => { + if (!config || typeof config !== 'object') { + throw new Error('padToggle requires a config object'); + } + const {pluginName, settingId, l10nId, defaultEnabled = true} = config; + if (!PLUGIN_NAME_RE.test(pluginName || '')) { + throw new Error( + `padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`); + } + if (!settingId || typeof settingId !== 'string') { + throw new Error('padToggle requires settingId (string)'); + } + if (!l10nId || typeof l10nId !== 'string') { + throw new Error('padToggle requires l10nId (string) — never hardcode a label'); + } + return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled}; +}; + +const renderCheckbox = (settingId, l10nId, idPrefix) => + `

` + + `` + + `` + + `

`; + +const padToggleServer = (rawConfig) => { + const {pluginName, settingId, l10nId, defaultEnabled} = validateConfig(rawConfig); + let cachedDefaultEnabled = defaultEnabled; + + const loadSettings = async (hookName, args) => { + const ps = (args && args.settings && args.settings[pluginName]) || {}; + if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled; + }; + + const clientVars = async (hookName, ctx) => { + let initialPadEnabled = cachedDefaultEnabled; + try { + const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function' + ? ctx.pad.getPadSettings() : null; + const stored = padSettings && padSettings[pluginName]; + if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled; + } catch (_e) { /* leave initialPadEnabled at instance default */ } + + return { + ep_plugin_helpers: { + padToggle: { + [pluginName]: { + padWideSupported: padOptionsPluginPassthrough, + settingId, + l10nId, + defaultEnabled: cachedDefaultEnabled, + initialPadEnabled, + }, + }, + }, + }; + }; + + const eejsBlock_mySettings = (hookName, args, cb) => { + args.content += renderCheckbox(settingId, l10nId, ''); + return cb(); + }; + + const eejsBlock_padSettings = (hookName, args, cb) => { + if (!padOptionsPluginPassthrough) return cb(); + args.content += renderCheckbox(settingId, l10nId, 'padsettings-'); + return cb(); + }; + + return {loadSettings, clientVars, eejsBlock_mySettings, eejsBlock_padSettings}; +}; + +module.exports = {padToggle: padToggleServer, createPadToggle: padToggleServer}; diff --git a/pad-toggle.js b/pad-toggle.js index 4d14050..648dc70 100644 --- a/pad-toggle.js +++ b/pad-toggle.js @@ -1,30 +1,19 @@ 'use strict'; -// padToggle — emit parallel checkboxes in the User Settings (mySettings) and -// Pad Wide Settings (padSettings) panels, mirroring how native settings like -// stickychat / lineNumbers / disablechat work. The pad-wide value rides the -// existing padoptions COLLABROOM rail (the helper's value lives at -// pad.padOptions[pluginName] = {enabled: bool}), so broadcast, persistence, -// creator-only-write, and enforceSettings semantics all come for free — -// provided Etherpad core ships the ep_* passthrough patch (since 2.7.4). +// padToggle (client side) — wires up the parallel User Settings / Pad Wide +// Settings checkboxes that pad-toggle-server.js renders. Reads the helper's +// clientVars block (capability flag + initial pad-wide value), persists the +// per-user choice in padcookie, and forwards pad-wide changes through the +// native pad.changePadOption() flow so they ride the existing padoptions +// COLLABROOM broadcast. // -// On older cores (no PluginCapabilities module), the pad-wide block is a -// no-op and the user-side cookie toggle still works. Plugins built on this -// helper continue to function everywhere; only the pad-wide column is gone. +// This file deliberately has no top-level requires that touch server-only +// modules — esbuild bundles it into the browser pad bundle, and any +// node-only path would break the client build. const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/; -let padOptionsPluginPassthrough = false; -try { - // Server-only. Wrapped because the Settings module pulls in node deps - // (fs, path) that don't exist in the esbuild-bundled client. The require - // also fails on Etherpad versions before the passthrough patch shipped. - // eslint-disable-next-line global-require - const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities'); - padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true; -} catch (_e) { /* older core or client bundle — leave as false */ } - -const padToggle = (config) => { +const validateConfig = (config) => { if (!config || typeof config !== 'object') { throw new Error('padToggle requires a config object'); } @@ -39,64 +28,19 @@ const padToggle = (config) => { if (!l10nId || typeof l10nId !== 'string') { throw new Error('padToggle requires l10nId (string) — never hardcode a label'); } + return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled}; +}; +const padToggleClient = (rawConfig) => { + const {pluginName, settingId, defaultEnabled} = validateConfig(rawConfig); const userCheckboxId = `options-${settingId}`; const padCheckboxId = `padsettings-options-${settingId}`; - let cachedDefaultEnabled = !!defaultEnabled; - - // ---------- Server hooks ---------- - - const loadSettings = async (hookName, args) => { - const ps = (args && args.settings && args.settings[pluginName]) || {}; - if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled; - }; - - const clientVars = async (hookName, ctx) => { - let initialPadEnabled = cachedDefaultEnabled; - try { - const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function' - ? ctx.pad.getPadSettings() : null; - const stored = padSettings && padSettings[pluginName]; - if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled; - } catch (_e) { /* leave initialPadEnabled at instance default */ } - - const helperBlock = { - [pluginName]: { - padWideSupported: padOptionsPluginPassthrough, - settingId, - l10nId, - defaultEnabled: cachedDefaultEnabled, - initialPadEnabled, - }, - }; - return {ep_plugin_helpers: {padToggle: helperBlock}}; - }; - - const renderCheckbox = (idPrefix) => - `

` + - `` + - `` + - `

`; - - const eejsBlock_mySettings = (hookName, args, cb) => { - args.content += renderCheckbox(''); - return cb(); - }; - - const eejsBlock_padSettings = (hookName, args, cb) => { - if (!padOptionsPluginPassthrough) return cb(); - args.content += renderCheckbox('padsettings-'); - return cb(); - }; - - // ---------- Client-side state (closed over by init/handleClientMessage) ---------- let onChangeCallback = () => {}; let lastEffective = null; const getPad = () => { if (typeof window === 'undefined') return null; - // Try the AMD pad module first (preferred), then the global. try { // eslint-disable-next-line global-require const m = require('ep_etherpad-lite/static/js/pad'); @@ -148,12 +92,12 @@ const padToggle = (config) => { const getEffective = () => { if (isEnforced()) { const padVal = readPadValue(); - return padVal != null ? padVal : cachedDefaultEnabled; + return padVal != null ? padVal : defaultEnabled; } const userVal = readUserValue(); if (userVal != null) return userVal; const padVal = readPadValue(); - return padVal != null ? padVal : cachedDefaultEnabled; + return padVal != null ? padVal : defaultEnabled; }; const refreshUI = () => { @@ -181,9 +125,6 @@ const padToggle = (config) => { const $u = window.$(`#${userCheckboxId}`); const $p = window.$(`#${padCheckboxId}`); - // User-side checkbox: cookie-backed, mirrors the effective value when not - // enforced. Disabled visually + functionally when the pad creator has - // turned on enforceSettings. if ($u.length) { $u.prop('checked', getEffective()); $u.prop('disabled', isEnforced()); @@ -198,9 +139,6 @@ const padToggle = (config) => { }); } - // Pad-wide checkbox: only present when the rendering hook ran (i.e. the - // server has the passthrough patch). changePadOption broadcasts and the - // local applyPadSettings updates pad.padOptions[pluginName] in-place. if ($p.length && pad && typeof pad.changePadOption === 'function') { const initial = readPadValue(); if (initial != null) $p.prop('checked', initial); @@ -210,9 +148,6 @@ const padToggle = (config) => { refreshUI(); }); } else if (!isSupportedClient()) { - // Surface the degraded state once per pad load so the operator notices - // when their core lacks the passthrough patch but plugin authors expect - // pad-wide behavior. if (typeof console !== 'undefined' && !init._warned) { console.warn( `[ep_plugin_helpers.padToggle ${pluginName}] pad-wide settings ` + @@ -231,24 +166,17 @@ const padToggle = (config) => { }; }; - // Etherpad dispatches handleClientMessage_ for every incoming - // COLLABROOM message. For pad-wide changes, the outer type is - // CLIENT_MESSAGE and the inner payload.type is padoptions. Plugins - // re-export this hook so the helper can refresh local state when another - // user toggles the pad-wide value. + // Plugin re-exports this so the helper sees pad-wide broadcasts and + // refreshes local state when another user toggles the pad-wide checkbox. + // Etherpad dispatches handleClientMessage_ for every COLLABROOM + // message; for pad-wide changes the outer type is CLIENT_MESSAGE and the + // inner payload.type is padoptions. const handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => { if (!ctx || !ctx.payload) return; if (ctx.payload.type === 'padoptions') refreshUI(); }; - return { - loadSettings, - clientVars, - eejsBlock_mySettings, - eejsBlock_padSettings, - init, - handleClientMessage_CLIENT_MESSAGE, - }; + return {init, handleClientMessage_CLIENT_MESSAGE}; }; -module.exports = {padToggle, createPadToggle: padToggle}; +module.exports = {padToggle: padToggleClient, createPadToggle: padToggleClient}; diff --git a/test/pad-toggle.js b/test/pad-toggle.js index acab8f9..b67dbff 100644 --- a/test/pad-toggle.js +++ b/test/pad-toggle.js @@ -1,7 +1,7 @@ 'use strict'; const assert = require('assert'); -const {padToggle} = require('../pad-toggle'); +const {padToggle} = require('../pad-toggle-server'); const baseConfig = () => ({ pluginName: 'ep_test', @@ -27,16 +27,22 @@ describe('padToggle', () => { /l10nId/); }); - it('returns the full hook surface for valid config', () => { + it('returns the full server hook surface for valid config', () => { const t = padToggle(baseConfig()); for (const k of [ 'loadSettings', 'clientVars', 'eejsBlock_mySettings', 'eejsBlock_padSettings', - 'init', 'handleClientMessage_CLIENT_MESSAGE', ]) { assert.strictEqual(typeof t[k], 'function', `missing hook: ${k}`); } }); + + it('client sub-path exposes init + handleClientMessage_CLIENT_MESSAGE', () => { + const {padToggle: clientFactory} = require('../pad-toggle'); + const t = clientFactory(baseConfig()); + assert.strictEqual(typeof t.init, 'function'); + assert.strictEqual(typeof t.handleClientMessage_CLIENT_MESSAGE, 'function'); + }); }); describe('eejsBlock_mySettings', () => { @@ -123,9 +129,9 @@ describe('padToggle', () => { }); describe('backwards-compat alias', () => { - it('createPadToggle still resolves', () => { - const {createPadToggle} = require('../pad-toggle'); - assert.strictEqual(typeof createPadToggle, 'function'); + it('createPadToggle resolves on both sub-paths', () => { + assert.strictEqual(typeof require('../pad-toggle-server').createPadToggle, 'function'); + assert.strictEqual(typeof require('../pad-toggle').createPadToggle, 'function'); }); }); }); From 0ea52bc93c44a988d759df86dd81319dfa7d3cbb Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 7 May 2026 16:19:31 +0100 Subject: [PATCH 4/4] fix(padToggle): require defaultLabel for screen-reader fallback The empty meant screen readers had nothing to announce while html10n was still loading or if a translation failed to fetch. Native Etherpad's templates always render English text inside the label tag for exactly this reason; the helper was missing that safety net. Make defaultLabel a required config field. Helper now renders with both the defaultLabel and l10nId HTML-escaped to prevent injection. html10n still overwrites the text the moment it loads in the user's locale. Validate on both server and client factories so a misconfigured plugin fails loudly on import rather than silently shipping a nameless checkbox. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +++- pad-toggle-server.js | 27 +++++++++++++++++++-------- pad-toggle.js | 8 +++++++- test/pad-toggle.js | 30 ++++++++++++++++++++++++++---- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d2b6d19..f8081a0 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,9 @@ const {padToggle} = require('ep_plugin_helpers'); const t = padToggle({ pluginName: 'ep_myplugin', // must match /^ep_[a-z0-9_]+$/ settingId: 'my-feature', // → ids: options-my-feature, padsettings-options-my-feature - l10nId: 'ep_myplugin.myFeature', // i18n key (no hardcoded English label) + l10nId: 'ep_myplugin.myFeature', // i18n key, html10n overwrites the fallback + defaultLabel: 'My feature', // a11y fallback — rendered inside