Skip to content

Commit 6532a22

Browse files
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.
1 parent a618a71 commit 6532a22

7 files changed

Lines changed: 253 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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,143 @@ const loadScript = require('./load-script');
1111
const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist';
1212
const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100;
1313
const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50;
14+
const CROWDIN_INLINE_ELEMENT_SELECTOR = [
15+
'a',
16+
'abbr',
17+
'b',
18+
'cite',
19+
'code',
20+
'del',
21+
'em',
22+
'i',
23+
'ins',
24+
'kbd',
25+
'mark',
26+
'q',
27+
's',
28+
'samp',
29+
'small',
30+
'span',
31+
'strong',
32+
'sub',
33+
'sup',
34+
'time',
35+
'u',
36+
'var',
37+
].join(',');
38+
39+
/**
40+
* Records whitespace that separates text from inline elements before Crowdin translates the page.
41+
* @returns {Array<{
42+
* node: Text,
43+
* leading: boolean,
44+
* trailing: boolean,
45+
* previousInline: Element|null,
46+
* nextInline: Element|null,
47+
* whitespaceOnly: boolean
48+
* }>} Recorded text-node boundaries.
49+
*/
50+
function _captureCrowdinWhitespaceBoundaries() {
51+
const boundaries = [];
52+
const walker = document.createTreeWalker(document.body, globalThis.NodeFilter.SHOW_TEXT);
53+
let node = walker.nextNode();
54+
55+
while (node !== null) {
56+
const previousIsInline = node.previousSibling instanceof globalThis.Element &&
57+
node.previousSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
58+
const nextIsInline = node.nextSibling instanceof globalThis.Element &&
59+
node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
60+
const leading = previousIsInline && /^\s/.test(node.data);
61+
const trailing = nextIsInline && /\s$/.test(node.data);
62+
63+
if (leading || trailing) {
64+
boundaries.push({
65+
node,
66+
leading,
67+
trailing,
68+
previousInline: previousIsInline ? node.previousSibling : null,
69+
nextInline: nextIsInline ? node.nextSibling : null,
70+
whitespaceOnly: /^\s*$/.test(node.data),
71+
});
72+
}
73+
74+
node = walker.nextNode();
75+
}
76+
77+
return boundaries;
78+
}
79+
80+
/**
81+
* Finds the current text node for a boundary after Crowdin changes the DOM.
82+
* @param {Object} boundary Recorded whitespace boundary.
83+
* @returns {Text|null} Current or recreated text node.
84+
*/
85+
function _resolveCrowdinWhitespaceNode(boundary) {
86+
if (boundary.node.isConnected) return boundary.node;
87+
88+
const previousReplacement = boundary.previousInline?.nextSibling;
89+
if (previousReplacement instanceof globalThis.Text && previousReplacement.isConnected) {
90+
boundary.node = previousReplacement;
91+
return boundary.node;
92+
}
93+
const nextReplacement = boundary.nextInline?.previousSibling;
94+
if (nextReplacement instanceof globalThis.Text && nextReplacement.isConnected) {
95+
boundary.node = nextReplacement;
96+
return boundary.node;
97+
}
98+
if (!boundary.whitespaceOnly) return null;
99+
100+
const replacement = document.createTextNode('');
101+
if (boundary.previousInline?.isConnected) {
102+
boundary.previousInline.after(replacement);
103+
} else if (boundary.nextInline?.isConnected) {
104+
boundary.nextInline.before(replacement);
105+
} else {
106+
return null;
107+
}
108+
109+
boundary.node = replacement;
110+
return boundary.node;
111+
}
112+
113+
/**
114+
* Restores whitespace that Crowdin removed from translated text-node boundaries.
115+
* @param {Array<Object>} boundaries Recorded text-node boundaries.
116+
*/
117+
function _restoreCrowdinWhitespaceBoundaries(boundaries) {
118+
boundaries.forEach((boundary) => {
119+
const node = _resolveCrowdinWhitespaceNode(boundary);
120+
if (node === null) return;
121+
122+
if (boundary.leading && !/^\s/.test(node.data)) {
123+
node.data = ' ' + node.data;
124+
}
125+
if (boundary.trailing && !/\s$/.test(node.data)) {
126+
node.data += ' ';
127+
}
128+
});
129+
}
130+
131+
/**
132+
* Creates the Website Translator callback and observes later translation mutations.
133+
* @param {Array<Object>} boundaries Recorded text-node boundaries.
134+
* @returns {Function} Website Translator callback.
135+
*/
136+
function _createCrowdinTranslationCallback(boundaries) {
137+
const translationObserver = new globalThis.MutationObserver(restoreAndObserve);
138+
139+
function restoreAndObserve() {
140+
translationObserver.disconnect();
141+
_restoreCrowdinWhitespaceBoundaries(boundaries);
142+
translationObserver.observe(document.body, {
143+
characterData: true,
144+
childList: true,
145+
subtree: true,
146+
});
147+
}
148+
149+
return restoreAndObserve;
150+
}
14151

