Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -180,6 +180,64 @@ 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, html10n overwrites the fallback
defaultLabel: 'My feature', // a11y fallback — rendered inside <label> so screen readers
// announce something before html10n loads
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.
Expand Down Expand Up @@ -245,6 +303,7 @@ Old function names still work as aliases:
| `rawHTML` | `eejsBlock.raw` |
| `settings` | `createSettingsRelay` |
| `toggle` | `createSettingsToggle` |
| `padToggle` | `createPadToggle` |
| `messageRelay` | `createMessageRelay` |
| `logger` | `createLogger` |

Expand Down
10 changes: 10 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ 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).
//
// 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; },

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
111 changes: 111 additions & 0 deletions pad-toggle-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'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 HTML_ESCAPE_RE = /[&<>"']/g;
const HTML_ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'};
const escapeHtml = (s) => String(s).replace(HTML_ESCAPE_RE, (c) => HTML_ESCAPES[c]);

const validateConfig = (config) => {
if (!config || typeof config !== 'object') {
throw new Error('padToggle requires a config object');
}
const {pluginName, settingId, l10nId, defaultLabel, 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) — i18n is mandatory');
}
if (!defaultLabel || typeof defaultLabel !== 'string') {
throw new Error(
'padToggle requires defaultLabel (string) — accessibility fallback ' +
'rendered inside <label> so screen readers announce something before ' +
'html10n loads. html10n overwrites it at runtime via data-l10n-id.');
}
return {pluginName, settingId, l10nId, defaultLabel, defaultEnabled: !!defaultEnabled};
};

const renderCheckbox = (settingId, l10nId, defaultLabel, idPrefix) =>
`<p>` +
`<input type="checkbox" id="${idPrefix}options-${settingId}">` +
`<label for="${idPrefix}options-${settingId}" ` +
`data-l10n-id="${escapeHtml(l10nId)}">${escapeHtml(defaultLabel)}</label>` +
`</p>`;

const padToggleServer = (rawConfig) => {
const {pluginName, settingId, l10nId, defaultLabel, 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, defaultLabel, '');
return cb();
};

const eejsBlock_padSettings = (hookName, args, cb) => {
if (!padOptionsPluginPassthrough) return cb();
args.content += renderCheckbox(settingId, l10nId, defaultLabel, 'padsettings-');
return cb();
};

return {loadSettings, clientVars, eejsBlock_mySettings, eejsBlock_padSettings};
};

module.exports = {padToggle: padToggleServer, createPadToggle: padToggleServer};
Loading