Skip to content

Commit c409de5

Browse files
JohnMcLearclaude
andcommitted
refactor(padToggle): split server/client to keep client bundle clean
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) <noreply@anthropic.com>
1 parent 1c2f201 commit c409de5

4 files changed

Lines changed: 140 additions & 102 deletions

File tree

index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ module.exports = {
2323
// matching native Etherpad behavior. Pad-wide value rides the existing
2424
// padoptions broadcast/persist rail; degrades gracefully on cores that
2525
// lack the ep_* passthrough patch (Etherpad < 2.7.4).
26-
get padToggle() { return require('./pad-toggle').padToggle; },
26+
//
27+
// Server side ONLY here. Client code must import the sub-path
28+
// 'ep_plugin_helpers/pad-toggle' directly to avoid pulling settings-toggle
29+
// and other server-only modules into the client bundle.
30+
get padToggle() { return require('./pad-toggle-server').padToggle; },
2731

2832
// Messages — intercept and relay real-time messages
2933
get messageRelay() { return require('./message-relay').messageRelay; },

pad-toggle-server.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict';
2+
3+
// padToggle (server side) — emits parallel checkboxes in the User Settings
4+
// (mySettings) and Pad Wide Settings (padSettings) panels, mirroring native
5+
// Etherpad behavior. Pad-wide values ride the existing padoptions COLLABROOM
6+
// rail (stored at pad.padOptions[pluginName] = {enabled: bool}) when the core
7+
// has the ep_* passthrough patch (Etherpad >= 2.7.4); on older cores the
8+
// pad-wide block silently no-ops and the user-side cookie toggle alone still
9+
// works.
10+
//
11+
// This module is intended for server-side import only. The companion
12+
// `pad-toggle.js` provides the client-side init/handleClientMessage hooks.
13+
// They share the same config; plugin authors should use identical
14+
// `pluginName`, `settingId`, `l10nId`, and `defaultEnabled` on both sides so
15+
// the checkbox ids and clientVars block line up.
16+
17+
const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/;
18+
19+
let padOptionsPluginPassthrough = false;
20+
try {
21+
// The require lands on a leaf module on patched cores (Etherpad >= 2.7.4)
22+
// and throws on older cores. Server-only: this file is never bundled for
23+
// the browser, so esbuild's static analysis does not run here.
24+
// eslint-disable-next-line global-require
25+
const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities');
26+
padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true;
27+
} catch (_e) { /* older core — leave as false */ }
28+
29+
const validateConfig = (config) => {
30+
if (!config || typeof config !== 'object') {
31+
throw new Error('padToggle requires a config object');
32+
}
33+
const {pluginName, settingId, l10nId, defaultEnabled = true} = config;
34+
if (!PLUGIN_NAME_RE.test(pluginName || '')) {
35+
throw new Error(
36+
`padToggle pluginName must match /^ep_[a-z0-9_]+$/, got: ${pluginName}`);
37+
}
38+
if (!settingId || typeof settingId !== 'string') {
39+
throw new Error('padToggle requires settingId (string)');
40+
}
41+
if (!l10nId || typeof l10nId !== 'string') {
42+
throw new Error('padToggle requires l10nId (string) — never hardcode a label');
43+
}
44+
return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled};
45+
};
46+
47+
const renderCheckbox = (settingId, l10nId, idPrefix) =>
48+
`<p>` +
49+
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
50+
`<label for="${idPrefix}options-${settingId}" data-l10n-id="${l10nId}"></label>` +
51+
`</p>`;
52+
53+
const padToggleServer = (rawConfig) => {
54+
const {pluginName, settingId, l10nId, defaultEnabled} = validateConfig(rawConfig);
55+
let cachedDefaultEnabled = defaultEnabled;
56+
57+
const loadSettings = async (hookName, args) => {
58+
const ps = (args && args.settings && args.settings[pluginName]) || {};
59+
if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled;
60+
};
61+
62+
const clientVars = async (hookName, ctx) => {
63+
let initialPadEnabled = cachedDefaultEnabled;
64+
try {
65+
const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function'
66+
? ctx.pad.getPadSettings() : null;
67+
const stored = padSettings && padSettings[pluginName];
68+
if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled;
69+
} catch (_e) { /* leave initialPadEnabled at instance default */ }
70+
71+
return {
72+
ep_plugin_helpers: {
73+
padToggle: {
74+
[pluginName]: {
75+
padWideSupported: padOptionsPluginPassthrough,
76+
settingId,
77+
l10nId,
78+
defaultEnabled: cachedDefaultEnabled,
79+
initialPadEnabled,
80+
},
81+
},
82+
},
83+
};
84+
};
85+
86+
const eejsBlock_mySettings = (hookName, args, cb) => {
87+
args.content += renderCheckbox(settingId, l10nId, '');
88+
return cb();
89+
};
90+
91+
const eejsBlock_padSettings = (hookName, args, cb) => {
92+
if (!padOptionsPluginPassthrough) return cb();
93+
args.content += renderCheckbox(settingId, l10nId, 'padsettings-');
94+
return cb();
95+
};
96+
97+
return {loadSettings, clientVars, eejsBlock_mySettings, eejsBlock_padSettings};
98+
};
99+
100+
module.exports = {padToggle: padToggleServer, createPadToggle: padToggleServer};

