Merge branch 'develop' into session-creation-tests

This commit is contained in:
John McLear 2021-03-05 07:51:46 +00:00
commit d696a048dc
19 changed files with 322 additions and 237 deletions

View file

@ -0,0 +1,83 @@
name: "In-place git pull from master"
# any branch is useful for testing before a PR is submitted
on: [push, pull_request]
jobs:
withpluginsLinux:
# run on pushes to any branch
# run on PRs from external forks
if: |
(github.event_name != 'pull_request')
|| (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)
name: Linux with Plugins
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [10, 12, 14, 15]
steps:
- name: Checkout master repository
uses: actions/checkout@v2
with:
ref: master
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm v7:
# https://github.com/npm/cli/issues/2199
run: >
npm install --no-save --legacy-peer-deps
ep_align
ep_author_hover
ep_cursortrace
ep_font_size
ep_hash_auth
ep_headings2
ep_image_upload
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
# This must be run after installing the plugins, otherwise npm will try to
# hoist common dependencies by removing them from src/node_modules and
# installing them in the top-level node_modules. As of v6.14.10, npm's hoist
# logic appears to be buggy, because it sometimes removes dependencies from
# src/node_modules but fails to add them to the top-level node_modules. Even
# if npm correctly hoists the dependencies, the hoisting seems to confuse
# tools such as `npm outdated`, `npm update`, and some ESLint rules.
- name: Install all dependencies and symlink for ep_etherpad-lite
run: src/bin/installDeps.sh
- name: Run the backend tests
run: cd src && npm test
- name: Git fetch
run: git fetch
- name: Checkout this branch over master
run: git checkout "${GITHUB_SHA}"
- name: Install all dependencies and symlink for ep_etherpad-lite
run: src/bin/installDeps.sh
- name: Run the backend tests
run: cd src && npm test
- name: Install Cypress
run: npm install cypress -g
- name: Run Etherpad & Test Frontend
run: |
node src/node/server.js &
curl --connect-timeout 10 --max-time 20 --retry 5 --retry-delay 10 --retry-max-time 60 --retry-connrefused http://127.0.0.1:9001/p/test
cd src/tests/frontend
cypress run --spec cypress/integration/test.js --config-file cypress/cypress.json

View file

