Skip to content

Commit 451d993

Browse files
fix(Crowdin): anable whitespace cleanup
Set `cleanWhitespace: true` when initializing `proxyTranslator` so translated content preserves spaces around inline elements like links and emphasis. Added a focused unit test to ensure the option is passed during initialization.
1 parent a618a71 commit 451d993

6 files changed

Lines changed: 206 additions & 6 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/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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,98 @@ 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<{node: Text, leading: boolean, trailing: boolean}>} Recorded text-node boundaries.
42+
*/
43+
function _captureCrowdinWhitespaceBoundaries() {
44+
const boundaries = [];
45+
const walker = document.createTreeWalker(document.body, globalThis.NodeFilter.SHOW_TEXT);
46+
let node = walker.nextNode();
47+
48+
while (node !== null) {
49+
const previousIsInline = node.previousSibling instanceof globalThis.Element &&
50+
node.previousSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
51+
const nextIsInline = node.nextSibling instanceof globalThis.Element &&
52+
node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
53+
const leading = previousIsInline && /^\s/.test(node.data);
54+
const trailing = nextIsInline && /\s$/.test(node.data);
55+
56+
if (leading || trailing) {
57+
boundaries.push({ node, leading, trailing });
58+
}
59+
60+
node = walker.nextNode();
61+
}
62+
63+
return boundaries;
64+
}
65+
66+
/**
67+
* Restores whitespace that Crowdin removed from translated text-node boundaries.
68+
* @param {Array<{node: Text, leading: boolean, trailing: boolean}>} boundaries Recorded text-node boundaries.
69+
*/
70+
function _restoreCrowdinWhitespaceBoundaries(boundaries) {
71+
boundaries.forEach((boundary) => {
72+
if (!boundary.node.isConnected) return;
73+
74+
if (boundary.leading && !/^\s/.test(boundary.node.data)) {
75+
boundary.node.data = ' ' + boundary.node.data;
76+
}
77+
if (boundary.trailing && !/\s$/.test(boundary.node.data)) {
78+
boundary.node.data += ' ';
79+
}
80+
});
81+
}
82+
83+
/**
84+
* Creates the Website Translator callback that repairs whitespace after translations are applied.
85+
* @param {Array<{node: Text, leading: boolean, trailing: boolean}>} boundaries Recorded text-node boundaries.
86+
* @returns {Function} Website Translator callback.
87+
*/
88+
function _createCrowdinTranslationCallback(boundaries) {
89+
let listeningForLanguageChanges = false;
90+
91+
return function restoreCrowdinWhitespace() {
92+
const i18next = globalThis.i18nextify?.i18next;
93+
94+
if (!listeningForLanguageChanges && typeof i18next?.on === 'function') {
95+
i18next.on('languageChanged', function() {
96+
globalThis.setTimeout(function() {
97+
_restoreCrowdinWhitespaceBoundaries(boundaries);
98+
}, 0);
99+
});
100+
listeningForLanguageChanges = true;
101+
}
102+
103+
_restoreCrowdinWhitespaceBoundaries(boundaries);
104+
};
105+
}
14106

15107
/**
16108
* Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
@@ -171,8 +263,11 @@ function initCrowdIn(project = 'LizardByte', platform = null) {
171263
let currentBaseUrl = globalThis.location.origin;
172264

173265
// Initialize Crowdin translator
266+
const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries();
267+
174268
globalThis.proxyTranslator.init({
175269
baseUrl: currentBaseUrl,
270+
callback: _createCrowdinTranslationCallback(whitespaceBoundaries),
176271
distribution: projectSettings[project].distribution,
177272
defaultLanguage: "en",
178273
languageTitles: languageTitles,

tests/crowdin.test.js

Lines changed: 83 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,88 @@ 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+
164+
expect(translated.textContent.trim()).toBe(
165+
'GitHub Discussions are available. Yearly: $14.99, billed.'
166+
);
167+
});
168+
169+
it('should restore whitespace after the language changes', () => {
170+
globalThis.document.body.innerHTML = '<p id="translated">Use <a href="#">this link</a> here.</p>';
171+
const languageChangedHandlers = [];
172+
globalThis.window.i18nextify = {
173+
i18next: {
174+
on: jest.fn((event, handler) => {
175+
expect(event).toBe('languageChanged');
176+
languageChangedHandlers.push(handler);
177+
})
178+
}
179+
};
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+
options.callback();
190+
expect(globalThis.i18nextify.i18next.on).toHaveBeenCalledTimes(1);
191+
192+
link.previousSibling.data = link.previousSibling.data.trimEnd();
193+
link.nextSibling.data = link.nextSibling.data.trimStart();
194+
languageChangedHandlers[0]();
195+
jest.runOnlyPendingTimers();
196+
197+
expect(translated.textContent).toBe('Use this link here.');
198+
});
199+
117200
it('should initialize proxyTranslator with LizardByte-docs settings', () => {
118201
initCrowdIn('LizardByte-docs');
119202

0 commit comments

Comments
 (0)