pad-toggle.js

Lines changed: 23 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
11
'use strict';
22

3-
// padToggle — emit parallel checkboxes in the User Settings (mySettings) and
4-
// Pad Wide Settings (padSettings) panels, mirroring how native settings like
5-
// stickychat / lineNumbers / disablechat work. The pad-wide value rides the
6-
// existing padoptions COLLABROOM rail (the helper's value lives at
7-
// pad.padOptions[pluginName] = {enabled: bool}), so broadcast, persistence,
8-
// creator-only-write, and enforceSettings semantics all come for free —
9-
// provided Etherpad core ships the ep_* passthrough patch (since 2.7.4).
3+
// padToggle (client side) — wires up the parallel User Settings / Pad Wide
4+
// Settings checkboxes that pad-toggle-server.js renders. Reads the helper's
5+
// clientVars block (capability flag + initial pad-wide value), persists the
6+
// per-user choice in padcookie, and forwards pad-wide changes through the
7+
// native pad.changePadOption() flow so they ride the existing padoptions
8+
// COLLABROOM broadcast.
109
//
11-
// On older cores (no PluginCapabilities module), the pad-wide block is a
12-
// no-op and the user-side cookie toggle still works. Plugins built on this
13-
// helper continue to function everywhere; only the pad-wide column is gone.
10+
// This file deliberately has no top-level requires that touch server-only
11+
// modules — esbuild bundles it into the browser pad bundle, and any
12+
// node-only path would break the client build.
1413

1514
const PLUGIN_NAME_RE = /^ep_[a-z0-9_]+$/;
1615

