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
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const syntaxRenderer = require('ep_hljs/static/js/syntaxRenderer');
4
+ const codeIndent = require('ep_hljs/static/js/codeIndent');
5
+ const themeBridge = require('ep_hljs/static/js/themeBridge');
6
+ const socketio = require('ep_etherpad-lite/static/js/socketio');
7
+ // Sub-path import keeps the client bundle clean.
8
+ const {padToggle} = require('ep_plugin_helpers/pad-toggle');
9
+ const {padSelect} = require('ep_plugin_helpers/pad-select');
10
+
11
+ let socket = null;
12
+ let currentPadId = null;
13
+
14
+ const highlightToggle = padToggle({
15
+ pluginName: 'ep_hljs',
16
+ settingId: 'syntax-highlighting',
17
+ l10nId: 'ep_hljs.user_enable',
18
+ defaultLabel: 'Highlight syntax in pads',
19
+ defaultEnabled: true,
20
+ });
21
+
22
+ const indentSelect = padSelect({
23
+ pluginName: 'ep_hljs',
24
+ settingId: 'indent-size',
25
+ l10nId: 'ep_hljs.indent_size',
26
+ defaultLabel: 'Indent size',
27
+ options: [
28
+ {value: 2, label: '2 spaces'},
29
+ {value: 4, label: '4 spaces'},
30
+ ],
31
+ defaultValue: 2,
32
+ });
33
+
34
+ // Both helpers re-export handleClientMessage_CLIENT_MESSAGE so each can refresh
35
+ // its own UI on padoptions broadcasts; chain both invocations.
36
+ exports.handleClientMessage_CLIENT_MESSAGE = (hookName, ctx) => {
37
+ highlightToggle.handleClientMessage_CLIENT_MESSAGE(hookName, ctx);
38
+ indentSelect.handleClientMessage_CLIENT_MESSAGE(hookName, ctx);
39
+ };
40
+
41
+ const loadHljs = () => {
42
+ if (typeof window !== 'undefined' && window.hljs) return Promise.resolve();
43
+ return new Promise((resolve) => {
44
+ const script = document.createElement('script');
45
+ script.src = '/static/plugins/ep_hljs/static/js/vendor/hljs.min.js';
46
+ script.onload = resolve;
47
+ script.onerror = resolve;
48
+ document.head.appendChild(script);
49
+ });
50
+ };
51
+
52
+ const onLanguageChanged = (msg) => {
53
+ const sel = document.getElementById('ep_hljs_select');
54
+ if (sel) {
55
+ const newVal = msg.autoDetect ? 'auto' : msg.language;
56
+ sel.value = newVal;
57
+ const $ = window.$;
58
+ if ($ && $.fn && $.fn.niceSelect) $(sel).niceSelect('update');
59
+ }
60
+ syntaxRenderer.setState({language: msg.language, autoDetect: !!msg.autoDetect});
61
+ };
62
+
63
+ exports.postAceInit = async (hookName, context) => {
64
+ await loadHljs();
65
+ currentPadId = context.pad.getPadId();
66
+ const initial = (typeof clientVars !== 'undefined' && clientVars.ep_hljs) ||
67
+ {language: 'auto', autoDetect: true};
68
+
69
+ const pad = require('ep_etherpad-lite/static/js/pad');
70
+ socket = socketio.connect(pad.baseURL || '/', '/syntax-highlighting');
71
+ socket.on('connect', () => socket.emit('joinPad', {padId: currentPadId}));
72
+ socket.on('languageChanged', onLanguageChanged);
73
+ socket.on('languageChangeRejected', (reason) => {
74
+ console.warn('[ep_hljs] language change rejected:', reason && reason.error);
75
+ });
76
+
77
+ // Reflect initial language in the dropdown without dispatching a change event.
78
+ const sel = document.getElementById('ep_hljs_select');
79
+ if (sel) {
80
+ sel.value = initial.autoDetect ? 'auto' : initial.language;
81
+ const $ = window.$;
82
+ if ($ && $.fn && $.fn.niceSelect) $(sel).niceSelect('update');
83
+ const handler = () => {
84
+ const val = sel.value;
85
+ const payload = val === 'auto'
86
+ ? {padId: currentPadId, language: 'auto', autoDetect: true}
87
+ : {padId: currentPadId, language: val, autoDetect: false};
88
+ socket.emit('setLanguage', payload);
89
+ };
90
+ if ($ && $.fn) $(sel).on('change', handler);
91
+ else sel.addEventListener('change', handler);
92
+ }
93
+
94
+ syntaxRenderer.start(context, initial);
95
+ codeIndent.start({
96
+ indentSize: 2, // padSelect.init() fires onChange synchronously below with the effective value
97
+ getLanguage: () => syntaxRenderer.getState().language,
98
+ getAutoDetect: () => syntaxRenderer.getState().autoDetect,
99
+ });
100
+ themeBridge.start();
101
+
102
+ // padToggle drives per-user / pad-wide enable. init() binds the checkboxes
103
+ // (without it, the checkboxes render unchecked even when defaultEnabled is
104
+ // true — the helper renders empty <input type="checkbox"> server-side and
105
+ // relies on init() to set the correct state from cookie/pad option/default).
106
+ highlightToggle.init({
107
+ onChange: (enabled) => syntaxRenderer.setUserEnabled(enabled),
108
+ });
109
+
110
+ // padSelect drives the indent-size dropdown. init() fires onChange once with
111
+ // the effective initial value (cookie / pad option / default), and again
112
+ // whenever the user changes the dropdown OR a remote padoptions broadcast
113
+ // arrives via handleClientMessage_CLIENT_MESSAGE.
114
+ indentSelect.init({
115
+ onChange: (size) => codeIndent.setIndentSize(size),
116
+ });
117
+ };
118
+
119
+ exports.acePostWriteDomLineHTML = syntaxRenderer.acePostWriteDomLineHTML;
120
+ exports.aceKeyEvent = codeIndent.handleKey;
121
+
122
+ exports.aceEditorCSS = () => [
123
+ 'ep_hljs/static/css/editor.css',
124
+ ];
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ class LRU {
4
+ constructor(capacity) {
5
+ this.capacity = capacity;
6
+ this.map = new Map();
7
+ }
8
+ get size() { return this.map.size; }
9
+ has(key) { return this.map.has(key); }
10
+ get(key) {
11
+ if (!this.map.has(key)) return undefined;
12
+ const value = this.map.get(key);
13
+ this.map.delete(key);
14
+ this.map.set(key, value);
15
+ return value;
16
+ }
17
+ set(key, value) {
18
+ if (this.map.has(key)) this.map.delete(key);
19
+ this.map.set(key, value);
20
+ while (this.map.size > this.capacity) {
21
+ const oldest = this.map.keys().next().value;
22
+ this.map.delete(oldest);
23
+ }
24
+ }
25
+ clear() { this.map.clear(); }
26
+ }
27
+
28
+ module.exports = LRU;
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ const LRU = require('./lruCache');
4
+ const {setLineRanges, clearAll} = require('./highlightRegistry');
5
+ const {tokenize, detect} = require('./hljsAdapter');
6
+ const {MAX_LINES, AUTO_REDETECT_MS, LRU_CAPACITY} = require('./constants');
7
+
8
+ const cache = new LRU(LRU_CAPACITY);
9
+ let state = {language: 'auto', autoDetect: true};
10
+ let userEnabled = true;
11
+ let paused = false;
12
+ let aceContext = null;
13
+ let autoDetectTimer = null;
14
+ let lastDetectAt = 0;
15
+
16
+ const isHighlightingEnabled = () => {
17
+ if (paused || !userEnabled) return false;
18
+ if (!state.language || state.language === 'off') return false;
19
+ if (state.language === 'auto' && !state.autoDetect) return false;
20
+ return true;
21
+ };
22
+
23
+ const getInnerDoc = () => {
24
+ const outer = document.getElementsByName('ace_outer')[0];
25
+ if (!outer) return null;
26
+ const outerDoc = outer.contentWindow && outer.contentWindow.document;
27
+ if (!outerDoc) return null;
28
+ const inner = outerDoc.getElementsByName('ace_inner')[0];
29
+ if (!inner) return null;
30
+ return inner.contentWindow && inner.contentWindow.document;
31
+ };
32
+
33
+ const renderLine = (node) => {
34
+ if (!node) return;
35
+ if (!isHighlightingEnabled() || state.language === 'auto') {
36
+ setLineRanges(node, []);
37
+ return;
38
+ }
39
+ const text = node.textContent;
40
+ if (!text) {
41
+ setLineRanges(node, []);
42
+ return;
43
+ }
44
+ const key = `${state.language}:${text}`;
45
+ let ranges = cache.get(key);
46
+ if (ranges === undefined) {
47
+ ranges = tokenize(text, state.language);
48
+ if (ranges == null) {
49
+ // hljs not yet loaded. Don't cache, don't strip existing highlights —
50
+ // a later render (or MutationObserver tick) will retry once hljs is in.
51
+ return;
52
+ }
53
+ cache.set(key, ranges);
54
+ }
55
+ setLineRanges(node, ranges);
56
+ };
57
+
58
+ const repaintAllLines = () => {
59
+ const innerDoc = getInnerDoc();
60
+ if (!innerDoc) return;
61
+ innerDoc.querySelectorAll('div[id^="magicdomid"]').forEach(renderLine);
62
+ };
63
+
64
+ const padText = () => {
65
+ if (!aceContext) return '';
66
+ let result = '';
67
+ try {
68
+ aceContext.ace.callWithAce((ace) => {
69
+ result = ace.ace_exportText();
70
+ }, 'syntax-read');
71
+ } catch (_e) { /* ignore */ }
72
+ return result;
73
+ };
74
+
75
+ const lineCount = () => {
76
+ const innerDoc = getInnerDoc();
77
+ if (!innerDoc) return 0;
78
+ return innerDoc.querySelectorAll('div[id^="magicdomid"]').length;
79
+ };
80
+
81
+ const tickAutoRedetect = () => {
82
+ if (!state.autoDetect) return;
83
+ if (Date.now() - lastDetectAt < AUTO_REDETECT_MS) return;
84
+ lastDetectAt = Date.now();
85
+ const text = padText();
86
+ if (!text) return;
87
+ const detected = detect(text);
88
+ if (!detected || detected === state.language) return;
89
+ state = {language: detected, autoDetect: true};
90
+ cache.clear();
91
+ clearAll();
92
+ repaintAllLines();
93
+ };
94
+
95
+ const checkPaused = () => {
96
+ const n = lineCount();
97
+ const wasPaused = paused;
98
+ paused = n > MAX_LINES;
99
+ return wasPaused !== paused;
100
+ };
101
+
102
+ // Toggle a marker class on the inner iframe's body when this plugin is
103
+ // actively painting. Lets editor.css scope plugin-specific CSS rules (e.g.
104
+ // the authorship-bg suppression) so they only apply to pads with
105
+ // highlighting on — keeps core Etherpad tests on plain pads unaffected.
106
+ const updateActiveClass = () => {
107
+ const innerDoc = getInnerDoc();
108
+ if (!innerDoc || !innerDoc.body) return;
109
+ const active = isHighlightingEnabled() && state.language && state.language !== 'auto';
110
+ innerDoc.body.classList.toggle('ep-syntax-highlighting-active', !!active);
111
+ };
112
+
113
+ exports.start = (ctx, initialState) => {
114
+ aceContext = ctx;
115
+ state = {...state, ...(initialState || {})};
116
+ setTimeout(tickAutoRedetect, 500);
117
+ if (autoDetectTimer) clearInterval(autoDetectTimer);
118
+ autoDetectTimer = setInterval(tickAutoRedetect, 1000);
119
+ startMutationObserver(); // eslint-disable-line no-use-before-define
120
+ // The initial line renders fire BEFORE postAceInit completes (i.e. before
121
+ // we know the language and before hljs is loaded), so the
122
+ // acePostWriteDomLineHTML hook short-circuits and leaves them un-tokenized.
123
+ // Repaint once now that state and hljs are ready. Small timeout so the inner
124
+ // iframe is fully populated.
125
+ setTimeout(() => { repaintAllLines(); updateActiveClass(); }, 100);
126
+ };
127
+
128
+ exports.setState = (next) => {
129
+ state = {...state, ...next};
130
+ cache.clear();
131
+ clearAll();
132
+ repaintAllLines();
133
+ updateActiveClass();
134
+ };
135
+
136
+ exports.setUserEnabled = (enabled) => {
137
+ if (enabled === userEnabled) return;
138
+ userEnabled = !!enabled;
139
+ clearAll();
140
+ repaintAllLines();
141
+ };
142
+
143
+ exports.acePostWriteDomLineHTML = (hookName, context) => {
144
+ if (checkPaused() && paused) clearAll();
145
+ if (paused) return;
146
+ renderLine(context.node);
147
+ };
148
+
149
+ // Etherpad does incremental DOM updates on typing — the acePostWriteDomLineHTML
150
+ // hook only fires on FULL line re-renders (paste, language change, line split).
151
+ // To catch every text mutation (typing, remote changesets, IME composition,
152
+ // undo/redo) we observe the inner doc for character-level changes and
153
+ // re-render only the affected line divs. Since CSS Custom Highlights does
154
+ // not mutate the DOM, our setLineRanges calls don't trigger this observer.
155
+ let mutationObserver = null;
156
+
157
+ const startMutationObserver = () => {
158
+ const innerDoc = getInnerDoc();
159
+ if (!innerDoc || !innerDoc.body) {
160
+ setTimeout(startMutationObserver, 100);
161
+ return;
162
+ }
163
+ if (mutationObserver) return;
164
+ const win = innerDoc.defaultView;
165
+ if (!win || !win.MutationObserver) return;
166
+ const findLineAncestor = (node, dirtyLines) => {
167
+ let n = node;
168
+ while (n && n !== innerDoc.body) {
169
+ if (n.nodeType === 1 && n.id && n.id.startsWith('magicdomid')) {
170
+ dirtyLines.add(n);
171
+ return;
172
+ }
173
+ n = n.parentNode;
174
+ }
175
+ };
176
+ mutationObserver = new win.MutationObserver((mutations) => {
177
+ if (paused) return;
178
+ const dirtyLines = new Set();
179
+ for (const m of mutations) {
180
+ // characterData: m.target is the text node — walk up to its line div.
181
+ // childList: m.target is the parent of changed children. If
182
+ // Etherpad replaces a whole line div, m.target is
183
+ // innerdocbody and the new line is in addedNodes.
184
+ findLineAncestor(m.target, dirtyLines);
185
+ if (m.addedNodes) for (const n of m.addedNodes) findLineAncestor(n, dirtyLines);
186
+ }
187
+ for (const line of dirtyLines) renderLine(line);
188
+ });
189
+ mutationObserver.observe(innerDoc.body, {
190
+ childList: true,
191
+ subtree: true,
192
+ characterData: true,
193
+ });
194
+ };
195
+
196
+ exports.getState = () => ({...state});
197
+
198
+ exports.__test_internal = { // eslint-disable-line camelcase
199
+ cache,
200
+ getState: () => ({...state}),
201
+ };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const LIGHT_FILE = 'github.css';
4
+ const DARK_FILE = 'github-dark.css';
5
+ const PATH_MARKER = '/static/plugins/ep_hljs/static/css/themes/';
6
+
7
+ let listenersAttached = false;
8
+
9
+ const resolveDark = () => {
10
+ // Etherpad signals dark mode via the html element's `super-dark-editor`
11
+ // class (or `dark-editor` for medium dark). Fall back to legacy
12
+ // class names and prefers-color-scheme for environments outside Etherpad.
13
+ const html = document.documentElement;
14
+ if (html.classList.contains('super-dark-editor')) return true;
15
+ if (html.classList.contains('dark-editor')) return true;
16
+ if (html.classList.contains('darkMode')) return true;
17
+ if (document.body && document.body.dataset && document.body.dataset.theme === 'dark') return true;
18
+ return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
19
+ };
20
+
21
+ const collectThemeLinks = () => {
22
+ const links = [];
23
+ document
24
+ .querySelectorAll(`link[href*="${PATH_MARKER}"]`)
25
+ .forEach((l) => links.push(l));
26
+ // ace_getDocument() returns the outer pad document (ace2_inner.ts runs in the outer bundle).
27
+ // The ace_inner iframe's document is where the theme CSS links are injected.
28
+ try {
29
+ const outerIframe = document.getElementsByName('ace_outer')[0];
30
+ const outerDoc = outerIframe && outerIframe.contentWindow && outerIframe.contentWindow.document;
31
+ const innerIframe = outerDoc && outerDoc.getElementsByName('ace_inner')[0];
32
+ const innerDoc = innerIframe && innerIframe.contentWindow && innerIframe.contentWindow.document;
33
+ if (innerDoc) {
34
+ innerDoc
35
+ .querySelectorAll(`link[href*="${PATH_MARKER}"]`)
36
+ .forEach((l) => links.push(l));
37
+ }
38
+ } catch (_e) { /* cross-origin or not loaded yet */ }
39
+ return links;
40
+ };
41
+
42
+ const swap = () => {
43
+ const targetFile = resolveDark() ? DARK_FILE : LIGHT_FILE;
44
+ const otherFile = resolveDark() ? LIGHT_FILE : DARK_FILE;
45
+ collectThemeLinks().forEach((link) => {
46
+ if (link.href.includes(PATH_MARKER + otherFile)) {
47
+ link.href = link.href.replace(PATH_MARKER + otherFile, PATH_MARKER + targetFile);
48
+ }
49
+ });
50
+ };
51
+
52
+ exports.start = () => {
53
+ swap();
54
+ if (!listenersAttached) {
55
+ if (window.matchMedia) {
56
+ try {
57
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', swap);
58
+ } catch (_e) {
59
+ // older Safari fallback
60
+ window.matchMedia('(prefers-color-scheme: dark)').addListener(swap);
61
+ }
62
+ }
63
+ new MutationObserver(swap).observe(document.documentElement, {
64
+ attributes: true,
65
+ attributeFilter: ['class', 'data-theme'],
66
+ });
67
+ listenersAttached = true;
68
+ }
69
+ // The inner ace iframe may finish loading the link tag AFTER start(); re-run
70
+ // a few times shortly after start so the swap catches a late insertion.
71
+ setTimeout(swap, 250);
72
+ setTimeout(swap, 1000);
73
+ setTimeout(swap, 3000);
74
+ };
75
+
76
+ exports.swap = swap; // exposed for tests / manual triggering