Skip to content

Commit c85bef1

Browse files
fix(Crowdin): whitespace loss after translation
Crowdin's Website Translator strips whitespace between text nodes and adjacent inline elements (e.g. <a>, <em>, <strong>) during translation. This commit captures those whitespace boundaries before translation and restores them after mutations settle, using a MutationObserver with debounced timer. Also re-hooks on i18next languageChanged events for subsequent language switches. Also fixes example asset paths to reference installed node_modules instead of a relative dist/ directory, adds a build.js asset-copy step for the Jekyll example, and scopes the sphinx rstcheck lint to the source/ directory.
1 parent a618a71 commit c85bef1

7 files changed

Lines changed: 236 additions & 7 deletions

File tree

examples/jekyll/_config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ page-col: "#303436"
2424
text-col: "#e4e4e4"
2525
mobile-theme-col: "#05FF3B"
2626
site-css:
27-
- "../dist/crowdin-bootstrap-css.css"
27+
- "/assets/shared-web/crowdin-bootstrap-css.css"
2828
site-js:
29-
- "../dist/crowdin.js"
29+
- "/assets/shared-web/crowdin.js"
3030
- "/assets/js/crowdin-init.js"
3131

3232
# Advanced settings

examples/jekyll/build.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const fs = require('node:fs');
2+
const path = require('node:path');
3+
4+
const exampleDir = __dirname;
5+
const outputRoot = process.env.READTHEDOCS_OUTPUT || path.join(exampleDir, 'build');
6+
const assetDir = path.join(outputRoot, 'html', 'jekyll', 'assets', 'shared-web');
7+
const sharedWebDist = path.join(exampleDir, 'node_modules', '@lizardbyte', 'shared-web', 'dist');
8+
const sharedWebAssets = [
9+
'crowdin.js',
10+
'crowdin-bootstrap-css.css',
11+
];
12+
13+
fs.mkdirSync(assetDir, { recursive: true });
14+
15+
sharedWebAssets.forEach((asset) => {
16+
fs.copyFileSync(path.join(sharedWebDist, asset), path.join(assetDir, asset));
17+
});

examples/jekyll/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"postinstall": "npm-run-all postinstall:*",
1313
"postinstall:bundler": "echo 'Installing bundler...' && gem install bundler",
1414
"postinstall:bundle": "echo 'Installing Jekyll dependencies...' && bundle install",
15-
"build": "bundle exec jekyll build --verbose --destination ${READTHEDOCS_OUTPUT:-build/}html/jekyll"
15+
"build": "npm-run-all build:site build:assets",
16+
"build:assets": "node build.js",
17+
"build:site": "bundle exec jekyll build --verbose --destination ${READTHEDOCS_OUTPUT:-build/}html/jekyll"
1618
}
1719
}

examples/sphinx/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88
"scripts": {
99
"postinstall": "echo 'Installing Python dependencies...' && python -m pip install -r requirements.txt --no-cache-dir",
1010
"build": "python -m sphinx -b html source ${READTHEDOCS_OUTPUT:-build/}html/sphinx/html",
11-
"lint": "python -m rstcheck -r ."
11+
"lint": "python -m rstcheck -r source"
1212
}
1313
}

examples/sphinx/source/conf.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,22 @@
6060
# Add any paths that contain custom static files (such as style sheets) here,
6161
# relative to this directory. They are copied after the builtin static files,
6262
# so a file named "default.css" will overwrite the builtin "default.css".
63-
html_static_path = ['_static']
63+
html_static_path = [
64+
'_static',
65+
'../node_modules/@lizardbyte/shared-web/dist',
66+
]
6467

6568
# These paths are either relative to html_static_path
6669
# or fully qualified paths (eg. https://...)
6770
html_css_files = [
6871
# use jsdelivr for an easy way to include the css
6972
# 'https://cdn.jsdelivr.net/npm/@lizardbyte/shared-web@latest/dist/crowdin-furo-css.css',
70-
'../../../dist/crowdin-furo-css.css', # crowdin style from the readthedocs build
73+
'crowdin-furo-css.css', # crowdin style from the installed shared-web package
7174
]
7275
html_js_files = [
7376
# use jsdelivr for an easy way to include the script
7477
# 'https://cdn.jsdelivr.net/npm/@lizardbyte/shared-web@latest/dist/crowdin.js',
75-
'../../../dist/crowdin.js', # crowdin language selector from the readthedocs build
78+
'crowdin.js', # crowdin language selector from the installed shared-web package
7679
'js/crowdin.js', # initialize crowdin language selector
7780
]
7881

