Welcome to the Etherpad project. This guide provides essential context and instructions for AI agents and developers to effectively contribute to the codebase.
Etherpad is a real-time collaborative editor designed to be lightweight, scalable, and highly extensible via plugins.
- Runtime: Node.js >= 22.12.0
- Package Manager: pnpm (>= 11.0.0)
- Languages: TypeScript (primary for new code), JavaScript (legacy), CSS, HTML
- Backend: Express.js 5, Socket.io 4
- Frontend: Legacy core (
src/static), Modern React UI (ui/), Admin UI (admin/) - Database: ueberdb2 abstraction (supports dirtyDB, MySQL, PostgreSQL, Redis)
- Build Tools: Vite (for
uiandadmin), esbuild, tsx - Testing: Mocha (backend), Playwright (frontend E2E), Vitest (unit)
- Auth: JWT (jose library), OIDC provider
src/node/- Backend logic, API handlers, database models, hookssrc/static/- Core frontend logic (legacy jQuery-based editor)src/static/js/pluginfw/- Plugin framework (installer, hook system)src/tests/- Test suites (backend, frontend, container)ui/- Modern React OIDC login UI (Vite + TypeScript)admin/- Modern React admin panel (Vite + TypeScript + Radix UI)bin/- CLI utilities, build scripts, plugin management toolsbin/plugins/- Plugin maintenance scripts (checkPlugin.ts, updateCorePlugins.sh)doc/- Documentation (VitePress + Markdown/AsciiDoc)local_plugins/- Directory for developing and testing plugins locallyvar/- Runtime data (logs, dirtyDB, etc. - ignored by git)
pnpm install # Install all dependencies
pnpm run build:etherpad # Build admin UI and static assets
pnpm --filter ep_etherpad-lite run dev # Start dev server (port 9001)
pnpm --filter ep_etherpad-lite run prod # Start production server- Indentation: 2 spaces for all files (JS/TS/CSS/HTML). No tabs.
- TypeScript: All new code should be TypeScript. Strict mode is enabled.
- Comments: Provide clear comments for complex logic only.
- Backward Compatibility: Always ensure compatibility with older versions of the database and configuration files.
- Every user-facing string MUST go through i18n. That means JSX text,
placeholder,title,aria-label,alt, toast/alert titles,<option>labels, error messages, breadcrumbs, empty-state copy — anything a user can see or hear via a screen reader. - React (admin SPA): use
<Trans i18nKey="…"/>for JSX text andt('…')for attribute values. Thetcomes fromuseTranslation(). - Pad UI (legacy): use
data-l10n-id="…"(html10n) — never bindwindow._directly tohtml10n.getand call it (it's unbound; returnsundefined). - String keys live in
src/locales/en.json. Other locales sync from translatewiki on its own cadence — never hand-edit non-EN locale files. - Reuse existing keys before inventing new ones. Check
src/locales/en.jsonand the relevant plugin'sstatic/locale/en.json(e.g.admin/public/ep_admin_pads/en.json) for an existing match. Duplicating a key likeep_admin_pads:ep_adminpads2_last-editedas a freshadmin_pads.col.last_editedfragments the translation surface for translatewiki. - Naming: dot-namespaced, kebab-or-underscore-cased:
admin_plugins.subtitle,admin_pads.filter.all. Group by page/feature. - Pluralisation: use i18next's
_one/_othersuffix forms witht('key', {count: n})— nevern > 1 ? 'X items' : '1 item'. - Locale-aware formatters: pass a sanitised locale to
Intl.*/toLocaleString.i18n.languageis influenced by?lng=(user-controlled) and a malformed tag throwsRangeError. Use asanitizeLocale()helper that normalises_→-and validates viaIntl.DateTimeFormat.supportedLocalesOf(), falling back to'en'. defaultValue:int()is for safety, not a substitute for adding the key toen.json. If you're tempted to inline English withdefaultValue:and skip the key, you're shipping a future-broken translation.- Hard prohibition: literal German / French / Spanish / any non-English in JSX. The denylist test at
src/tests/backend-new/specs/admin-i18n-source-lint.test.tsenforces this for the admin SPA — extend it when adding new admin files or new known-bad words.
- Icon-only buttons MUST have
aria-labelANDtitle(both — screen readers preferaria-label; hover users gettitle). Lucide icons inside a button are not text content. Both labels must bet('…')-localised. - Sort controls are focusable. A
<select>plus a paired direction toggle is fine; a clickable column header is fine; "click invisible part of the row to sort" is not. When restyling, never strip a direction toggle without adding back an equivalent. - Semantic HTML over
<div>soup. Use<nav>,<main>,<button>,<a>(for navigation),<table>for tabular data. If a thing navigates externally, it is<a target="_blank" rel="noopener noreferrer">, not a click-handler on a<span>. - Don't drop existing affordances when restyling. A UI refresh that removes
<a href="https://npmjs.com/{plugin}">links, removes a sort-direction control, or replaces semantic elements with non-focusable divs is a regression even if it looks nicer. Audit the before/after for: focus order, keyboard reachability, external links, aria-labels, alt text. - Tests assert rendered strings + structural affordances. For UI changes, the Playwright spec must assert at least one rendered translated string (catches broken i18n loading) and one structural affordance you added/preserved (links, toggles, headings).
Automated tests are not enough for UI. If an AI tool wrote or changed anything a user can see or interact with (a button, popup, form, setting, layout, wording, styling), a human must run it and use it before the PR is opened.
- Agents: stop and ask before opening the PR. Start a local instance with the change, tell the human the URL and exactly what to click, and wait for them to say they tried it and are happy with how it looks and behaves. Do not open the PR until they do.
- Never tick the click-through checkbox in the PR template yourself. Only the human who did the click-through ticks it. If they haven't, leave it unticked and say so in the PR description.
- Fix what they find first. Layout, placement, wording and "this should be one click" feedback is the point of the click-through. Address it before the PR, not in review.
- A Playwright spec is still required (see Accessibility above). It covers regressions, not whether the UI is any good.
- Branching: Work in feature branches. Issue PRs against the
developbranch. Never PR directly tomaster. - Commits: Maintain a linear history (no merge commits). Use meaningful messages in the format:
submodule: description. - Feature Flags: New features should be placed behind feature flags and disabled by default.
- Deprecation: Never remove features abruptly; deprecate them first with a
WARNlog. - Forks: For etherpad-lite changes, commit to
johnmclear/etherpad-litefork on a new branch, then PR toether/etherpad. For plugins (ep_*repos), committing directly is acceptable.
- Requirement: Every bug fix MUST include a regression test in the same commit.
- Always run tests locally before pushing to CI.
- Linting:
pnpm run lint - Type Check:
pnpm --filter ep_etherpad-lite run ts-check - Build:
pnpm run build:etherpadbefore production deployment
Backend tests use Mocha with tsx and run against a real server instance (started automatically by the test harness). No separate server process is needed.
# Run ALL backend tests (includes plugin tests)
pnpm --filter ep_etherpad-lite run test
# Run only utility tests (faster, ~5s timeout)
pnpm --filter ep_etherpad-lite run test-utils
# Run a single test file directly
cd src && cross-env NODE_ENV=production npx mocha --import=tsx --timeout 120000 tests/backend/specs/YOUR_TEST.ts
# Run unit tests (Vitest)
cd src && npx vitest- Tests run with
NODE_ENV=production. - Default timeout is 120 seconds per test.
- Test files live in
src/tests/backend/specs/. - Plugin backend tests live in
node_modules/ep_*/static/tests/backend/specs/(at repo root) and are included automatically by the test script.
Frontend tests use Playwright. You must have a running Etherpad server before launching them — the Playwright config does not auto-start the server.
Before running frontend or admin tests, ensure Playwright browsers are installed. Check and install if needed:
# Check which browsers are installed
cd src && npx playwright install --dry-run
# Install all browsers and their system dependencies (must run from src/)
cd src && npx playwright install
cd src && sudo npx playwright install-depsIf sudo is unavailable, install system dependencies for webkit manually:
# Check which system libraries are missing for webkit
ldd ~/.cache/ms-playwright/webkit-*/minibrowser-wpe/MiniBrowser 2>&1 | grep "not found"If browsers or system dependencies are missing, tests will fail silently or timeout — always verify browser installation before debugging test failures.
# 1. Start the dev server in a separate terminal
pnpm --filter ep_etherpad-lite run dev
# 2. Run frontend E2E tests
pnpm --filter ep_etherpad-lite run test-ui
# 3. Run with interactive Playwright UI (useful for debugging)
pnpm --filter ep_etherpad-lite run test-ui:ui
# Run a single test file
cd src && cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs/YOUR_TEST.spec.ts- Tests expect the server at
localhost:9001. - Test files live in
src/tests/frontend-new/specs/. - Runs against chromium and firefox by default (webkit is disabled).
- Playwright config is at
src/playwright.config.ts.
# Requires a running server and Playwright browsers installed (same as frontend tests)
pnpm --filter ep_etherpad-lite run test-admin
# Interactive UI mode
pnpm --filter ep_etherpad-lite run test-admin:ui- Admin tests run with
--workers 1(sequential) on chromium and firefox only. - Test files live in
src/tests/frontend-new/admin-spec/.
Tests use JWT authentication, not API keys. Pattern:
import * as common from 'ep_etherpad-lite/tests/backend/common';
const agent = await common.init(); // Starts server, returns supertest agent
const token = await common.generateJWTToken();
agent.get('/api/1/endpoint').set('authorization', token);Do not use APIKEY.txt — it may not exist in the test environment.
The real-time synchronization engine. It is complex; refer to doc/public/easysync/ before modifying core synchronization logic.
Most functionality should be implemented as plugins (ep_*). Avoid modifying the core unless absolutely necessary.
Plugin structure:
ep_myplugin/
├── ep.json # Hook declarations (server_hooks, client_hooks)
├── index.js # Server-side hook implementations
├── package.json
├── static/
│ ├── js/ # Client-side code
│ ├── css/
│ └── tests/
│ ├── backend/specs/ # Backend tests (Mocha)
│ └── frontend-new/ # Frontend tests (Playwright)
├── templates/ # EJS templates
└── locales/ # i18n files
Plugin management:
pnpm run plugins i ep_plugin_name # Install from npm
pnpm run plugins i --path ../plugin # Install from local path
pnpm run plugins rm ep_plugin_name # Remove
pnpm run plugins ls # List installedPlugin installation internals: Plugins are installed to src/plugin_packages/ via live-plugin-manager, which stores them at src/plugin_packages/.versions/ep_name@version/. Symlinks are created: src/node_modules/ep_name → src/plugin_packages/ep_name → .versions/ep_name@ver/.
- Monorepo:
ether/ether-pluginscontains 80+ plugins with shared CI/publishing - Standalone repos: Individual
ether/ep_*repos still exist for many plugins - Plugin CI templates:
bin/plugins/lib/contains workflow templates pushed to standalone plugin repos viacheckPlugin.ts - Shared pipelines:
ether/ether-pipelinescontains reusable GitHub Actions workflows for plugin CI
Configured via settings.json. A template is available at settings.json.template. Environment variables can override any setting using "${ENV_VAR}" or "${ENV_VAR:default_value}".
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches one workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
- A
# X.Y.Zchangelog section for the target version must already be at the top ofCHANGELOG.md.bin/release.tsaborts withNo changelog record for X.Y.Z, please create changelog recordif it's missing. Write the section before dispatching. - All four
package.jsonfiles must agree on the current version: rootpackage.json,src/package.json,admin/package.json,bin/package.json.release.tsreads the current version fromsrc/package.jsonand computes the next withsemver.inc(current, type). If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. (This exact desync —src/binleft at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)
- Actions → "Release etherpad" → Run workflow (
workflow_dispatch), choosepatch/minor/major. Cadence is monthly minors (3.0.0 → 3.1.0 → 3.2.0 → …); usemajoronly for breaking changes. - The Prepare release step runs
bin/release.ts, which:- sanity-checks the tree (clean working dir, on
develop,develop/masterupstreams in sync,../ether.github.comcloned & clean onmaster); - bumps the version in all four
package.jsonfiles; - commits
bump version, mergesdevelop→master, creates bothX.Y.ZandvX.Y.Ztags, mergesmasterback todevelop; - builds and stages the versioned docs into the website repo (see Documentation below).
- sanity-checks the tree (clean working dir, on
- The Push after release step (
bin/push-after-release.sh) pushesmaster,develop, the tag,--tags, and theether.github.comdocs commit. - The pushed
vX.Y.Ztag auto-triggers three workflows:handleRelease.yml→ builds Etherpad, extracts the matching changelog section viagenerateChangelog(bin/generateReleaseNotes.ts), and publishes the GitHub Release (make_latest: true);docker.yml→ builds & pushes the Docker images;snap-publish.yml→ publishes the snap.
- npm publish — PARKED, not part of the release.
releaseEtherpad.yamlpublishes the core asep_etherpadto npm, but that package is not load-bearing: it has 0 dependents, nothing depends on it (plugins importep_etherpad-litefrom the local core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — nevernpm install. The publish currently fails withE404becauseep_etherpadhas no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind aconfirm: trueinput so it can't run by accident. Skip it for a normal release. To revive it, the npm owner ofep_etherpad(samtv12345) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repoether/etherpad, workflowreleaseEtherpad.yml); otherwise the workflow can be removed. Decision pending.
Two distinct things, both important:
1. Per-PR doc updates — your responsibility in every behaviour-change PR.
The doc/ workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in doc/ in the same PR — don't defer it to release time. The HTTP API reference is doc/api/http_api.{md,adoc} (keep both in sync). Preview locally with pnpm run makeDocs.
2. Release-time versioned docs publishing — automated, no manual step.
During a release, release.ts runs pnpm run makeDocs (→ bin/make_docs.ts) to render doc/ into out/doc/, then copies it into the sibling website repo at ../ether.github.com/public/doc/vX.Y.Z, bumps that repo's version, and commits X.Y.Z docs; push-after-release.sh pushes it, publishing the versioned docs on etherpad.org. This requires ether.github.com checked out as a sibling directory — the Release etherpad workflow checks it out automatically. If you ever run release.ts by hand, clone it first: cd .. && git clone git@github.com:ether/ether.github.com.git.
This project uses pnpm workspaces. The workspaces are:
src/- Core Etherpad (package:ep_etherpad-lite)bin/- CLI tools and plugin scriptsui/- Login UIadmin/- Admin paneldoc/- Documentation
Root-level commands operate across all workspaces. Use pnpm --filter <package> to target specific workspaces.
AI/Agent contributions are explicitly welcomed by the maintainers, provided they strictly adhere to the guidelines in CONTRIBUTING.md and this guide. Always prioritize stability, readability, and compatibility.