From 6532a22654162edf6ad952c183093b37e9320021 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:11:03 -0400 Subject: [PATCH] fix(Crowdin): whitespace loss after translation Preserves spacing around inline elements after Crowdin translation by capturing text-node boundaries, restoring them in a translator callback, and observing later DOM mutations for async updates. Also updates tests to cover supported translator options and whitespace restoration edge cases. For docs examples, Jekyll now copies shared-web dist assets into the build output and references them via /assets/shared-web, while Sphinx loads shared-web assets from node_modules and narrows rstcheck linting to the source directory. --- examples/jekyll/_config.yml | 4 +- examples/jekyll/build.js | 17 ++++ examples/jekyll/package.json | 4 +- examples/sphinx/package.json | 2 +- examples/sphinx/source/conf.py | 9 ++- src/js/crowdin.js | 140 +++++++++++++++++++++++++++++++++ tests/crowdin.test.js | 84 ++++++++++++++++++++ 7 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 examples/jekyll/build.js diff --git a/examples/jekyll/_config.yml b/examples/jekyll/_config.yml index 7d48163..dc79c47 100644 --- a/examples/jekyll/_config.yml +++ b/examples/jekyll/_config.yml @@ -24,9 +24,9 @@ page-col: "#303436" text-col: "#e4e4e4" mobile-theme-col: "#05FF3B" site-css: - - "../dist/crowdin-bootstrap-css.css" + - "/assets/shared-web/crowdin-bootstrap-css.css" site-js: - - "../dist/crowdin.js" + - "/assets/shared-web/crowdin.js" - "/assets/js/crowdin-init.js" # Advanced settings diff --git a/examples/jekyll/build.js b/examples/jekyll/build.js new file mode 100644 index 0000000..5865de6 --- /dev/null +++ b/examples/jekyll/build.js @@ -0,0 +1,17 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const exampleDir = __dirname; +const outputRoot = process.env.READTHEDOCS_OUTPUT || path.join(exampleDir, 'build'); +const assetDir = path.join(outputRoot, 'html', 'jekyll', 'assets', 'shared-web'); +const sharedWebDist = path.join(exampleDir, 'node_modules', '@lizardbyte', 'shared-web', 'dist'); +const sharedWebAssets = [ + 'crowdin.js', + 'crowdin-bootstrap-css.css', +]; + +fs.mkdirSync(assetDir, { recursive: true }); + +sharedWebAssets.forEach((asset) => { + fs.copyFileSync(path.join(sharedWebDist, asset), path.join(assetDir, asset)); +}); diff --git a/examples/jekyll/package.json b/examples/jekyll/package.json index a83a458..d4a217f 100644 --- a/examples/jekyll/package.json +++ b/examples/jekyll/package.json @@ -12,6 +12,8 @@ "postinstall": "npm-run-all postinstall:*", "postinstall:bundler": "echo 'Installing bundler...' && gem install bundler", "postinstall:bundle": "echo 'Installing Jekyll dependencies...' && bundle install", - "build": "bundle exec jekyll build --verbose --destination ${READTHEDOCS_OUTPUT:-build/}html/jekyll" + "build": "npm-run-all build:site build:assets", + "build:assets": "node build.js", + "build:site": "bundle exec jekyll build --verbose --destination ${READTHEDOCS_OUTPUT:-build/}html/jekyll" } } diff --git a/examples/sphinx/package.json b/examples/sphinx/package.json index 4203aa4..1c6a2b1 100644 --- a/examples/sphinx/package.json +++ b/examples/sphinx/package.json @@ -8,6 +8,6 @@ "scripts": { "postinstall": "echo 'Installing Python dependencies...' && python -m pip install -r requirements.txt --no-cache-dir", "build": "python -m sphinx -b html source ${READTHEDOCS_OUTPUT:-build/}html/sphinx/html", - "lint": "python -m rstcheck -r ." + "lint": "python -m rstcheck -r source" } } diff --git a/examples/sphinx/source/conf.py b/examples/sphinx/source/conf.py index 5aeaf0d..74379c2 100644 --- a/examples/sphinx/source/conf.py +++ b/examples/sphinx/source/conf.py @@ -60,19 +60,22 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [ + '_static', + '../node_modules/@lizardbyte/shared-web/dist', +] # These paths are either relative to html_static_path # or fully qualified paths (eg. https://...) html_css_files = [ # use jsdelivr for an easy way to include the css # 'https://cdn.jsdelivr.net/npm/@lizardbyte/shared-web@latest/dist/crowdin-furo-css.css', - '../../../dist/crowdin-furo-css.css', # crowdin style from the readthedocs build + 'crowdin-furo-css.css', # crowdin style from the installed shared-web package ] html_js_files = [ # use jsdelivr for an easy way to include the script # 'https://cdn.jsdelivr.net/npm/@lizardbyte/shared-web@latest/dist/crowdin.js', - '../../../dist/crowdin.js', # crowdin language selector from the readthedocs build + 'crowdin.js', # crowdin language selector from the installed shared-web package 'js/crowdin.js', # initialize crowdin language selector ] diff --git a/src/js/crowdin.js b/src/js/crowdin.js index 875be0c..ffddebc 100644 --- a/src/js/crowdin.js +++ b/src/js/crowdin.js @@ -11,6 +11,143 @@ const loadScript = require('./load-script'); const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist'; const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100; const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50; +const CROWDIN_INLINE_ELEMENT_SELECTOR = [ + 'a', + 'abbr', + 'b', + 'cite', + 'code', + 'del', + 'em', + 'i', + 'ins', + 'kbd', + 'mark', + 'q', + 's', + 'samp', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'time', + 'u', + 'var', +].join(','); + +/** + * Records whitespace that separates text from inline elements before Crowdin translates the page. + * @returns {Array<{ + * node: Text, + * leading: boolean, + * trailing: boolean, + * previousInline: Element|null, + * nextInline: Element|null, + * whitespaceOnly: boolean + * }>} Recorded text-node boundaries. + */ +function _captureCrowdinWhitespaceBoundaries() { + const boundaries = []; + const walker = document.createTreeWalker(document.body, globalThis.NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + + while (node !== null) { + const previousIsInline = node.previousSibling instanceof globalThis.Element && + node.previousSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR); + const nextIsInline = node.nextSibling instanceof globalThis.Element && + node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR); + const leading = previousIsInline && /^\s/.test(node.data); + const trailing = nextIsInline && /\s$/.test(node.data); + + if (leading || trailing) { + boundaries.push({ + node, + leading, + trailing, + previousInline: previousIsInline ? node.previousSibling : null, + nextInline: nextIsInline ? node.nextSibling : null, + whitespaceOnly: /^\s*$/.test(node.data), + }); + } + + node = walker.nextNode(); + } + + return boundaries; +} + +/** + * Finds the current text node for a boundary after Crowdin changes the DOM. + * @param {Object} boundary Recorded whitespace boundary. + * @returns {Text|null} Current or recreated text node. + */ +function _resolveCrowdinWhitespaceNode(boundary) { + if (boundary.node.isConnected) return boundary.node; + + const previousReplacement = boundary.previousInline?.nextSibling; + if (previousReplacement instanceof globalThis.Text && previousReplacement.isConnected) { + boundary.node = previousReplacement; + return boundary.node; + } + const nextReplacement = boundary.nextInline?.previousSibling; + if (nextReplacement instanceof globalThis.Text && nextReplacement.isConnected) { + boundary.node = nextReplacement; + return boundary.node; + } + if (!boundary.whitespaceOnly) return null; + + const replacement = document.createTextNode(''); + if (boundary.previousInline?.isConnected) { + boundary.previousInline.after(replacement); + } else if (boundary.nextInline?.isConnected) { + boundary.nextInline.before(replacement); + } else { + return null; + } + + boundary.node = replacement; + return boundary.node; +} + +/** + * Restores whitespace that Crowdin removed from translated text-node boundaries. + * @param {Array} boundaries Recorded text-node boundaries. + */ +function _restoreCrowdinWhitespaceBoundaries(boundaries) { + boundaries.forEach((boundary) => { + const node = _resolveCrowdinWhitespaceNode(boundary); + if (node === null) return; + + if (boundary.leading && !/^\s/.test(node.data)) { + node.data = ' ' + node.data; + } + if (boundary.trailing && !/\s$/.test(node.data)) { + node.data += ' '; + } + }); +} + +/** + * Creates the Website Translator callback and observes later translation mutations. + * @param {Array} boundaries Recorded text-node boundaries. + * @returns {Function} Website Translator callback. + */ +function _createCrowdinTranslationCallback(boundaries) { + const translationObserver = new globalThis.MutationObserver(restoreAndObserve); + + function restoreAndObserve() { + translationObserver.disconnect(); + _restoreCrowdinWhitespaceBoundaries(boundaries); + translationObserver.observe(document.body, { + characterData: true, + childList: true, + subtree: true, + }); + } + + return restoreAndObserve; +} /** * Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to @@ -171,8 +308,11 @@ function initCrowdIn(project = 'LizardByte', platform = null) { let currentBaseUrl = globalThis.location.origin; // Initialize Crowdin translator + const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries(); + globalThis.proxyTranslator.init({ baseUrl: currentBaseUrl, + callback: _createCrowdinTranslationCallback(whitespaceBoundaries), distribution: projectSettings[project].distribution, defaultLanguage: "en", languageTitles: languageTitles, diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index faf4208..33071ce 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -86,6 +86,7 @@ describe('initCrowdIn', () => { jest.clearAllMocks(); jest.useRealTimers(); delete globalThis.window.proxyTranslator; + delete globalThis.window.i18nextify; delete globalThis._crowdinMirrorInstalled; }); @@ -114,6 +115,89 @@ describe('initCrowdIn', () => { ); }); + it('should only pass supported Website Translator options', () => { + initCrowdIn(); + + // Simulate script loading + jest.runAllTimers(); + + const options = globalThis.proxyTranslator.init.mock.calls[0][0]; + expect(Object.keys(options).sort()).toEqual([ + 'baseUrl', + 'callback', + 'defaultLanguage', + 'distribution', + 'languageRoutingMethod', + 'languageTitles', + 'position', + 'poweredBy', + 'showDefaultLanguageInUrl', + 'submenuPosition', + ]); + }); + + it('should restore whitespace around translated inline elements', () => { + globalThis.document.body.innerHTML = ` +