src/js/crowdin.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,120 @@ const loadScript = require('./load-script');
99
* @type {string}
1010
*/
1111
const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist';
12+
const CROWDIN_TRANSLATION_SETTLE_DELAY_MS = 50;
1213
const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100;
1314
const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50;
15+
const CROWDIN_INLINE_ELEMENT_SELECTOR = [
16+
'a',
17+
'abbr',
18+
'b',
19+
'cite',
20+
'code',
21+
'del',
22+
'em',
23+
'i',
24+
'ins',
25+
'kbd',
26+
'mark',
27+
'q',
28+
's',
29+
'samp',
30+
'small',
31+
'span',
32+
'strong',
33+
'sub',
34+
'sup',
35+
'time',
36+
'u',
37+
'var',
38+
].join(',');
39+
40+
/**
41+
* Records whitespace that separates text from inline elements before Crowdin translates the page.
42+
* @returns {Array<{node: Text, leading: boolean, trailing: boolean}>} Recorded text-node boundaries.
43+
*/
44+
function _captureCrowdinWhitespaceBoundaries() {
45+
const boundaries = [];
46+
const walker = document.createTreeWalker(document.body, globalThis.NodeFilter.SHOW_TEXT);
47+
let node = walker.nextNode();
48+
49+
while (node !== null) {
50+
const previousIsInline = node.previousSibling instanceof globalThis.Element &&
51+
node.previousSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
52+
const nextIsInline = node.nextSibling instanceof globalThis.Element &&
53+
node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
54+
const leading = previousIsInline && /^\s/.test(node.data);
55+
const trailing = nextIsInline && /\s$/.test(node.data);
56+
57+
if (leading || trailing) {
58+
boundaries.push({ node, leading, trailing });
59+
}
60+
61+
node = walker.nextNode();
62+
}
63+
64+
return boundaries;
65+
}
66+
67+
/**
68+
* Restores whitespace that Crowdin removed from translated text-node boundaries.
69+
* @param {Array<{node: Text, leading: boolean, trailing: boolean}>} boundaries Recorded text-node boundaries.
70+
*/
71+
function _restoreCrowdinWhitespaceBoundaries(boundaries) {
72+
boundaries.forEach((boundary) => {
73+
if (!boundary.node.isConnected) return;
74+
75+
if (boundary.leading && !/^\s/.test(boundary.node.data)) {
76+
boundary.node.data = ' ' + boundary.node.data;
77+
}
78+
if (boundary.trailing && !/\s$/.test(boundary.node.data)) {
79+
boundary.node.data += ' ';
80+
}
81+
});
82+
}
83+
84+
/**
85+
* Creates the Website Translator callback that repairs whitespace after translation mutations settle.
86+
* @param {Array<{node: Text, leading: boolean, trailing: boolean}>} boundaries Recorded text-node boundaries.
87+
* @returns {Function} Website Translator callback.
88+
*/
89+
function _createCrowdinTranslationCallback(boundaries) {
90+
let listeningForLanguageChanges = false;
91+
let translationObserver;
92+
let restoreTimer;
93+
94+
function restoreAfterTranslationSettles() {
95+
globalThis.clearTimeout(restoreTimer);
96+
restoreTimer = globalThis.setTimeout(function() {
97+
translationObserver.disconnect();
98+
translationObserver = undefined;
99+
restoreTimer = undefined;
100+
_restoreCrowdinWhitespaceBoundaries(boundaries);
101+
}, CROWDIN_TRANSLATION_SETTLE_DELAY_MS);
102+
}
103+
104+
function observeTranslationMutations() {
105+
translationObserver?.disconnect();
106+
translationObserver = new globalThis.MutationObserver(restoreAfterTranslationSettles);
107+
translationObserver.observe(document.body, {
108+
characterData: true,
109+
childList: true,
110+
subtree: true,
111+
});
112+
restoreAfterTranslationSettles();
113+
}
114+
115+
return function waitForCrowdinTranslation() {
116+
const i18next = globalThis.i18nextify?.i18next;
117+
118+
if (!listeningForLanguageChanges && typeof i18next?.on === 'function') {
119+
i18next.on('languageChanged', observeTranslationMutations);
120+
listeningForLanguageChanges = true;
121+
}
122+
123+
observeTranslationMutations();
124+
};
125+
}
14126

