ep_hljs 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/.github/workflows/automerge.yml +45 -0
  3. package/.github/workflows/backend-tests.yml +75 -0
  4. package/.github/workflows/codeql.yml +41 -0
  5. package/.github/workflows/frontend-tests.yml +72 -0
  6. package/.github/workflows/npmpublish.yml +124 -0
  7. package/.github/workflows/test-and-release.yml +32 -0
  8. package/CLAUDE.md +141 -0
  9. package/LICENSE +201 -0
  10. package/README.md +56 -0
  11. package/demo.gif +0 -0
  12. package/demo.png +0 -0
  13. package/ep.json +26 -0
  14. package/index.js +109 -0
  15. package/lib/exportRenderer.js +39 -0
  16. package/lib/languageAllowlist.js +16 -0
  17. package/lib/padLanguageStore.js +27 -0
  18. package/locales/en.json +10 -0
  19. package/package.json +59 -0
  20. package/pnpm-workspace.yaml +2 -0
  21. package/scripts/build-vendor.js +45 -0
  22. package/static/css/editor.css +94 -0
  23. package/static/css/themes/github-dark.css +118 -0
  24. package/static/css/themes/github.css +118 -0
  25. package/static/js/codeIndent.js +107 -0
  26. package/static/js/constants.js +7 -0
  27. package/static/js/domOverlay.js +16 -0
  28. package/static/js/highlightRegistry.js +109 -0
  29. package/static/js/hljsAdapter.js +80 -0
  30. package/static/js/index.js +124 -0
  31. package/static/js/lruCache.js +28 -0
  32. package/static/js/syntaxRenderer.js +201 -0
  33. package/static/js/themeBridge.js +76 -0
  34. package/static/js/vendor/hljs.min.js +5 -0
  35. package/static/tests/backend/specs/codeIndent.test.js +144 -0
  36. package/static/tests/backend/specs/export.test.js +47 -0
  37. package/static/tests/backend/specs/highlightRegistry.test.js +59 -0
  38. package/static/tests/backend/specs/hljsAdapter.test.js +43 -0
  39. package/static/tests/backend/specs/lruCache.test.js +45 -0
  40. package/static/tests/backend/specs/padLanguageStore.test.js +63 -0
  41. package/static/tests/backend/specs/socket.test.js +54 -0
  42. package/static/tests/frontend-new/helper/highlights.ts +64 -0
  43. package/static/tests/frontend-new/specs/caret-stability.spec.ts +106 -0
  44. package/static/tests/frontend-new/specs/code-indent.spec.ts +78 -0
  45. package/static/tests/frontend-new/specs/collaboration.spec.ts +59 -0
  46. package/static/tests/frontend-new/specs/content-sync.spec.ts +45 -0
  47. package/static/tests/frontend-new/specs/dark-mode.spec.ts +87 -0
  48. package/static/tests/frontend-new/specs/export.spec.ts +31 -0
  49. package/static/tests/frontend-new/specs/initial-paint.spec.ts +49 -0
  50. package/static/tests/frontend-new/specs/language-picker.spec.ts +54 -0
  51. package/static/tests/frontend-new/specs/large-pad.spec.ts +36 -0
  52. package/static/tests/frontend-new/specs/lifecycle.spec.ts +27 -0
  53. package/static/tests/frontend-new/specs/multi-user-caret.spec.ts +167 -0
  54. package/static/tests/frontend-new/specs/single-line-while.spec.ts +50 -0
  55. package/templates/editbarButtons.ejs +29 -0
