Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/js/crowdin.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ function _getTrailingWhitespace(text) {
* trailingWhitespace: string,
* previousInline: Element|null,
* nextInline: Element|null,
* whitespaceOnly: boolean
* whitespaceOnly: boolean,
* lastRestoredNode: Text|null,
* lastRestoredFrom: string|null
* }>} Recorded text-node boundaries.
*/
function _captureCrowdinWhitespaceBoundaries() {
Expand All @@ -102,6 +104,8 @@ function _captureCrowdinWhitespaceBoundaries() {
previousInline,
nextInline,
whitespaceOnly: node.data.trim() === '',
lastRestoredNode: null,
lastRestoredFrom: null,
});
}

Expand Down Expand Up @@ -152,6 +156,9 @@ function _restoreCrowdinWhitespaceBoundaries(boundaries) {
boundaries.forEach((boundary) => {
const node = _resolveCrowdinWhitespaceNode(boundary);
if (node === null) return;
if (boundary.lastRestoredNode === node && boundary.lastRestoredFrom === node.data) return;

const originalText = node.data;

const currentLeadingWhitespace = _getLeadingWhitespace(node.data);
if (boundary.leading && currentLeadingWhitespace !== boundary.leadingWhitespace) {
Expand All @@ -162,6 +169,12 @@ function _restoreCrowdinWhitespaceBoundaries(boundaries) {
const translatedTextEnd = node.data.length - currentTrailingWhitespace.length;
node.data = node.data.slice(0, translatedTextEnd) + boundary.trailingWhitespace;
}
if (node.data !== originalText) {
// A translator may observe this correction and remove the same
// whitespace again. Do not repeat the correction indefinitely.
boundary.lastRestoredNode = node;
boundary.lastRestoredFrom = originalText;
}
});
}

Expand Down Expand Up @@ -282,7 +295,9 @@ function initCrowdIn(project = 'LizardByte', platform = 'jekyll') {
// before the script is even loaded so every fetch() it makes is intercepted.
_installCrowdinFetchInterceptor();

loadScript('https://website-translator.app.crowdin.net/assets/proxy-translator.js', function() {
function initializeCrowdin(error) {
if (error) return;

// Configure base settings based on project
const projectSettings = {
'LizardByte': {
Expand Down Expand Up @@ -343,7 +358,21 @@ function initCrowdIn(project = 'LizardByte', platform = 'jekyll') {

// Apply styling based on UI framework
_applyCrowdinPlatformStyling(platform);
});
}

function loadCrowdinScript() {
loadScript('https://website-translator.app.crowdin.net/assets/proxy-translator.js', initializeCrowdin);
}

// A pending third-party script delays the browser's load event. Let the
// documentation finish loading before requesting Crowdin.
if (document.readyState === 'complete') {
loadCrowdinScript();
} else {
globalThis.addEventListener('load', function() {
globalThis.setTimeout(loadCrowdinScript, 0);
}, { once: true });
}
}

// Expose to the global scope
Expand Down
62 changes: 60 additions & 2 deletions tests/crowdin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import {

// We need to mock the module BEFORE importing the module that uses it
jest.mock('../src/js/load-script', () => {
return function(url, callback) {
return jest.fn(function(url, callback) {
if (callback) setTimeout(callback, 0);
return true;
};
});
});

const loadScript = require('../src/js/load-script');
const initCrowdIn = require('../src/js/crowdin');

const delayedPickerMarkup = `
Expand Down Expand Up @@ -71,6 +72,37 @@ describe('initCrowdIn', () => {
);
});

it('should request Crowdin only after the page load event', () => {
const readyState = jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading');

try {
initCrowdIn();
expect(loadScript).not.toHaveBeenCalled();

globalThis.dispatchEvent(new Event('load'));
expect(loadScript).not.toHaveBeenCalled();

jest.advanceTimersByTime(0);
expect(loadScript).toHaveBeenCalledTimes(1);
expect(loadScript).toHaveBeenCalledWith(
'https://website-translator.app.crowdin.net/assets/proxy-translator.js',
expect.any(Function)
);
jest.runAllTimers();
expect(globalThis.proxyTranslator.init).toHaveBeenCalled();
} finally {
readyState.mockRestore();
}
});

it('should leave the docs usable if the Crowdin script fails', () => {
loadScript.mockImplementationOnce((url, callback) => callback(new Error('Crowdin unavailable')));

initCrowdIn();

expect(globalThis.proxyTranslator.init).not.toHaveBeenCalled();
});

it('should initialize proxyTranslator with LizardByte settings', () => {
initCrowdIn();

Expand Down Expand Up @@ -190,6 +222,32 @@ describe('initCrowdIn', () => {
expect(translated.textContent).toBe('Use this link here.');
});

it('should not fight another observer over translated whitespace', async () => {
globalThis.document.body.innerHTML = '<p>Use <a href="#">this link</a> here.</p>';

initCrowdIn();
jest.runAllTimers();

const options = globalThis.proxyTranslator.init.mock.calls[0][0];
const text = document.querySelector('a').previousSibling;
options.callback();

let rewrites = 0;
const translatorObserver = new MutationObserver(() => {
if (text.data.endsWith(' ') && rewrites < 10) {
rewrites++;
text.data = text.data.trimEnd();
}
});
translatorObserver.observe(document.body, { characterData: true, subtree: true });

text.data = text.data.trimEnd();
for (let i = 0; i < 10; i++) await Promise.resolve();
translatorObserver.disconnect();

expect(rewrites).toBe(1);
});

it('should initialize proxyTranslator with LizardByte-docs settings', () => {
initCrowdIn('LizardByte-docs');

Expand Down
Loading