@ -1,11 +1,27 @@
# 1.8.12 # 1.8.12
Special mention: Thanks to Sauce Labs for additional testing tunnels to help us grow! :)
### Security patches
* Fixed a regression in v1.8.11 which caused some pad names to cause Etherpad to restart.
### Notable fixes ### Notable fixes
* Fixed a bug in the `dirty` database driver that sometimes caused Node.js to * Fixed a bug in the `dirty` database driver that sometimes caused Node.js to
crash during shutdown and lose buffered database writes. crash during shutdown and lose buffered database writes.
* Fixed a regression in v1.8.8 that caused "Uncaught TypeError: Cannot read * Fixed a regression in v1.8.8 that caused "Uncaught TypeError: Cannot read
property '0' of undefined" with some plugins (#4885) property '0' of undefined" with some plugins (#4885)
* Less warnings in server console for supported element types on import.
* Support Azure and other network share installations by using a
more truthful relative path.
### Notable enhancements
* Dependency updates
* Various Docker deployment improvements
* Various new translations
* Improvement of rendering of plugin hook list and error message handling
# 1.8.11 # 1.8.11

View file

@ -40,9 +40,20 @@ ENV NODE_ENV=production
# #
# Running as non-root enables running this image in platforms like OpenShift # Running as non-root enables running this image in platforms like OpenShift
# that do not allow images running as root. # that do not allow images running as root.
RUN useradd --uid 5001 --create-home etherpad #
# If any of the following args are set to the empty string, default
# values will be chosen.
ARG EP_HOME=
ARG EP_UID=5001
ARG EP_GID=0
ARG EP_SHELL=
RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \
useradd --system ${EP_UID:+--uid "${EP_UID}" --non-unique} --gid etherpad \
${EP_HOME:+--home-dir "${EP_HOME}"} --create-home \
${EP_SHELL:+--shell "${EP_SHELL}"} etherpad
RUN mkdir /opt/etherpad-lite && chown etherpad:0 /opt/etherpad-lite ARG EP_DIR=/opt/etherpad-lite
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
# install abiword for DOC/PDF/ODT export # install abiword for DOC/PDF/ODT export
RUN [ -z "${INSTALL_ABIWORD}" ] || (apt update && apt -y install abiword && apt clean && rm -rf /var/lib/apt/lists/*) RUN [ -z "${INSTALL_ABIWORD}" ] || (apt update && apt -y install abiword && apt clean && rm -rf /var/lib/apt/lists/*)
@ -53,24 +64,20 @@ RUN [ -z "${INSTALL_SOFFICE}" ] || (apt update && mkdir -p /usr/share/man/man1 &
USER etherpad USER etherpad
WORKDIR /opt/etherpad-lite WORKDIR "${EP_DIR}"
COPY --chown=etherpad:0 ./ ./ COPY --chown=etherpad:etherpad ./ ./
# install node dependencies for Etherpad # install node dependencies for Etherpad
RUN src/bin/installDeps.sh && \ RUN src/bin/installDeps.sh && \
rm -rf ~/.npm/_cacache rm -rf ~/.npm/_cacache
# Install the plugins, if ETHERPAD_PLUGINS is not empty. RUN [ -z "${ETHERPAD_PLUGINS}" ] || npm install ${ETHERPAD_PLUGINS}
#
# Bash trick: in the for loop ${ETHERPAD_PLUGINS} is NOT quoted, in order to be
# able to split at spaces.
RUN for PLUGIN_NAME in ${ETHERPAD_PLUGINS}; do npm install "${PLUGIN_NAME}" || exit 1; done
# Copy the configuration file. # Copy the configuration file.
COPY --chown=etherpad:0 ./settings.json.docker /opt/etherpad-lite/settings.json COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json
# Fix permissions for root group # Fix group permissions
RUN chmod -R g=u . RUN chmod -R g=u .
EXPOSE 9001 EXPOSE 9001

View file

@ -9,10 +9,12 @@
"Upwinxp" "Upwinxp"
] ]
}, },
"admin_plugins.description": "Opis",
"admin_plugins.last-update": "Zadnja posodobitev", "admin_plugins.last-update": "Zadnja posodobitev",
"admin_plugins.name": "Ime", "admin_plugins.name": "Ime",
"admin_plugins.version": "Različica", "admin_plugins.version": "Različica",
"admin_settings": "Nastavitve", "admin_settings": "Nastavitve",
"admin_settings.current_save.value": "Shrani nastavitve",
"index.newPad": "Nov dokument", "index.newPad": "Nov dokument",
"index.createOpenPad": "ali pa ustvari/odpri dokument z imenom:", "index.createOpenPad": "ali pa ustvari/odpri dokument z imenom:",
"pad.toolbar.bold.title": "Krepko (Ctrl-B)", "pad.toolbar.bold.title": "Krepko (Ctrl-B)",

View file

@ -26,8 +26,8 @@ exports.expressCreateServer = (hookName, args, cb) => {
epVersion, epVersion,
installedPlugins: `<pre>${plugins.formatPlugins().replace(/, /g, '\n')}</pre>`, installedPlugins: `<pre>${plugins.formatPlugins().replace(/, /g, '\n')}</pre>`,
installedParts: `<pre>${plugins.formatParts()}</pre>`, installedParts: `<pre>${plugins.formatParts()}</pre>`,
installedServerHooks: `<div>${plugins.formatHooks()}</div>`, installedServerHooks: `<div>${plugins.formatHooks('hooks', true)}</div>`,
installedClientHooks: `<div>${plugins.formatHooks('client_hooks')}</div>`, installedClientHooks: `<div>${plugins.formatHooks('client_hooks', true)}</div>`,
latestVersion: UpdateCheck.getLatestVersion(), latestVersion: UpdateCheck.getLatestVersion(),
req, req,
})); }));

View file

@ -10,12 +10,20 @@ const readOnlyManager = require('../../db/ReadOnlyManager');
hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead'; hooks.deprecationNotices.authFailure = 'use the authnFailure and authzFailure hooks instead';
const staticPathsRE = new RegExp(`^/(?:${[ const staticPathsRE = new RegExp(`^/(?:${[
'api/.*', 'api(?:/.*)?',
'favicon\\.ico', 'favicon\\.ico',
'ep/pad/connection-diagnostic-info',
'javascript',
'javascripts/.*', 'javascripts/.*',
'jserror/?',
'locales\\.json', 'locales\\.json',
'locales/.*',
'rest/.*',
'pluginfw/.*', 'pluginfw/.*',
'robots.txt',
'static/.*', 'static/.*',
'stats/?',
'tests/frontend(?:/.*)?'
].join('|')})$`); ].join('|')})$`);
exports.normalizeAuthzLevel = (level) => { exports.normalizeAuthzLevel = (level) => {

View file

@ -144,7 +144,7 @@ exports.start = async () => {
.join(', '); .join(', ');
logger.info(`Installed plugins: ${installedPlugins}`); logger.info(`Installed plugins: ${installedPlugins}`);
logger.debug(`Installed parts:\n${plugins.formatParts()}`); logger.debug(`Installed parts:\n${plugins.formatParts()}`);
logger.debug(`Installed hooks:\n${plugins.formatHooks()}`); logger.debug(`Installed server-side hooks:\n${plugins.formatHooks('hooks', false)}`);
await hooks.aCallAll('loadSettings', {settings}); await hooks.aCallAll('loadSettings', {settings});
await hooks.aCallAll('createServer'); await hooks.aCallAll('createServer');
} catch (err) { } catch (err) {

View file

@ -18,15 +18,14 @@
const db = require('../db/DB'); const db = require('../db/DB');
const hooks = require('../../static/js/pluginfw/hooks'); const hooks = require('../../static/js/pluginfw/hooks');
const supportedElems = require('../../static/js/contentcollector').supportedElems;
exports.setPadRaw = (padId, r) => { exports.setPadRaw = (padId, r) => {
const records = JSON.parse(r); const records = JSON.parse(r);
const blockElems = ['div', 'br', 'p', 'pre', 'li', 'author', 'lmkr', 'insertorder'];
// get supported block Elements from plugins, we will use this later. // get supported block Elements from plugins, we will use this later.
hooks.callAll('ccRegisterBlockElements').forEach((element) => { hooks.callAll('ccRegisterBlockElements').forEach((element) => {
blockElems.push(element); supportedElems.push(element);
}); });
Object.keys(records).forEach(async (key) => { Object.keys(records).forEach(async (key) => {
@ -65,7 +64,7 @@ exports.setPadRaw = (padId, r) => {
if (value.pool) { if (value.pool) {
for (const attrib of Object.keys(value.pool.numToAttrib)) { for (const attrib of Object.keys(value.pool.numToAttrib)) {
const attribName = value.pool.numToAttrib[attrib][0]; const attribName = value.pool.numToAttrib[attrib][0];
if (blockElems.indexOf(attribName) === -1) { if (supportedElems.indexOf(attribName) === -1) {
console.warn('Plugin missing: ' + console.warn('Plugin missing: ' +
`You might want to install a plugin to support this node name: ${attribName}`); `You might want to install a plugin to support this node name: ${attribName}`);
} }

10
src/package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "ep_etherpad-lite", "name": "ep_etherpad-lite",
"version": "1.8.11", "version": "1.8.12",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -7539,11 +7539,11 @@
} }
}, },
"resolve": { "resolve": {
"version": "1.19.0", "version": "1.20.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
"integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
"requires": { "requires": {
"is-core-module": "^2.1.0", "is-core-module": "^2.2.0",
"path-parse": "^1.0.6" "path-parse": "^1.0.6"
} }
}, },

View file

@ -61,7 +61,7 @@
"rehype": "^10.0.0", "rehype": "^10.0.0",
"rehype-minify-whitespace": "^4.0.5", "rehype-minify-whitespace": "^4.0.5",
"request": "2.88.2", "request": "2.88.2",
"resolve": "1.19.0", "resolve": "1.20.0",
"security": "1.0.0", "security": "1.0.0",
"semver": "5.7.1", "semver": "5.7.1",
"socket.io": "^2.4.1", "socket.io": "^2.4.1",
@ -246,6 +246,6 @@
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs", "test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test-container": "mocha --timeout 5000 tests/container/specs/api" "test-container": "mocha --timeout 5000 tests/container/specs/api"
}, },
"version": "1.8.11", "version": "1.8.12",
"license": "Apache-2.0" "license": "Apache-2.0"
} }

View file

@ -4,8 +4,7 @@
@import url('./lists_and_indents.css'); @import url('./lists_and_indents.css');
html.inner-editor { html.outer-editor, html.inner-editor {
height: auto !important;
background-color: transparent !important; background-color: transparent !important;
} }
#outerdocbody { #outerdocbody {

View file

@ -1,11 +1,12 @@
html, body { html, body {
width: 100%; width: 100%;
height: 100%; height: auto;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
html:not(.inner-editor), html:not(.inner-editor) body { html.pad, html.pad body {
overflow: hidden; overflow: hidden;
height: 100%;
} }
body { body {
display: flex; display: flex;

View file

@ -28,88 +28,19 @@ const hooks = require('./pluginfw/hooks');
const pluginUtils = require('./pluginfw/shared'); const pluginUtils = require('./pluginfw/shared');
const debugLog = (...args) => {}; const debugLog = (...args) => {};
window.debugLog = debugLog;
// The inner and outer iframe's locations are about:blank, so relative URLs are relative to that. // The inner and outer iframe's locations are about:blank, so relative URLs are relative to that.
// Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari // Firefox and Chrome seem to do what the developer intends if given a relative URL, but Safari
// errors out unless given an absolute URL for a JavaScript-created element. // errors out unless given an absolute URL for a JavaScript-created element.
const absUrl = (url) => new URL(url, window.location.href).href; const absUrl = (url) => new URL(url, window.location.href).href;
const eventFired = async (obj, event, cleanups = [], predicate = () => true) => { const scriptTag =
if (typeof cleanups === 'function') { (source) => `<script type="text/javascript">\n${source.replace(/<\//g, '<\\/')}</script>`;
predicate = cleanups;
cleanups = [];
}
await new Promise((resolve, reject) => {
let cleanup;
const successCb = () => {
if (!predicate()) return;
debugLog(`Ace2Editor.init() ${event} event on`, obj);
cleanup();
resolve();
};
const errorCb = () => {
const err = new Error(`Ace2Editor.init() error event while waiting for ${event} event`);
debugLog(`${err} on object`, obj);
cleanup();
reject(err);
};
cleanup = () => {
cleanup = () => {};
obj.removeEventListener(event, successCb);
obj.removeEventListener('error', errorCb);
};
cleanups.push(cleanup);
obj.addEventListener(event, successCb);
obj.addEventListener('error', errorCb);
});
};
const pollCondition = async (predicate, cleanups, pollPeriod, timeout) => {
let done = false;
cleanups.push(() => { done = true; });
// Pause a tick to give the predicate a chance to become true before adding latency.
await new Promise((resolve) => setTimeout(resolve, 0));
const start = Date.now();
while (!done && !predicate()) {
if (Date.now() - start > timeout) throw new Error('timeout');
await new Promise((resolve) => setTimeout(resolve, pollPeriod));
debugLog('Ace2Editor.init() polling');
}
if (!done) debugLog('Ace2Editor.init() poll condition became true');
};
// Resolves when the frame's document is ready to be mutated:
// - Firefox seems to replace the frame's contentWindow.document object with a different object
// after the frame is created so we need to wait for the window's load event before continuing.
// - Chrome doesn't need any waiting (not even next tick), but on Windows it never seems to fire
// any events. Eventually the document's readyState becomes 'complete' (even though it never
// fires a readystatechange event), so this function waits for that to happen to avoid returning
// too soon on Firefox.
// - Safari behaves like Chrome.
// I'm not sure how other browsers behave, so this function throws the kitchen sink at the problem.
// Maybe one day we'll find a concise general solution.
const frameReady = async (frame) => {
// Can't do `const doc = frame.contentDocument;` because Firefox seems to asynchronously replace
// the document object after the frame is first created for some reason. ¯\_(ツ)_/¯
const doc = () => frame.contentDocument;
const cleanups = [];
try {
await Promise.race([
eventFired(frame, 'load', cleanups),
eventFired(frame.contentWindow, 'load', cleanups),
eventFired(doc(), 'load', cleanups),
eventFired(doc(), 'DOMContentLoaded', cleanups),
eventFired(doc(), 'readystatechange', cleanups, () => doc.readyState === 'complete'),
// If all else fails, poll.
pollCondition(() => doc().readyState === 'complete', cleanups, 10, 5000),
]);
} finally {
for (const cleanup of cleanups) cleanup();
}
};
const Ace2Editor = function () { const Ace2Editor = function () {
let info = {editor: this}; let info = {editor: this};
window.ace2EditorInfo = info; // Make it accessible to iframes.
let loaded = false; let loaded = false;
let actionsPendingInit = []; let actionsPendingInit = [];
@ -178,19 +109,16 @@ const Ace2Editor = function () {
// returns array of {error: <browser Error object>, time: +new Date()} // returns array of {error: <browser Error object>, time: +new Date()}
this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : []; this.getUnhandledErrors = () => loaded ? info.ace_getUnhandledErrors() : [];
const addStyleTagsFor = (doc, files) => { const pushStyleTagsFor = (buffer, files) => {
for (const file of files) { for (const file of files) {
const link = doc.createElement('link'); buffer.push(`<link rel="stylesheet" type="text/css" href="${absUrl(encodeURI(file))}"/>`);
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = absUrl(encodeURI(file));
doc.head.appendChild(link);
} }
}; };
this.destroy = pendingInit(() => { this.destroy = pendingInit(() => {
info.ace_dispose(); info.ace_dispose();
info.frame.parentNode.removeChild(info.frame); info.frame.parentNode.removeChild(info.frame);
delete window.ace2EditorInfo;
info = null; // prevent IE 6 closure memory leaks info = null; // prevent IE 6 closure memory leaks
}); });
@ -207,128 +135,109 @@ const Ace2Editor = function () {
`../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`, `../static/skins/${clientVars.skinName}/pad.css?v=${clientVars.randomVersionString}`,
]; ];
const skinVariants = clientVars.skinVariants.split(' ').filter((x) => x !== ''); const doctype = '<!doctype html>';
const outerFrame = document.createElement('iframe'); const iframeHTML = [];
iframeHTML.push(doctype);
iframeHTML.push(`<html class='inner-editor ${clientVars.skinVariants}'><head>`);
pushStyleTagsFor(iframeHTML, includedCSS);
const requireKernelUrl =
absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
iframeHTML.push(`<script type="text/javascript" src="${requireKernelUrl}"></script>`);
// Pre-fetch modules to improve load performance.
for (const module of ['ace2_inner', 'ace2_common']) {
const url = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
`?callback=require.define&v=${clientVars.randomVersionString}`);
iframeHTML.push(`<script type="text/javascript" src="${url}"></script>`);
}
iframeHTML.push(scriptTag(`(async () => {
parent.parent.debugLog('Ace2Editor.init() inner frame ready');
const require = window.require;
require.setRootURI(${JSON.stringify(absUrl('../javascripts/src'))});
require.setLibraryURI(${JSON.stringify(absUrl('../javascripts/lib'))});
require.setGlobalKeyPath('require');
// intentially moved before requiring client_plugins to save a 307
window.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner');
window.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
window.plugins.adoptPluginsFromAncestorsOf(window);
window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
parent.parent.debugLog('Ace2Editor.init() waiting for plugins');
await new Promise((resolve, reject) => window.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));
parent.parent.debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
const editorInfo = parent.parent.ace2EditorInfo;
await new Promise((resolve, reject) => window.Ace2Inner.init(
editorInfo, (err) => err != null ? reject(err) : resolve()));
parent.parent.debugLog('Ace2Editor.init() Ace2Inner.init() returned');
editorInfo.onEditorReady();
})();`));
iframeHTML.push('<style type="text/css" title="dynamicsyntax"></style>');
hooks.callAll('aceInitInnerdocbodyHead', {
iframeHTML,
});
iframeHTML.push('</head><body id="innerdocbody" class="innerdocbody" role="application" ' +
'spellcheck="false">&nbsp;</body></html>');
const outerScript = `(async () => {
await new Promise((resolve) => { window.onload = () => resolve(); });
parent.debugLog('Ace2Editor.init() outer frame ready');
window.onload = null;
await new Promise((resolve) => setTimeout(resolve, 0));
const iframe = document.createElement('iframe');
iframe.name = 'ace_inner';
iframe.title = 'pad';
iframe.scrolling = 'no';
iframe.frameBorder = 0;
iframe.allowTransparency = true; // for IE
iframe.ace_outerWin = window;
document.body.insertBefore(iframe, document.body.firstChild);
const doc = iframe.contentWindow.document;
doc.open();
doc.write(${JSON.stringify(iframeHTML.join('\n'))});
doc.close();
parent.debugLog('Ace2Editor.init() waiting for inner frame');
})();`;
const outerHTML =
[doctype, `<html class="outer-editor outerdoc ${clientVars.skinVariants}"><head>`];
pushStyleTagsFor(outerHTML, includedCSS);
// bizarrely, in FF2, a file with no "external" dependencies won't finish loading properly
// (throbs busy while typing)
const pluginNames = pluginUtils.clientPluginNames();
outerHTML.push(
'<style type="text/css" title="dynamicsyntax"></style>',
scriptTag(outerScript),
'</head>',
'<body id="outerdocbody" class="outerdocbody ', pluginNames.join(' '), '">',
'<div id="sidediv" class="sidediv"><!-- --></div>',
'<div id="linemetricsdiv">x</div>',
'</body></html>');
const outerFrame = document.createElement('IFRAME');
outerFrame.name = 'ace_outer'; outerFrame.name = 'ace_outer';
outerFrame.frameBorder = 0; // for IE outerFrame.frameBorder = 0; // for IE
outerFrame.title = 'Ether'; outerFrame.title = 'Ether';
info.frame = outerFrame; info.frame = outerFrame;
document.getElementById(containerId).appendChild(outerFrame); document.getElementById(containerId).appendChild(outerFrame);
const outerWindow = outerFrame.contentWindow;
// For some unknown reason Firefox replaces outerWindow.document with a new Document object some const editorDocument = outerFrame.contentWindow.document;
// time between running the above code and firing the outerWindow load event. Work around it by
// waiting until the load event fires before mutating the Document object.
debugLog('Ace2Editor.init() waiting for outer frame'); debugLog('Ace2Editor.init() waiting for outer frame');
await frameReady(outerFrame); await new Promise((resolve, reject) => {
debugLog('Ace2Editor.init() outer frame ready'); info.onEditorReady = (err) => err != null ? reject(err) : resolve();
editorDocument.open();
// This must be done after the Window's load event. See above comment. editorDocument.write(outerHTML.join(''));
const outerDocument = outerWindow.document; editorDocument.close();
});
// <html> tag
outerDocument.documentElement.classList.add('inner-editor', 'outerdoc', ...skinVariants);
// <head> tag
addStyleTagsFor(outerDocument, includedCSS);
const outerStyle = outerDocument.createElement('style');
outerStyle.type = 'text/css';
outerStyle.title = 'dynamicsyntax';
outerDocument.head.appendChild(outerStyle);
const link = outerDocument.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = 'data:text/css,';
outerDocument.head.appendChild(link);
// <body> tag
outerDocument.body.id = 'outerdocbody';
outerDocument.body.classList.add('outerdocbody', ...pluginUtils.clientPluginNames());
const sideDiv = outerDocument.createElement('div');
sideDiv.id = 'sidediv';
sideDiv.classList.add('sidediv');
outerDocument.body.appendChild(sideDiv);
const lineMetricsDiv = outerDocument.createElement('div');
lineMetricsDiv.id = 'linemetricsdiv';
lineMetricsDiv.appendChild(outerDocument.createTextNode('x'));
outerDocument.body.appendChild(lineMetricsDiv);
const innerFrame = outerDocument.createElement('iframe');
innerFrame.name = 'ace_inner';
innerFrame.title = 'pad';
innerFrame.scrolling = 'no';
innerFrame.frameBorder = 0;
innerFrame.allowTransparency = true; // for IE
innerFrame.ace_outerWin = outerWindow;
outerDocument.body.insertBefore(innerFrame, outerDocument.body.firstChild);
const innerWindow = innerFrame.contentWindow;
// Wait before mutating the inner document. See above comment recarding outerWindow load.
debugLog('Ace2Editor.init() waiting for inner frame');
await frameReady(innerFrame);
debugLog('Ace2Editor.init() inner frame ready');
// This must be done after the Window's load event. See above comment.
const innerDocument = innerWindow.document;
// <html> tag
innerDocument.documentElement.classList.add('inner-editor', ...skinVariants);
// <head> tag
addStyleTagsFor(innerDocument, includedCSS);
const requireKernel = innerDocument.createElement('script');
requireKernel.type = 'text/javascript';
requireKernel.src =
absUrl(`../static/js/require-kernel.js?v=${clientVars.randomVersionString}`);
innerDocument.head.appendChild(requireKernel);
// Pre-fetch modules to improve load performance.
for (const module of ['ace2_inner', 'ace2_common']) {
const script = innerDocument.createElement('script');
script.type = 'text/javascript';
script.src = absUrl(`../javascripts/lib/ep_etherpad-lite/static/js/${module}.js` +
`?callback=require.define&v=${clientVars.randomVersionString}`);
innerDocument.head.appendChild(script);
}
const innerStyle = innerDocument.createElement('style');
innerStyle.type = 'text/css';
innerStyle.title = 'dynamicsyntax';
innerDocument.head.appendChild(innerStyle);
const headLines = [];
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
const tmp = innerDocument.createElement('div');
tmp.innerHTML = headLines.join('\n');
while (tmp.firstChild) innerDocument.head.appendChild(tmp.firstChild);
// <body> tag
innerDocument.body.id = 'innerdocbody';
innerDocument.body.classList.add('innerdocbody');
innerDocument.body.setAttribute('role', 'application');
innerDocument.body.setAttribute('spellcheck', 'false');
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;
debugLog('Ace2Editor.init() waiting for require kernel load');
await eventFired(requireKernel, 'load');
debugLog('Ace2Editor.init() require kernel loaded');
const require = innerWindow.require;
require.setRootURI(absUrl('../javascripts/src'));
require.setLibraryURI(absUrl('../javascripts/lib'));
require.setGlobalKeyPath('require');
// intentially moved before requiring client_plugins to save a 307
innerWindow.Ace2Inner = require('ep_etherpad-lite/static/js/ace2_inner');
innerWindow.plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins');
innerWindow.plugins.adoptPluginsFromAncestorsOf(innerWindow);
innerWindow.$ = innerWindow.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
debugLog('Ace2Editor.init() waiting for plugins');
await new Promise((resolve, reject) => innerWindow.plugins.ensure(
(err) => err != null ? reject(err) : resolve()));
debugLog('Ace2Editor.init() waiting for Ace2Inner.init()');
await new Promise((resolve, reject) => innerWindow.Ace2Inner.init(
info, (err) => err != null ? reject(err) : resolve()));
debugLog('Ace2Editor.init() Ace2Inner.init() returned');
loaded = true; loaded = true;
doActionsPendingInit(); doActionsPendingInit();
debugLog('Ace2Editor.init() done'); debugLog('Ace2Editor.init() done');

View file

@ -55,6 +55,28 @@ const getAttribute = (n, a) => {
if (n.attribs != null) return n.attribs[a]; if (n.attribs != null) return n.attribs[a];
return null; return null;
}; };
// supportedElems are Supported natively within Etherpad and don't require a plugin
const supportedElems = [
'author',
'b',
'bold',
'br',
'div',
'font',
'i',
'insertorder',
'italic',
'li',
'lmkr',
'ol',
'p',
'pre',
'strong',
's',
'span',
'u',
'ul',
];
const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => { const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => {
const _blockElems = { const _blockElems = {
@ -66,6 +88,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
hooks.callAll('ccRegisterBlockElements').forEach((element) => { hooks.callAll('ccRegisterBlockElements').forEach((element) => {
_blockElems[element] = 1; _blockElems[element] = 1;
supportedElems.push(element);
}); });
const isBlockElement = (n) => !!_blockElems[tagName(n) || '']; const isBlockElement = (n) => !!_blockElems[tagName(n) || ''];
@ -315,9 +338,11 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
const localAttribs = state.localAttribs; const localAttribs = state.localAttribs;
state.localAttribs = null; state.localAttribs = null;
const isBlock = isBlockElement(node); const isBlock = isBlockElement(node);
if (!isBlock && node.name && (node.name !== 'body') && (node.name !== 'br')) { if (!isBlock && node.name && (node.name !== 'body')) {
console.warn('Plugin missing: ' + if (supportedElems.indexOf(node.name) === -1) {
console.warn('Plugin missing: ' +
`You might want to install a plugin to support this node name: ${node.name}`); `You might want to install a plugin to support this node name: ${node.name}`);
}
} }
const isEmpty = _isEmpty(node, state); const isEmpty = _isEmpty(node, state);
if (isBlock) _ensureColumnZero(state); if (isBlock) _ensureColumnZero(state);
@ -701,3 +726,4 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
exports.sanitizeUnicode = sanitizeUnicode; exports.sanitizeUnicode = sanitizeUnicode;
exports.makeContentCollector = makeContentCollector; exports.makeContentCollector = makeContentCollector;
exports.supportedElems = supportedElems;

View file

@ -28,16 +28,48 @@ exports.formatPlugins = () => Object.keys(defs.plugins).join(', ');
exports.formatParts = () => defs.parts.map((part) => part.full_name).join('\n'); exports.formatParts = () => defs.parts.map((part) => part.full_name).join('\n');
exports.formatHooks = (hookSetName) => { exports.formatHooks = (hookSetName, html) => {
const res = []; let hooks = new Map();
const hooks = pluginUtils.extractHooks(defs.parts, hookSetName || 'hooks'); for (const [pluginName, def] of Object.entries(defs.plugins)) {
for (const registeredHooks of Object.values(hooks)) { for (const part of def.parts) {
for (const hook of registeredHooks) { for (const [hookName, hookFnName] of Object.entries(part[hookSetName] || {})) {
res.push(`<dt>${hook.hook_name}</dt><dd>${hook.hook_fn_name} ` + let hookEntry = hooks.get(hookName);
`from ${hook.part.full_name}</dd>`); if (!hookEntry) {
hookEntry = new Map();
hooks.set(hookName, hookEntry);
}
let pluginEntry = hookEntry.get(pluginName);
if (!pluginEntry) {
pluginEntry = new Map();
hookEntry.set(pluginName, pluginEntry);
}
pluginEntry.set(part.name, hookFnName);
}
} }
} }
return `<dl>${res.join('\n')}</dl>`; const lines = [];
const sortStringKeys = (a, b) => String(a[0]).localeCompare(b[0]);
if (html) lines.push('<dl>');
hooks = new Map([...hooks].sort(sortStringKeys));
for (const [hookName, hookEntry] of hooks) {
lines.push(html ? ` <dt>${hookName}:</dt><dd><dl>` : ` ${hookName}:`);
const sortedHookEntry = new Map([...hookEntry].sort(sortStringKeys));
hooks.set(hookName, sortedHookEntry);
for (const [pluginName, pluginEntry] of sortedHookEntry) {
lines.push(html ? ` <dt>${pluginName}:</dt><dd><dl>` : ` ${pluginName}:`);
const sortedPluginEntry = new Map([...pluginEntry].sort(sortStringKeys));
sortedHookEntry.set(pluginName, sortedPluginEntry);
for (const [partName, hookFnName] of sortedPluginEntry) {
lines.push(html
? ` <dt>${partName}:</dt><dd>${hookFnName}</dd>`
: ` ${partName}: ${hookFnName}`);
}
if (html) lines.push(' </dl></dd>');
}
if (html) lines.push(' </dl></dd>');
}
if (html) lines.push('</dl>');
return lines.join('\n');
}; };
const callInit = async () => { const callInit = async () => {

View file

@ -55,9 +55,10 @@ const extractHooks = (parts, hookSetName, normalizer) => {
try { try {
hookFn = loadFn(hookFnName, hookName); hookFn = loadFn(hookFnName, hookName);
if (!hookFn) throw new Error('Not a function'); if (!hookFn) throw new Error('Not a function');
} catch (exc) { } catch (err) {
console.error(`Failed to load '${hookFnName}' for ` + console.error(`Failed to load hook function "${hookFnName}" for plugin "${part.plugin}" ` +
`'${part.full_name}/${hookSetName}/${hookName}': ${exc.toString()}`); `part "${part.name}" hook set "${hookSetName}" hook "${hookName}": ` +
`${err.stack || err}`);
} }
if (hookFn) { if (hookFn) {
if (hooks[hookName] == null) hooks[hookName] = []; if (hooks[hookName] == null) hooks[hookName] = [];

View file

@ -0,0 +1 @@
/* intentionally empty */

View file

@ -7,7 +7,7 @@
<!doctype html> <!doctype html>
<% e.begin_block("htmlHead"); %> <% e.begin_block("htmlHead"); %>
<html class="<%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>"> <html class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
<% e.end_block(); %> <% e.end_block(); %>
<title><%=settings.title%></title> <title><%=settings.title%></title>

View file

@ -10,6 +10,7 @@ Cypress.Commands.add('iframe', {prevSubject: 'element'},
describe(__filename, () => { describe(__filename, () => {
it('Pad content exists', () => { it('Pad content exists', () => {
cy.visit('http://127.0.0.1:9001/p/test'); cy.visit('http://127.0.0.1:9001/p/test');
cy.wait(10000); // wait for Minified JS to be built...
cy.get('iframe[name="ace_outer"]', {timeout: 10000}).iframe() cy.get('iframe[name="ace_outer"]', {timeout: 10000}).iframe()
.find('.line-number:first') .find('.line-number:first')
.should('have.text', '1'); .should('have.text', '1');