package/.eslintrc.cjs ADDED
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ // This is a workaround for https://github.com/eslint/eslint/issues/3458
4
+ require('eslint-config-etherpad/patch/modern-module-resolution');
5
+
6
+ module.exports = {
7
+ root: true,
8
+ extends: 'etherpad/plugin',
9
+ ignorePatterns: ['node_modules', 'static/css/themes', 'static/js/vendor', 'static/tests/frontend-new'],
10
+ };
@@ -0,0 +1,45 @@
1
+ name: Dependabot Automerge
2
+ permissions:
3
+ contents: write
4
+ pull-requests: write
5
+ # `actions: write` lets the post-merge step kick off Node.js Package on
6
+ # the default branch via `gh workflow run`. Without this, automerge'd
7
+ # PRs land on main but the on-push release job never fires (GitHub
8
+ # Actions intentionally suppresses on:push triggers when the push is
9
+ # authenticated with GITHUB_TOKEN).
10
+ actions: write
11
+ on:
12
+ workflow_run:
13
+ workflows:
14
+ - Node.js Package
15
+ types:
16
+ - completed
17
+
18
+ jobs:
19
+ automerge:
20
+ if: >
21
+ github.event.workflow_run.conclusion == 'success' &&
22
+ github.event.workflow_run.event == 'push' &&
23
+ github.event.workflow_run.actor.login == 'dependabot[bot]'
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v6
28
+
29
+ - name: Automerge
30
+ id: automerge
31
+ uses: "pascalgn/automerge-action@v0.16.4"
32
+ env:
33
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34
+ MERGE_METHOD: squash
35
+ MERGE_LABELS: ""
36
+ MERGE_RETRY_SLEEP: "100000"
37
+
38
+ - name: Trigger release on default branch
39
+ # `pascalgn/automerge-action` exits 0 whether or not it merged. Skip
40
+ # the dispatch when nothing was actually merged so we don't kick a
41
+ # phantom release run on every Dependabot Automerge invocation.
42
+ if: steps.automerge.outputs.mergeResult == 'merged'
43
+ env:
44
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ run: gh workflow run test-and-release.yml --ref ${{ github.event.repository.default_branch }}
@@ -0,0 +1,75 @@
1
+ name: Backend Tests
2
+
3
+ # any branch is useful for testing before a PR is submitted
4
+ on:
5
+ workflow_call:
6
+
7
+ jobs:
8
+ withplugins:
9
+ # run on pushes to any branch
10
+ # run on PRs from external forks
11
+ if: |
12
+ (github.event_name != 'pull_request')
13
+ || (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)
14
+ name: with Plugins
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ -
18
+ name: Install libreoffice
19
+ uses: awalsh128/cache-apt-pkgs-action@v1.6.0
20
+ with:
21
+ packages: libreoffice libreoffice-pdfimport
22
+ version: 1.0
23
+ -
24
+ name: Install etherpad core
25
+ uses: actions/checkout@v6
26
+ with:
27
+ repository: ether/etherpad-lite
28
+ path: etherpad-lite
29
+ - uses: actions/setup-node@v6
30
+ name: Install Node.js
31
+ with:
32
+ node-version: 22
33
+ - uses: pnpm/action-setup@v6
34
+ name: Install pnpm
35
+ with:
36
+ version: 10
37
+ run_install: false
38
+ - name: Get pnpm store directory
39
+ shell: bash
40
+ run: |
41
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
42
+ - uses: actions/cache@v5
43
+ name: Setup pnpm cache
44
+ with:
45
+ path: ${{ env.STORE_PATH }}
46
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
47
+ restore-keys: |
48
+ ${{ runner.os }}-pnpm-store-
49
+ -
50
+ name: Checkout plugin repository
51
+ uses: actions/checkout@v6
52
+ with:
53
+ path: plugin
54
+ - name: Remove tests
55
+ working-directory: ./etherpad-lite
56
+ run: rm -rf ./src/tests/backend/specs
57
+ -
58
+ name: Install Etherpad core dependencies
59
+ working-directory: ./etherpad-lite
60
+ run: bin/installDeps.sh
61
+ - name: Install plugin
62
+ working-directory: ./etherpad-lite
63
+ run: |
64
+ pnpm run plugins i --path ../../plugin
65
+ -
66
+ name: Run the backend tests
67
+ working-directory: ./etherpad-lite/src
68
+ run: |
69
+ shopt -s globstar
70
+ res=$(find ./plugin_packages -path "*/static/tests/backend/specs/*" 2>/dev/null | wc -l)
71
+ if [ $res -eq 0 ]; then
72
+ echo "No backend tests found"
73
+ else
74
+ npx cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --recursive node_modules/ep_*/static/tests/backend/specs/**
75
+ fi
@@ -0,0 +1,41 @@
1
+ name: "CodeQL"
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ pull_request:
7
+ branches: [ "main" ]
8
+ schedule:
9
+ - cron: "26 5 * * 2"
10
+
11
+ jobs:
12
+ analyze:
13
+ name: Analyze
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ actions: read
17
+ contents: read
18
+ security-events: write
19
+
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ language: [ javascript ]
24
+
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v6
28
+
29
+ - name: Initialize CodeQL
30
+ uses: github/codeql-action/init@v4
31
+ with:
32
+ languages: ${{ matrix.language }}
33
+ queries: +security-and-quality
34
+
35
+ - name: Autobuild
36
+ uses: github/codeql-action/autobuild@v4
37
+
38
+ - name: Perform CodeQL Analysis
39
+ uses: github/codeql-action/analyze@v4
40
+ with:
41
+ category: "/language:${{ matrix.language }}"
@@ -0,0 +1,72 @@
1
+ # Publicly credit Sauce Labs because they generously support open source
2
+ # projects.
3
+ name: Frontend Tests
4
+
5
+ on:
6
+ workflow_call:
7
+
8
+ jobs:
9
+ test-frontend:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ -
14
+ name: Check out Etherpad core
15
+ uses: actions/checkout@v6
16
+ with:
17
+ repository: ether/etherpad-lite
18
+ path: etherpad-lite
19
+ - uses: actions/setup-node@v6
20
+ name: Install Node.js
21
+ with:
22
+ node-version: 22
23
+ - uses: pnpm/action-setup@v6
24
+ name: Install pnpm
25
+ with:
26
+ version: 10
27
+ run_install: false
28
+ - name: Get pnpm store directory
29
+ shell: bash
30
+ run: |
31
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
32
+ - uses: actions/cache@v5
33
+ name: Setup pnpm cache
34
+ with:
35
+ path: ${{ env.STORE_PATH }}
36
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
37
+ restore-keys: |
38
+ ${{ runner.os }}-pnpm-store-
39
+ -
40
+ name: Checkout plugin repository
41
+ uses: actions/checkout@v6
42
+ with:
43
+ path: plugin
44
+ -
45
+ name: Install Etherpad core dependencies
46
+ working-directory: ./etherpad-lite
47
+ run: bin/installDeps.sh
48
+ - name: Install plugin
49
+ working-directory: ./etherpad-lite
50
+ run: |
51
+ pnpm run plugins i --path ../../plugin
52
+ - name: Create settings.json
53
+ working-directory: ./etherpad-lite
54
+ run: cp ./src/tests/settings.json settings.json
55
+ - name: Run the frontend tests
56
+ working-directory: ./etherpad-lite
57
+ shell: bash
58
+ run: |
59
+ pnpm run dev &
60
+ connected=false
61
+ can_connect() {
62
+ curl -sSfo /dev/null http://localhost:9001/ || return 1
63
+ connected=true
64
+ }
65
+ now() { date +%s; }
66
+ start=$(now)
67
+ while [ $(($(now) - $start)) -le 30 ] && ! can_connect; do
68
+ sleep 1
69
+ done
70
+ cd src
71
+ pnpm exec playwright install chromium --with-deps
72
+ pnpm run test-ui --project=chromium
@@ -0,0 +1,124 @@
1
+ # This workflow will run tests using node and then publish a package to the npm registry when a release is created
2
+ # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
3
+ #
4
+ # Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret is
5
+ # required. Each package must have a trusted publisher configured on npmjs.com
6
+ # pointing at this workflow file. See:
7
+ # https://docs.npmjs.com/trusted-publishers
8
+
9
+ name: Node.js Package
10
+
11
+ on:
12
+ workflow_call:
13
+
14
+ jobs:
15
+ publish-npm:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: write # for the atomic version-bump push (branch + tag)
19
+ id-token: write # for npm OIDC trusted publishing
20
+ steps:
21
+ - uses: actions/setup-node@v6
22
+ with:
23
+ # OIDC trusted publishing needs npm >= 11.5.1, which requires
24
+ # Node >= 20.17.0. setup-node's `20` resolves to the latest
25
+ # 20.x, which satisfies that.
26
+ node-version: 20
27
+ registry-url: https://registry.npmjs.org/
28
+ - name: Upgrade npm to >=11.5.1 (required for trusted publishing)
29
+ run: npm install -g npm@latest
30
+ - name: Check out Etherpad core
31
+ uses: actions/checkout@v6
32
+ with:
33
+ repository: ether/etherpad-lite
34
+ - uses: pnpm/action-setup@v6
35
+ name: Install pnpm
36
+ with:
37
+ # No `version:` here — defer to packageManager in package.json so
38
+ # we don't clash with etherpad-lite's pnpm version pin (the
39
+ # checkout above puts its package.json at cwd). See
40
+ # pnpm/action-setup#225.
41
+ run_install: false
42
+ - name: Get pnpm store directory
43
+ shell: bash
44
+ run: |
45
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
46
+ - uses: actions/cache@v5
47
+ name: Setup pnpm cache
48
+ with:
49
+ path: ${{ env.STORE_PATH }}
50
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
51
+ restore-keys: |
52
+ ${{ runner.os }}-pnpm-store-
53
+ -
54
+ uses: actions/checkout@v6
55
+ with:
56
+ fetch-depth: 0
57
+ -
58
+ name: Bump version (patch)
59
+ id: bump
60
+ run: |
61
+ LATEST_TAG=$(git describe --tags --abbrev=0) || exit 1
62
+ NEW_COMMITS=$(git rev-list --count "${LATEST_TAG}"..) || exit 1
63
+ # No new commits since the last tag → nothing to publish.
64
+ # Setting `bumped=false` lets the publish step below skip
65
+ # itself instead of trying to republish the existing version
66
+ # (which fails with "cannot publish over previously published
67
+ # versions"). Only manual `gh workflow run` invocations on a
68
+ # tag-current branch hit this path; on:push always sees ≥1
69
+ # new commit because the push event is what triggered us.
70
+ if [ "${NEW_COMMITS}" -le 0 ]; then
71
+ echo "bumped=false" >> "${GITHUB_OUTPUT}"
72
+ exit 0
73
+ fi
74
+ git config user.name 'github-actions[bot]'
75
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
76
+ pnpm i --frozen-lockfile
77
+ # `pnpm version patch` bumps package.json, makes a commit, and creates
78
+ # a `v<new-version>` tag. Capture the new tag name from package.json
79
+ # rather than parsing pnpm's output, which has historically varied.
80
+ # Bump the patch component directly with Node. pnpm/action-setup@v6
81
+ # sometimes installs pnpm 11 pre-releases even when version: 10.x is
82
+ # requested (pnpm/action-setup#225); those pre-releases either skip
83
+ # the git commit/tag or reject --no-git-tag-version as unknown.
84
+ # Doing the bump in Node sidesteps both failure modes.
85
+ NEW_VERSION=$(node -e "const fs=require('fs');const p=require('./package.json');const v=p.version.split('.');v[2]=String(Number(v[2])+1);p.version=v.join('.');fs.writeFileSync('./package.json',JSON.stringify(p,null,2)+'\n');console.log(p.version);")
86
+ NEW_TAG="v${NEW_VERSION}"
87
+ git add package.json
88
+ git commit -m "${NEW_TAG}"
89
+ git tag -a "${NEW_TAG}" -m "${NEW_TAG}"
90
+ # CRITICAL: use --atomic so the branch update and the tag update
91
+ # succeed (or fail) as a single transaction on the server. The old
92
+ # `git push --follow-tags` was non-atomic per ref: if a concurrent
93
+ # publish run won the race, the branch fast-forward would be rejected
94
+ # but the tag push would still land — leaving a dangling tag with no
95
+ # matching commit on the branch. Subsequent runs would then forever
96
+ # try to bump to the same already-existing tag and fail with
97
+ # `tag 'vN+1' already exists`. With --atomic, a rejected branch push
98
+ # rejects the tag push too, and the next workflow tick can retry
99
+ # cleanly against the up-to-date refs.
100
+ git push --atomic origin "${GITHUB_REF_NAME}" "${NEW_TAG}"
101
+ echo "bumped=true" >> "${GITHUB_OUTPUT}"
102
+ # This is required if the package has a prepare script that uses something
103
+ # in dependencies or devDependencies.
104
+ -
105
+ if: steps.bump.outputs.bumped == 'true'
106
+ run: pnpm i
107
+ # `npm publish` must come after `git push` otherwise there is a race
108
+ # condition: If two PRs are merged back-to-back then master/main will be
109
+ # updated with the commits from the second PR before the first PR's
110
+ # workflow has a chance to push the commit generated by `npm version
111
+ # patch`. This causes the first PR's `git push` step to fail after the
112
+ # package has already been published, which in turn will cause all future
113
+ # workflow runs to fail because they will all attempt to use the same
114
+ # already-used version number. By running `npm publish` after `git push`,
115
+ # back-to-back merges will cause the first merge's workflow to fail but
116
+ # the second's will succeed.
117
+ #
118
+ # Use `npm publish` directly (not `pnpm publish`) because OIDC trusted
119
+ # publishing requires npm CLI >= 11.5.1 and `pnpm publish` shells out to
120
+ # whichever `npm` is on PATH; calling `npm` directly avoids any shim
121
+ # ambiguity.
122
+ - name: Publish to npm via OIDC
123
+ if: steps.bump.outputs.bumped == 'true'
124
+ run: npm publish --provenance --access public
@@ -0,0 +1,32 @@
1
+ name: Node.js Package
2
+ on:
3
+ push:
4
+ # Invoked by automerge.yml after a Dependabot PR is merged. GitHub
5
+ # Actions doesn't fire on:push when the push is authored by GITHUB_TOKEN
6
+ # (the automerge action's only available identity), so without this
7
+ # dispatch trigger the release job never runs after auto-merges.
8
+ workflow_dispatch:
9
+
10
+ # id-token: write must be granted here so the reusable npmpublish workflow
11
+ # can request an OIDC token for npm trusted publishing.
12
+ permissions:
13
+ contents: write
14
+ id-token: write
15
+
16
+ jobs:
17
+ backend:
18
+ uses: ./.github/workflows/backend-tests.yml
19
+ secrets: inherit
20
+ frontend:
21
+ uses: ./.github/workflows/frontend-tests.yml
22
+ secrets: inherit
23
+ release:
24
+ if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
25
+ needs:
26
+ - backend
27
+ - frontend
28
+ permissions:
29
+ contents: write # for the version bump push
30
+ id-token: write # for npm OIDC trusted publishing
31
+ uses: ./.github/workflows/npmpublish.yml
32
+ secrets: inherit
package/CLAUDE.md ADDED
@@ -0,0 +1,141 @@
1
+ # Claude Code Guidelines for `ep_hljs`
2
+
3
+ Whole-pad syntax highlighting for Etherpad, powered by highlight.js, painted via the **CSS Custom Highlights API**. This file is the architectural reference for anyone (Claude or human) extending the plugin.
4
+
5
+ ## Architecture in one paragraph
6
+
7
+ Tokens are computed at render time and painted by the browser via `CSS.highlights` Range registration — **the DOM Etherpad's Ace owns is never mutated**. On every line render (`acePostWriteDomLineHTML`) and every text mutation (`MutationObserver`), the renderer reads `node.textContent`, runs `hljs.highlight(text, {language})` (cached in an LRU keyed by `language:text`), turns the resulting `<span>`-soup into `{start, end, cls}` token ranges, then builds DOM `Range` objects pointing into the line's existing text nodes and adds them to `CSS.highlights.get('hljs-keyword')` etc. The browser composites the paint via `::highlight(hljs-…)` rules in `editor.css`. No `splitText`, no injected `<span>`s, no Easysync attribute broadcast.
8
+
9
+ ## Files
10
+
11
+ | Path | Role |
12
+ |---|---|
13
+ | `static/js/syntaxRenderer.js` | Orchestrator: state, LRU cache, auto-detect timer, `acePostWriteDomLineHTML` hook, MutationObserver, repaintAllLines |
14
+ | `static/js/highlightRegistry.js` | Wraps `CSS.highlights`. `setLineRanges(lineEl, [{start,end,cls}])` / `removeLineRanges(lineEl)` / `clearAll()` |
15
+ | `static/js/hljsAdapter.js` | `tokenize(text, lang) -> ranges \| null`, `detect(text) -> language \| null`, `parseHljsHtml(html) -> ranges` |
16
+ | `static/js/codeIndent.js` | `aceKeyEvent` handler: Enter inherits/extends indent, Tab/Shift+Tab indent/de-indent in code mode |
17
+ | `static/js/lruCache.js` | Map-backed LRU |
18
+ | `static/js/index.js` | Client postAceInit: dynamically injects `vendor/hljs.min.js`, wires socketio + padToggle.init + syntaxRenderer.start + codeIndent.start |
19
+ | `static/js/themeBridge.js` | Light/dark theme detection (no longer swaps stylesheets — `editor.css` carries both palettes) |
20
+ | `static/js/domOverlay.js` | "Highlighting paused" badge for MAX_LINES kill switch |
21
+ | `static/css/editor.css` | `::highlight(hljs-…)` rules. Light + dark palettes scoped via `.super-dark-editor` |
22
+ | `static/css/themes/{github,github-dark}.css` | Vendored hljs themes — used **only by export pipeline**, not by the live editor |
23
+ | `index.js` (server) | loadSettings, clientVars (lang + indentSize), socketio (setLanguage), eejsBlock_*, getLineHTMLForExport, stylesForExport |
24
+ | `lib/padLanguageStore.js` | `{language, autoDetect}` per pad |
25
+ | `lib/exportRenderer.js` | HTML/PDF export: hljs.highlight() emits real `<span>`s + theme CSS inlined |
26
+
27
+ ## How rendering actually flows
28
+
29
+ ```
30
+ keystroke / paste / remote changeset
31
+
32
+
33
+ Etherpad updates inner-iframe DOM
34
+
35
+ ├─ acePostWriteDomLineHTML hook fires (full line re-renders only)
36
+ │ └─ syntaxRenderer.renderLine(lineDiv)
37
+
38
+ └─ MutationObserver fires (every text mutation, including incremental typing)
39
+ └─ syntaxRenderer.renderLine(dirtyLine)
40
+
41
+
42
+ tokenize(text, language) ← LRU cache
43
+
44
+
45
+ setLineRanges(lineEl, ranges)
46
+
47
+ ├─ removeLineRanges(lineEl) // strip old Highlight refs
48
+ ├─ buildSegments(lineEl) // walk text nodes
49
+ ├─ buildRange(...) per token // Range with setStart/setEnd
50
+ └─ CSS.highlights.get(cls).add(range)
51
+
52
+
53
+ browser paints ::highlight(cls) rules
54
+ ```
55
+
56
+ ## Hard-won lessons (read before changing the render path)
57
+
58
+ ### 1. Don't mutate the DOM Ace owns
59
+ v0.1 stored tokens as Easysync attributes — `setAttributesOnRange` moved the caret to the start of the range on the active line. v0.2 wrapped text nodes via `splitText`/`insertBefore` — fought Etherpad's `_magicdom_dirtiness.knownHTML` bookkeeping; lying about `knownHTML` to break the feedback loop broke remote changeset application. **CSS Custom Highlights is the answer:** observation only, no DOM modification, no fights with Ace.
60
+
61
+ ### 2. `acePostWriteDomLineHTML` only fires on FULL line re-renders
62
+ Etherpad does incremental DOM updates on single-character typing (modifies `textNode.nodeValue` in place). The hook is only called when a line is fully written: paste, language change, line split, undo/redo. To catch every text mutation, install a `MutationObserver` on the inner doc body (`{childList: true, subtree: true, characterData: true}`) and walk up to the magicdomid line div. Etherpad sometimes swaps a line div wholesale on edit — `m.target` becomes `innerdocbody` and the new line is in `m.addedNodes`, so iterate both `m.target` and `m.addedNodes` when finding line ancestors.
63
+
64
+ ### 3. The cache must not poison
65
+ `tokenize()` returns `null` (not `[]`) when `window.hljs` isn't loaded. `renderLine` defers — does not cache, does not strip existing highlights. If you cache `[]` from a missing-hljs render, every future render for that key is empty.
66
+
67
+ ### 4. `CSS.highlights` is per-document
68
+ The editor lives in a nested iframe (`ace_outer` → `ace_inner`). Range objects must be created on the inner document and Highlights registered with the inner window's `CSS.highlights`. Using the outer window's `Highlight` constructor or `CSS.highlights` produces silent no-ops. `highlightRegistry.setLineRanges` resolves the right window via `lineEl.ownerDocument.defaultView`.
69
+
70
+ ### 5. Multi-class hljs spans need splitting
71
+ hljs sometimes emits `<span class="hljs-meta hljs-string">@foo</span>`. CSS `<custom-ident>` doesn't allow spaces, so registering a Highlight named `"hljs-meta hljs-string"` is invisible. `parseHljsHtml` splits on whitespace and emits one range per class.
72
+
73
+ ### 6. Etherpad applies `super-dark-editor` to the inner `<html>`, not `<body>`
74
+ Per `ace.ts:266`. Our dark-mode CSS uses `.super-dark-editor ::highlight(…)` (descendant-selector) so it matches whether the class is on `html`, `body`, or any ancestor.
75
+
76
+ ### 7. `padToggle` exposes `init({onChange})`, not `subscribe`
77
+ The helper's eejs templates render `<input type="checkbox">` with NO `checked` attribute. Without calling `init()` on the client, the checkbox stays unchecked even when `defaultEnabled: true`. The flow is server-renders-empty → client-init-binds-state.
78
+
79
+ ### 8. `acePostWriteDomLineHTML` fires BEFORE `postAceInit` for the initial render
80
+ For pads with persisted language, the initial line renders happen before `loadHljs()` resolves and before `syntaxRenderer.start()` sets state. The hook short-circuits on auto/missing-hljs and leaves them un-tokenized. Fix: schedule a `setTimeout(repaintAllLines, 100)` from `start()` to catch lines that rendered too early.
81
+
82
+ ### 9. Auto-detect is per-client, no broadcast (yet)
83
+ Each client runs `hljs.highlightAuto(padText)` on a 2s idle interval. Convergence relies on `hljs.highlightAuto` being deterministic (same content → same language). If divergence is reported, add a jittered `setLanguage` broadcast with cancel-on-incoming so first-fire wins (sketched but not landed; see git stash `v0.2-task6-wip-archive` for the broadcast variant).
84
+
85
+ ### 10. The `padShortcutEnabled.tab` Etherpad setting affects Tab interception
86
+ `codeIndent.handleKey` returns `true` and `evt.preventDefault()` to suppress Etherpad's default Tab. Test before assuming this works for a given keystroke — `aceKeyEvent` hook ordering means `outsideKeyPress` runs *before* the hook for Enter on Chrome (`isTypeForSpecialKey && keyCode === 13` branch in `ace2_inner.ts`). Backend unit tests are reliable; some Playwright tests for keystroke interaction are flaky and `test.fixme`'d.
87
+
88
+ ## Settings (server-side)
89
+
90
+ ```json
91
+ "ep_hljs": {
92
+ "indentSize": 2
93
+ }
94
+ ```
95
+
96
+ `indentSize` is admin-configurable in `settings.json` (clamped to `1..16`, default `2`). A pad-level UI picker (TOC-style) is a deferred follow-up.
97
+
98
+ ## i18n
99
+
100
+ All user-facing strings have `data-l10n-id` and a fallback English string. Keys live in `locales/en.json`:
101
+
102
+ | Key | Where |
103
+ |---|---|
104
+ | `ep_hljs.label` | toolbar dropdown aria-label |
105
+ | `ep_hljs.auto` | dropdown "Auto-detect" option |
106
+ | `ep_hljs.off` | dropdown "Off" option |
107
+ | `ep_hljs.paused` | "Highlighting paused" badge |
108
+ | `ep_hljs.user_enable` | padToggle checkbox label |
109
+
110
+ Programming language names (`JavaScript`, `Python`, etc.) are intentionally **not** translated — they're proper nouns / fixed identifiers in the hljs grammar registry.
111
+
112
+ ## Accessibility
113
+
114
+ - The toolbar `<select>` exposes `aria-label` (via `data-l10n-attr="aria-label"`).
115
+ - Toggle checkboxes get `<label for="...">` from the padToggle helper.
116
+ - `Ctrl/Alt/Meta + Tab` is **not** intercepted by `codeIndent` — keyboard navigation escape hatch.
117
+ - `Shift+Tab` defers to Etherpad's default when there's no leading whitespace to remove (so list-mode Shift+Tab still works).
118
+ - **Color contrast trade-off:** the light-mode operator color (`#3b82f6` against white) is ~3.7:1 — passes WCAG AA for large/bold text only. The string color (`#1d4ed8`) passes AA at 7:1. Users with low-vision needs should use the dark skin variant which has high-contrast brights (`#79c0ff` etc., > 7:1 on the dark BG).
119
+
120
+ ## Tests
121
+
122
+ ```bash
123
+ # Backend (jsdom + mocha)
124
+ cd etherpad-lite/src && npx cross-env NODE_ENV=production mocha --import=tsx --timeout 30000 \
125
+ $(find plugin_packages -path '*ep_hljs*/static/tests/backend/specs/*.test.js' | tr '\n' ' ')
126
+
127
+ # Frontend (Playwright)
128
+ cd etherpad-lite/src && CI=true pnpm exec playwright test --project=chromium --reporter=line --workers=1 --retries=0 \
129
+ $(find plugin_packages -path '*ep_hljs*/static/tests/frontend-new/specs/*.spec.ts' | tr '\n' ' ')
130
+ ```
131
+
132
+ Backend (38 cases): `lruCache`, `highlightRegistry` (jsdom Range building), `codeIndent` (Enter/Tab/Shift+Tab logic with mocked rep+editorInfo), `hljsAdapter` (parseHljsHtml multi-class + entities), `padLanguageStore`, `socket`, `export`.
133
+
134
+ Frontend (~21 cases, ~3 fixme'd): `caret-stability`, `collaboration`, `content-sync`, `dark-mode` (CSS rule presence), `export`, `initial-paint`, `language-picker`, `large-pad`, `lifecycle`, `multi-user-caret`, `single-line-while`, `code-indent` (mostly fixme — see lesson #10).
135
+
136
+ ## Known issues / follow-ups
137
+
138
+ - Pad-level indent size picker (currently admin-only via `settings.json`).
139
+ - Auto-detect divergence between collaborators is theoretically possible; broadcast-with-jitter pattern designed but not landed (see git stash).
140
+ - `code-indent.spec.ts` Enter/Tab/Shift+Tab Playwright cases are `test.fixme` — manual testing + backend unit tests cover the logic.
141
+ - `caret-stability.spec.ts` and `multi-user-caret.spec.ts` repro 2 are intermittently flaky on the niceSelect dropdown click — UI timing race, not a code bug.