Shared factory functions that eliminate boilerplate across Etherpad plugins.
Instead of writing the same 20-line hook function in every plugin, call a one-liner that generates it for you.
pnpm run plugins i ep_plugin_helpers
Plugins that use these helpers should add it as a dependency in their package.json:
"dependencies": {
"ep_plugin_helpers": "^0.2.0"
}Generate the full attribute rendering pipeline — from editor display to HTML export — with a single config object.
// Client-side (static/js/index.js)
const {lineAttribute} = require('ep_plugin_helpers/attributes');
const headings = lineAttribute({
attr: 'heading',
tags: ['h1', 'h2', 'h3', 'h4', 'code'],
normalize: (value) => (value === 'h5' || value === 'h6') ? 'h4' : value,
});
exports.aceAttribsToClasses = headings.aceAttribsToClasses;
exports.aceDomLineProcessLineAttributes = headings.aceDomLineProcessLineAttributes;
exports.aceRegisterBlockElements = headings.aceRegisterBlockElements;
exports.aceRegisterLineAttributes = headings.aceRegisterLineAttributes;// Shared (static/js/shared.js)
const {lineAttribute} = require('ep_plugin_helpers/attributes');
const headings = lineAttribute({attr: 'heading', tags: ['h1', 'h2', 'h3']});
exports.collectContentPre = headings.collectContentPre;
exports.collectContentPost = headings.collectContentPost;Config:
attr— the attribute name (e.g.'heading','align')tags— array of valid tag/value namesnormalize(value)— optional function to map values (e.g. h5 → h4)
Returns: aceAttribsToClasses, aceDomLineProcessLineAttributes, aceRegisterBlockElements, aceRegisterLineAttributes, collectContentPre, collectContentPost
const {inlineAttribute} = require('ep_plugin_helpers/attributes');
const fontColor = inlineAttribute({
attr: 'color',
values: ['black', 'red', 'green', 'blue'],
});
exports.aceAttribsToClasses = fontColor.aceAttribsToClasses;
exports.aceCreateDomLine = fontColor.aceCreateDomLine;Config:
attr— the attribute name (e.g.'color','font-size')values— optional array of allowed values (omit to accept any)
Returns: aceAttribsToClasses, aceCreateDomLine, collectContentPre, collectContentPost
const {tagAttribute} = require('ep_plugin_helpers/attributes');
const subSup = tagAttribute({tags: ['sub', 'sup']});
exports.aceAttribClasses = subSup.aceAttribClasses;
exports.aceAttribsToClasses = subSup.aceAttribsToClasses;
exports.aceRegisterBlockElements = subSup.aceRegisterBlockElements;Config:
tags— array of tag names that map to HTML elements
Returns: aceAttribClasses, aceAttribsToClasses, aceRegisterBlockElements, collectContentPre, collectContentPost
These require ep_etherpad-lite modules and must NOT be imported from client-side code.
// Server-side (index.js)
const {lineAttributeExport} = require('ep_plugin_helpers/attributes-server');
const headingsExport = lineAttributeExport({
attr: 'heading',
normalize: (v) => (v === 'h5' || v === 'h6') ? 'h4' : v,
exportStyles: 'h1{font-size:2.5em}\nh2{font-size:1.8em}\n',
});
exports.stylesForExport = headingsExport.stylesForExport;
exports.getLineHTMLForExport = headingsExport.getLineHTMLForExport;Available exports:
lineAttributeExport(config)— returnsstylesForExport,getLineHTMLForExportinlineAttributeExport(config)— returnsexportHtmlAdditionalTagsWithData,stylesForExport,getLineHTMLForExporttagAttributeExport(config)— returnsexportHtmlAdditionalTags,getLineHTMLForExport
Inject HTML templates into page sections with one line.
const {template, rawHTML} = require('ep_plugin_helpers');
// Inject an EJS template
exports.eejsBlock_editbarMenuLeft = template('ep_myplugin/templates/buttons.ejs');
// With template variables
exports.eejsBlock_mySettings = template('ep_myplugin/templates/settings.ejs', {
vars: () => ({checked: 'checked'}),
});
// Skip when button already in toolbar
exports.eejsBlock_editbarMenuLeft = template('ep_myplugin/templates/buttons.ejs', {
skip: () => JSON.stringify(settings.toolbar).indexOf('myButton') > -1,
});
// Inject raw HTML string
exports.eejsBlock_styles = rawHTML('<link href="..." rel="stylesheet">');Load plugin settings from settings.json and relay to the client.
const {settings} = require('ep_plugin_helpers');
const relay = settings('ep_myplugin', {defaultOption: true});
exports.loadSettings = relay.loadSettings;
exports.clientVars = relay.clientVars;
// Access settings anywhere in your plugin:
relay.get() // full settings object
relay.get('option') // specific keyCheckbox in the User Settings panel with cookie persistence (per-user, per-pad).
const {toggle} = require('ep_plugin_helpers');
const myToggle = toggle({
pluginName: 'ep_myplugin',
settingId: 'my-feature',
defaultEnabled: true,
});
// Server-side
exports.eejsBlock_mySettings = myToggle.eejsBlock_mySettings;
// Client-side (in postAceInit)
const state = myToggle.init(); // reads cookie, binds checkbox
// state.enabled tracks current valueParallel 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 (PR #7698, shipped in >= 3.0.0) AND the runtime flag settings.enablePluginPadOptions = true in settings.json (default false). When either is missing the pad-wide column is hidden automatically and the user-side cookie toggle keeps working — plugins built on this helper run everywhere. The console warning logged on degradation names the specific cause so an admin can tell whether to upgrade the core or to flip the runtime flag.
// settings.json
{
"enablePluginPadOptions": true
}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:
{
"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):
enforceSettingson → use the pad-wide valueenforceSettingsoff → use the user cookie value, falling back to pad-wide, falling back todefaultEnabled
DRY up the toolbar <select>-change → ace edit → focus-restore boilerplate that plugins like ep_font_color, ep_font_size, and ep_headings2 each implement by hand:
// before — repeated in each plugin
exports.postAceInit = (hookName, context) => {
const hs = $('#font-size, select.size-selection');
hs.on('change', function () {
const value = $(this).val();
const intValue = parseInt(value, 10);
if (!isNaN(intValue)) {
context.ace.callWithAce((ace) => {
ace.ace_doInsertsizes(intValue);
}, 'insertsize', true);
hs.val('dummy');
context.ace.focus();
}
});
};With this helper:
// Client-only — import the sub-path directly so esbuild doesn't pull
// any server-only deps into the pad bundle.
const {toolbarSelect} = require('ep_plugin_helpers/toolbar-select');
exports.postAceInit = (hookName, context) => {
toolbarSelect({
selector: '#font-size, select.size-selection',
context,
invoke: (ace, value) => ace.ace_doInsertsizes(value),
op: 'insertsize',
});
};Config:
| Field | Required | Default | Description |
|---|---|---|---|
selector |
yes | — | jQuery selector for the toolbar <select>. |
context |
yes | — | The context argument from postAceInit — must expose context.ace.callWithAce and context.ace.focus. |
invoke |
yes | — | (ace, coercedValue) => void. Runs inside callWithAce so the edit joins the undo stack. |
op |
no | 'toolbarSelect' |
callWithAce label — useful for debugging the undo stack. |
coerce |
no | 'int' |
One of 'int' | 'number' | 'string' | 'identity', or a custom (raw) => coerced | null. Returning null skips the edit but still restores focus. |
resetValue |
no | 'dummy' |
Value to write back to the select after a successful edit, so picking the same option again still fires change. |
onAfterChange |
no | — | (coercedValue) => void, called after focus is restored. Errors are swallowed and logged so a buggy callback can't break the editor. |
Focus restoration runs unconditionally — even if coerce returned null — so an accidental pick on a non-numeric option leaves the user typing back in the pad, not on the toolbar control.
Intercept and relay real-time COLLABROOM messages.
const {messageRelay} = require('ep_plugin_helpers');
const relay = messageRelay({
incomingType: 'cursor',
action: 'cursorPosition',
buildPayload: async (message) => ({
authorId: message.myAuthorId,
padId: message.padId,
}),
});
exports.handleMessage = relay.handleMessage;Hide or remove UI elements — for ep_disable_* style plugins.
const {hideCSS, removeElement} = require('ep_plugin_helpers');
// Server-side: inject CSS to hide
exports.eejsBlock_styles = hideCSS('#chatbox, #chaticon');
// Client-side: remove from DOM
exports.postAceInit = removeElement('li[data-key="clearauthorship"]', {
removePrecedingSeparator: true,
});const {logger} = require('ep_plugin_helpers');
const log = logger('ep_myplugin');
log.info('loaded');
log.warn('something');Use cases: syntax highlighting, spell-check squiggles, find-and-replace highlights, lint indicators, "search-in-pad" UIs.
The trap: any plugin that calls splitText / insertBefore / innerHTML = to inject decorative <span>s into a line div is mutating DOM that Etherpad's Ace owns. Ace tracks each line's text nodes, attribute spans, and _magicdom_dirtiness.knownHTML. Mutating that DOM mid-edit fights its bookkeeping — broken caret on active-line typing, broken changeset application from collaborators, stuck stale decorations.
The fix: register character ranges with the browser's CSS Custom Highlights API and let the browser composite the paint via ::highlight() CSS rules. The DOM stays exactly as Ace wrote it — Ace's bookkeeping never sees your decorations.
// Client-side (static/js/index.js)
const {createCssHighlights} = require('ep_plugin_helpers/css-highlights');
const reg = createCssHighlights();
// Whenever a line should re-paint (acePostWriteDomLineHTML, MutationObserver,
// aceEditEvent, language change, …):
reg.setLineRanges(lineEl, [
{start: 0, end: 5, cls: 'my-keyword'},
{start: 12, end: 18, cls: 'my-string'},
]);
// On a global state reset (e.g. user changed language):
reg.clearAll();/* static/css/editor.css — applied inside the inner ace iframe */
::highlight(my-keyword) { color: #d73a49; font-weight: bold; }
::highlight(my-string) { color: #032f62; }Returns: setLineRanges(lineEl, ranges), removeLineRanges(lineEl), clearAll(), plus buildRange / buildSegments exposed as pure helpers for unit testing without a browser window.
Each instance owns its own Highlight registry — multiple plugins can use this helper side-by-side without colliding (as long as their cls names don't clash; namespacing like myplugin-keyword is recommended).
setLineRanges no-ops gracefully on browsers that lack CSS.highlights (Chrome < 105, Firefox < 140, Safari < 17.2). The host editor still works; just no decoration paint.
Etherpad bundles client-side JS with esbuild. To avoid pulling Node.js modules into the browser bundle:
- Client-side code →
require('ep_plugin_helpers/attributes') - Server-side code →
require('ep_plugin_helpers')orrequire('ep_plugin_helpers/attributes-server')
Old function names still work as aliases:
| New | Old |
|---|---|
lineAttribute |
createLineAttribute |
inlineAttribute |
createInlineAttribute |
tagAttribute |
createTagAttribute |
template |
eejsBlock |
rawHTML |
eejsBlock.raw |
settings |
createSettingsRelay |
toggle |
createSettingsToggle |
padToggle |
createPadToggle |
messageRelay |
createMessageRelay |
logger |
createLogger |
Apache-2.0