15127
/**
16128
* Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
@@ -171,8 +283,11 @@ function initCrowdIn(project = 'LizardByte', platform = null) {
171283
let currentBaseUrl = globalThis.location.origin;
172284

173285
// Initialize Crowdin translator
286+
const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries();
287+
174288
globalThis.proxyTranslator.init({
175289
baseUrl: currentBaseUrl,
290+
callback: _createCrowdinTranslationCallback(whitespaceBoundaries),
176291
distribution: projectSettings[project].distribution,
177292
defaultLanguage: "en",
178293
languageTitles: languageTitles,

tests/crowdin.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ describe('initCrowdIn', () => {
8686
jest.clearAllMocks();
8787
jest.useRealTimers();
8888
delete globalThis.window.proxyTranslator;
89+
delete globalThis.window.i18nextify;
8990
delete globalThis._crowdinMirrorInstalled;
9091
});
9192

@@ -114,6 +115,97 @@ describe('initCrowdIn', () => {
114115
);
115116
});
116117

118+
it('should only pass supported Website Translator options', () => {
119+
initCrowdIn();
120+
121+
// Simulate script loading
122+
jest.runAllTimers();
123+
124+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
125+
expect(Object.keys(options).sort()).toEqual([
126+
'baseUrl',
127+
'callback',
128+
'defaultLanguage',
129+
'distribution',
130+
'languageRoutingMethod',
131+
'languageTitles',
132+
'position',
133+
'poweredBy',
134+
'showDefaultLanguageInUrl',
135+
'submenuPosition',
136+
]);
137+
});
138+
139+
it('should restore whitespace around translated inline elements', () => {
140+
globalThis.document.body.innerHTML = `
141+
<p id="translated">
142+
GitHub <em>Discussions</em> are available. <strong>Yearly:</strong> <strong>$14.99</strong>, billed.
143+
</p>
144+
<p id="detached">Before <i>removed</i></p>
145+
`;
146+
147+
initCrowdIn();
148+
jest.runAllTimers();
149+
150+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
151+
const translated = globalThis.document.getElementById('translated');
152+
const emphasis = translated.querySelector('em');
153+
const strongElements = translated.querySelectorAll('strong');
154+
const detachedBoundary = globalThis.document.querySelector('#detached i').previousSibling;
155+
156+
emphasis.previousSibling.data = emphasis.previousSibling.data.trimEnd();
157+
emphasis.nextSibling.data = emphasis.nextSibling.data.trim();
158+
strongElements[0].nextSibling.data = '';
159+
detachedBoundary.remove();
160+
161+
options.callback();
162+
options.callback();
163+
jest.runOnlyPendingTimers();
164+
165+
expect(translated.textContent.trim()).toBe(
166+
'GitHub Discussions are available. Yearly: $14.99, billed.'
167+
);
168+
});
169+
170+
it('should restore whitespace after asynchronous language changes', async () => {
171+
globalThis.document.body.innerHTML = '<p id="translated">Use <a href="#">this link</a> here.</p>';
172+
const languageChangedHandlers = [];
173+
globalThis.window.i18nextify = {
174+
i18next: {
175+
on: jest.fn((event, handler) => {
176+
expect(event).toBe('languageChanged');
177+
languageChangedHandlers.push(handler);
178+
})
179+
}
180+
};
181+
182+
initCrowdIn();
183+
jest.runAllTimers();
184+
185+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
186+
const translated = globalThis.document.getElementById('translated');
187+
const link = translated.querySelector('a');
188+
189+
options.callback();
190+
options.callback();
191+
expect(globalThis.i18nextify.i18next.on).toHaveBeenCalledTimes(1);
192+
193+
languageChangedHandlers[0]();
194+
link.previousSibling.data = link.previousSibling.data.trimEnd();
195+
await Promise.resolve();
196+
jest.advanceTimersByTime(25);
197+
198+
link.nextSibling.data = link.nextSibling.data.trimStart();
199+
await Promise.resolve();
200+
jest.advanceTimersByTime(25);
201+
202+
expect(translated.textContent).toBe('Usethis linkhere.');
203+
204+
jest.advanceTimersByTime(25);
205+
206+
expect(translated.textContent).toBe('Use this link here.');
207+
});
208+
117209
it('should initialize proxyTranslator with LizardByte-docs settings', () => {
118210
initCrowdIn('LizardByte-docs');
119211

0 commit comments

Comments
 (0)