17-
let padOptionsPluginPassthrough = false;
18-
try {
19-
// Server-only. Wrapped because the Settings module pulls in node deps
20-
// (fs, path) that don't exist in the esbuild-bundled client. The require
21-
// also fails on Etherpad versions before the passthrough patch shipped.
22-
// eslint-disable-next-line global-require
23-
const caps = require('ep_etherpad-lite/node/utils/PluginCapabilities');
24-
padOptionsPluginPassthrough = caps && caps.padOptionsPluginPassthrough === true;
25-
} catch (_e) { /* older core or client bundle — leave as false */ }
26-
27-
const padToggle = (config) => {
16+
const validateConfig = (config) => {
2817
if (!config || typeof config !== 'object') {
2918
throw new Error('padToggle requires a config object');
3019
}
@@ -39,64 +28,19 @@ const padToggle = (config) => {
3928
if (!l10nId || typeof l10nId !== 'string') {
4029
throw new Error('padToggle requires l10nId (string) — never hardcode a label');
4130
}
31+
return {pluginName, settingId, l10nId, defaultEnabled: !!defaultEnabled};
32+
};
4233

34+
const padToggleClient = (rawConfig) => {
35+
const {pluginName, settingId, defaultEnabled} = validateConfig(rawConfig);
4336
const userCheckboxId = `options-${settingId}`;
4437
const padCheckboxId = `padsettings-options-${settingId}`;
45-
let cachedDefaultEnabled = !!defaultEnabled;
46-
47-
// ---------- Server hooks ----------
48-
49-
const loadSettings = async (hookName, args) => {
50-
const ps = (args && args.settings && args.settings[pluginName]) || {};
51-
if (typeof ps.defaultEnabled === 'boolean') cachedDefaultEnabled = ps.defaultEnabled;
52-
};
53-
54-
const clientVars = async (hookName, ctx) => {
55-
let initialPadEnabled = cachedDefaultEnabled;
56-
try {
57-
const padSettings = ctx && ctx.pad && typeof ctx.pad.getPadSettings === 'function'
58-
? ctx.pad.getPadSettings() : null;
59-
const stored = padSettings && padSettings[pluginName];
60-
if (stored && typeof stored.enabled === 'boolean') initialPadEnabled = stored.enabled;
61-
} catch (_e) { /* leave initialPadEnabled at instance default */ }
62-
63-
const helperBlock = {
64-
[pluginName]: {
65-
padWideSupported: padOptionsPluginPassthrough,
66-
settingId,
67-
l10nId,
68-
defaultEnabled: cachedDefaultEnabled,
69-
initialPadEnabled,
70-
},
71-
};
72-
return {ep_plugin_helpers: {padToggle: helperBlock}};
73-
};
74-
75-
const renderCheckbox = (idPrefix) =>
76-
`<p>` +
77-
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
78-
`<label for="${idPrefix}options-${settingId}" data-l10n-id="${l10nId}"></label>` +
79-
`</p>`;
80-
81-
const eejsBlock_mySettings = (hookName, args, cb) => {
82-
args.content += renderCheckbox('');
83-
return cb();
84-
};
85-
86-
const eejsBlock_padSettings = (hookName, args, cb) => {
87-
if (!padOptionsPluginPassthrough) return cb();
88-
args.content += renderCheckbox('padsettings-');
89-
return cb();
90-
};
91-
92-
// ---------- Client-side state (closed over by init/handleClientMessage) ----------
9338

9439
let onChangeCallback = () => {};
9540
let lastEffective = null;
9641

9742
const getPad = () => {
9843
if (typeof window === 'undefined') return null;
99-
// Try the AMD pad module first (preferred), then the global.
10044
try {
10145
// eslint-disable-next-line global-require
10246
const m = require('ep_etherpad-lite/static/js/pad');
@@ -148,12 +92,12 @@ const padToggle = (config) => {
14892
const getEffective = () => {
14993
if (isEnforced()) {
15094
const padVal = readPadValue();
151-
return padVal != null ? padVal : cachedDefaultEnabled;
95+
return padVal != null ? padVal : defaultEnabled;
15296
}
15397
const userVal = readUserValue();
15498
if (userVal != null) return userVal;
15599
const padVal = readPadValue();
156-
return padVal != null ? padVal : cachedDefaultEnabled;
100+
return padVal != null ? padVal : defaultEnabled;
157101
};
158102

159103
const refreshUI = () => {
@@ -181,9 +125,6 @@ const padToggle = (config) => {
181125
const $u = window.$(`#${userCheckboxId}`);
182126
const $p = window.$(`#${padCheckboxId}`);
183127

184-
// User-side checkbox: cookie-backed, mirrors the effective value when not
185-
// enforced. Disabled visually + functionally when the pad creator has
186-
// turned on enforceSettings.
187128
if ($u.length) {
188129
$u.prop('checked', getEffective());
189130
$u.prop('disabled', isEnforced());
@@ -198,9 +139,6 @@ const padToggle = (config) => {
198139
});
199140
}
200141

201-
// Pad-wide checkbox: only present when the rendering hook ran (i.e. the
202-
// server has the passthrough patch). changePadOption broadcasts and the
203-
// local applyPadSettings updates pad.padOptions[pluginName] in-place.
204142
if ($p.length && pad && typeof pad.changePadOption === 'function') {
205143
const initial = readPadValue();
206144
if (initial != null) $p.prop('checked', initial);
@@ -210,9 +148,6 @@ const padToggle = (config) => {
210148
refreshUI();
211149
});
212150
} else if (!isSupportedClient()) {
213-
// Surface the degraded state once per pad load so the operator notices
214-
// when their core lacks the passthrough patch but plugin authors expect
215-
// pad-wide behavior.
216151
if (typeof console !== 'undefined' && !init._warned) {
217152
console.warn(
218153
`[ep_plugin_helpers.padToggle ${pluginName}] pad-wide settings ` +
@@ -231,24 +166,17 @@ const padToggle = (config) => {
231166
};
232167
};
233168

234-
// Etherpad dispatches handleClientMessage_<type> for every incoming
235-
// COLLABROOM message. For pad-wide changes, the outer type is
236-
// CLIENT_MESSAGE and the inner payload.type is padoptions. Plugins
237-
// re-export this hook so the helper can refresh local state when another
238-
// user toggles the pad-wide value.
169+
// Plugin re-exports this so the helper sees pad-wide broadcasts and
170+
// refreshes local state when another user toggles the pad-wide checkbox.
171+
// Etherpad dispatches handleClientMessage_<type> for every COLLABROOM
172+
// message; for pad-wide changes the outer type is CLIENT_MESSAGE and the
173+
// inner payload.type is padoptions.
239174
const handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => {
240175
if (!ctx || !ctx.payload) return;
241176
if (ctx.payload.type === 'padoptions') refreshUI();
242177
};
243178

244-
return {
245-
loadSettings,
246-
clientVars,
247-
eejsBlock_mySettings,
248-
eejsBlock_padSettings,
249-
init,
250-
handleClientMessage_CLIENT_MESSAGE,
251-
};
179+
return {init, handleClientMessage_CLIENT_MESSAGE};
252180
};
253181

254-
module.exports = {padToggle, createPadToggle: padToggle};
182+
module.exports = {padToggle: padToggleClient, createPadToggle: padToggleClient};

test/pad-toggle.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
const assert = require('assert');
4-
const {padToggle} = require('../pad-toggle');
4+
const {padToggle} = require('../pad-toggle-server');
55

66
const baseConfig = () => ({
77
pluginName: 'ep_test',
@@ -27,16 +27,22 @@ describe('padToggle', () => {
2727
/l10nId/);
2828
});
2929

30-
it('returns the full hook surface for valid config', () => {
30+
it('returns the full server hook surface for valid config', () => {
3131
const t = padToggle(baseConfig());
3232
for (const k of [
3333
'loadSettings', 'clientVars',
3434
'eejsBlock_mySettings', 'eejsBlock_padSettings',
35-
'init', 'handleClientMessage_CLIENT_MESSAGE',
3635
]) {
3736
assert.strictEqual(typeof t[k], 'function', `missing hook: ${k}`);
3837
}
3938
});
39+
40+
it('client sub-path exposes init + handleClientMessage_CLIENT_MESSAGE', () => {
41+
const {padToggle: clientFactory} = require('../pad-toggle');
42+
const t = clientFactory(baseConfig());
43+
assert.strictEqual(typeof t.init, 'function');
44+
assert.strictEqual(typeof t.handleClientMessage_CLIENT_MESSAGE, 'function');
45+
});
4046
});
4147

4248
describe('eejsBlock_mySettings', () => {
@@ -123,9 +129,9 @@ describe('padToggle', () => {
123129
});
124130

125131
describe('backwards-compat alias', () => {
126-
it('createPadToggle still resolves', () => {
127-
const {createPadToggle} = require('../pad-toggle');
128-
assert.strictEqual(typeof createPadToggle, 'function');
132+
it('createPadToggle resolves on both sub-paths', () => {
133+
assert.strictEqual(typeof require('../pad-toggle-server').createPadToggle, 'function');
134+
assert.strictEqual(typeof require('../pad-toggle').createPadToggle, 'function');
129135
});
130136
});
131137
});

0 commit comments

Comments
 (0)