+ GitHub Discussions are available. Yearly: $14.99, billed. +

+

Before removed

+

gone kept

+

gone also gone

+ `; + + initCrowdIn(); + jest.runAllTimers(); + + const options = globalThis.proxyTranslator.init.mock.calls[0][0]; + const translated = globalThis.document.getElementById('translated'); + const emphasis = translated.querySelector('em'); + const strongElements = translated.querySelectorAll('strong'); + const detachedBoundary = globalThis.document.querySelector('#detached i').previousSibling; + const nextAnchor = globalThis.document.getElementById('next-anchor'); + const nextAnchorWhitespace = nextAnchor.querySelector('i').nextSibling; + const noAnchor = globalThis.document.getElementById('no-anchor'); + + emphasis.previousSibling.replaceWith(document.createTextNode('GitHub')); + emphasis.nextSibling.replaceWith(document.createTextNode('are available. ')); + strongElements[0].nextSibling.remove(); + detachedBoundary.remove(); + nextAnchor.querySelector('i').remove(); + nextAnchorWhitespace.remove(); + noAnchor.remove(); + + options.callback(); + options.callback(); + + expect(translated.textContent.trim()).toBe( + 'GitHub Discussions are available. Yearly: $14.99, billed.' + ); + expect(nextAnchor.textContent).toBe(' kept'); + }); + + it('should restore whitespace after later asynchronous DOM changes', async () => { + globalThis.document.body.innerHTML = '

Use this link here.

'; + + initCrowdIn(); + jest.runAllTimers(); + + const options = globalThis.proxyTranslator.init.mock.calls[0][0]; + const translated = globalThis.document.getElementById('translated'); + const link = translated.querySelector('a'); + + options.callback(); + link.previousSibling.data = link.previousSibling.data.trimEnd(); + await Promise.resolve(); + + expect(translated.textContent).toBe('Use this link here.'); + + jest.advanceTimersByTime(1000); + link.nextSibling.data = link.nextSibling.data.trimStart(); + await Promise.resolve(); + + expect(translated.textContent).toBe('Use this link here.'); + }); + it('should initialize proxyTranslator with LizardByte-docs settings', () => { initCrowdIn('LizardByte-docs');