diff --git a/src/js/crowdin.js b/src/js/crowdin.js index dd83937..901ffad 100644 --- a/src/js/crowdin.js +++ b/src/js/crowdin.js @@ -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() { @@ -102,6 +104,8 @@ function _captureCrowdinWhitespaceBoundaries() { previousInline, nextInline, whitespaceOnly: node.data.trim() === '', + lastRestoredNode: null, + lastRestoredFrom: null, }); } @@ -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) { @@ -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; + } }); } @@ -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': { @@ -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 diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index 03333dd..0be2f4d 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -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 = ` @@ -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(); @@ -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 = '
Use this link here.
'; + + 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');