15152
/**
16153
* Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
@@ -171,8 +308,11 @@ function initCrowdIn(project = 'LizardByte', platform = null) {
171308
let currentBaseUrl = globalThis.location.origin;
172309

173310
// Initialize Crowdin translator
311+
const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries();
312+
174313
globalThis.proxyTranslator.init({
175314
baseUrl: currentBaseUrl,
315+
callback: _createCrowdinTranslationCallback(whitespaceBoundaries),
176316
distribution: projectSettings[project].distribution,
177317
defaultLanguage: "en",
178318
languageTitles: languageTitles,

tests/crowdin.test.js

Lines changed: 84 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,89 @@ 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+
<p id="next-anchor"><i>gone</i> <strong>kept</strong></p>
146+
<p id="no-anchor"><i>gone</i> <strong>also gone</strong></p>
147+
`;
148+
149+
initCrowdIn();
150+
jest.runAllTimers();
151+
152+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
153+
const translated = globalThis.document.getElementById('translated');
154+
const emphasis = translated.querySelector('em');
155+
const strongElements = translated.querySelectorAll('strong');
156+
const detachedBoundary = globalThis.document.querySelector('#detached i').previousSibling;
157+
const nextAnchor = globalThis.document.getElementById('next-anchor');
158+
const nextAnchorWhitespace = nextAnchor.querySelector('i').nextSibling;
159+
const noAnchor = globalThis.document.getElementById('no-anchor');
160+
161+
emphasis.previousSibling.replaceWith(document.createTextNode('GitHub'));
162+
emphasis.nextSibling.replaceWith(document.createTextNode('are available. '));
163+
strongElements[0].nextSibling.remove();
164+
detachedBoundary.remove();
165+
nextAnchor.querySelector('i').remove();
166+
nextAnchorWhitespace.remove();
167+
noAnchor.remove();
168+
169+
options.callback();
170+
options.callback();
171+
172+
expect(translated.textContent.trim()).toBe(
173+
'GitHub Discussions are available. Yearly: $14.99, billed.'
174+
);
175+
expect(nextAnchor.textContent).toBe(' kept');
176+
});
177+
178+
it('should restore whitespace after later asynchronous DOM changes', async () => {
179+
globalThis.document.body.innerHTML = '<p id="translated">Use <a href="#">this link</a> here.</p>';
180+
181+
initCrowdIn();
182+
jest.runAllTimers();
183+
184+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
185+
const translated = globalThis.document.getElementById('translated');
186+
const link = translated.querySelector('a');
187+
188+
options.callback();
189+
link.previousSibling.data = link.previousSibling.data.trimEnd();
190+
await Promise.resolve();
191+
192+
expect(translated.textContent).toBe('Use this link here.');
193+
194+
jest.advanceTimersByTime(1000);
195+
link.nextSibling.data = link.nextSibling.data.trimStart();
196+
await Promise.resolve();
197+
198+
expect(translated.textContent).toBe('Use this link here.');
199+
});
200+
117201
it('should initialize proxyTranslator with LizardByte-docs settings', () => {
118202
initCrowdIn('LizardByte-docs');
119203

0 commit comments

Comments
 (0)