Skip to content

Commit 0ca55b0

Browse files
fix(Crowdin): preserve exact inline whitespace after translation
Update Crowdin whitespace boundary handling to capture and restore the full leading/trailing whitespace strings, not just a single space. This keeps original formatting intact for translated inline nodes, including newline boundaries in syntax-highlighted code blocks. Adds a regression test that verifies line breaks are restored between translated checklist lines.
1 parent a58bf8d commit 0ca55b0

3 files changed

Lines changed: 82 additions & 13 deletions

File tree

examples/sphinx/source/index.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,14 @@ Then create a file named ``js/crowdin.js`` located in the ``html_static_path`` d
2727
.. code-block:: javascript
2828
2929
window.initCrowdIn('LizardByte-docs', 'sphinx')
30+
31+
Whitespace restoration example
32+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
33+
34+
The translated block below intentionally places each item on its own syntax-highlighted line. It provides a visual
35+
regression check that CrowdIn preserves line breaks between adjacent inline elements.
36+
37+
.. code-block:: markdown
38+
39+
- [x] This is a complete item
40+
- [ ] This is an incomplete item

src/js/crowdin.js

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,44 @@ const CROWDIN_INLINE_ELEMENT_SELECTOR = [
3636
'var',
3737
].join(',');
3838

39+
/**
40+
* Returns a sibling when it is an inline element whose boundary Crowdin may rewrite.
41+
* @param {Node|null} sibling Candidate sibling.
42+
* @returns {Element|null} Matching inline element.
43+
*/
44+
function _getCrowdinInlineSibling(sibling) {
45+
if (!(sibling instanceof globalThis.Element)) return null;
46+
return sibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR) ? sibling : null;
47+
}
48+
49+
/**
50+
* Returns the exact whitespace at the start of a string.
51+
* @param {string} text Text to inspect.
52+
* @returns {string} Leading whitespace.
53+
*/
54+
function _getLeadingWhitespace(text) {
55+
const trimmedText = text.trimStart();
56+
return text.slice(0, text.length - trimmedText.length);
57+
}
58+
59+
/**
60+
* Returns the exact whitespace at the end of a string.
61+
* @param {string} text Text to inspect.
62+
* @returns {string} Trailing whitespace.
63+
*/
64+
function _getTrailingWhitespace(text) {
65+
const trimmedText = text.trimEnd();
66+
return text.slice(trimmedText.length);
67+
}
68+
3969
/**
4070
* Records whitespace that separates text from inline elements before Crowdin translates the page.
4171
* @returns {Array<{
4272
* node: Text,
4373
* leading: boolean,
74+
* leadingWhitespace: string,
4475
* trailing: boolean,
76+
* trailingWhitespace: string,
4577
* previousInline: Element|null,
4678
* nextInline: Element|null,
4779
* whitespaceOnly: boolean
@@ -53,21 +85,23 @@ function _captureCrowdinWhitespaceBoundaries() {
5385
let node = walker.nextNode();
5486

5587
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);
88+
const previousInline = _getCrowdinInlineSibling(node.previousSibling);
89+
const nextInline = _getCrowdinInlineSibling(node.nextSibling);
90+
const leadingWhitespace = previousInline === null ? '' : _getLeadingWhitespace(node.data);
91+
const trailingWhitespace = nextInline === null ? '' : _getTrailingWhitespace(node.data);
92+
const leading = leadingWhitespace !== '';
93+
const trailing = trailingWhitespace !== '';
6294

6395
if (leading || trailing) {
6496
boundaries.push({
6597
node,
6698
leading,
99+
leadingWhitespace,
67100
trailing,
68-
previousInline: previousIsInline ? node.previousSibling : null,
69-
nextInline: nextIsInline ? node.nextSibling : null,
70-
whitespaceOnly: /^\s*$/.test(node.data),
101+
trailingWhitespace,
102+
previousInline,
103+
nextInline,
104+
whitespaceOnly: node.data.trim() === '',
71105
});
72106
}
73107

@@ -119,11 +153,14 @@ function _restoreCrowdinWhitespaceBoundaries(boundaries) {
119153
const node = _resolveCrowdinWhitespaceNode(boundary);
120154
if (node === null) return;
121155

122-
if (boundary.leading && !/^\s/.test(node.data)) {
123-
node.data = ' ' + node.data;
156+
const currentLeadingWhitespace = _getLeadingWhitespace(node.data);
157+
if (boundary.leading && currentLeadingWhitespace !== boundary.leadingWhitespace) {
158+
node.data = boundary.leadingWhitespace + node.data.slice(currentLeadingWhitespace.length);
124159
}
125-
if (boundary.trailing && !/\s$/.test(node.data)) {
126-
node.data += ' ';
160+
const currentTrailingWhitespace = _getTrailingWhitespace(node.data);
161+
if (boundary.trailing && currentTrailingWhitespace !== boundary.trailingWhitespace) {
162+
const translatedTextEnd = node.data.length - currentTrailingWhitespace.length;
163+
node.data = node.data.slice(0, translatedTextEnd) + boundary.trailingWhitespace;
127164
}
128165
});
129166
}

tests/crowdin.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,27 @@ describe('initCrowdIn', () => {
175175
expect(nextAnchor.textContent).toBe(' kept');
176176
});
177177

178+
it('should restore line breaks between translated syntax-highlighted lines', () => {
179+
globalThis.document.body.innerHTML = [
180+
'<pre id="code-block"><span></span><span class="k">- [x]</span> This is a complete item\n',
181+
'<span class="k">- [ ]</span> This is an incomplete item\n</pre>',
182+
].join('');
183+
184+
initCrowdIn();
185+
jest.runAllTimers();
186+
187+
const options = globalThis.proxyTranslator.init.mock.calls[0][0];
188+
const codeBlock = globalThis.document.getElementById('code-block');
189+
const firstLineText = codeBlock.querySelector('.k').nextSibling;
190+
191+
firstLineText.data = firstLineText.data.trim();
192+
options.callback();
193+
194+
expect(codeBlock.textContent).toBe(
195+
'- [x] This is a complete item\n- [ ] This is an incomplete item\n'
196+
);
197+
});
198+
178199
it('should restore whitespace after later asynchronous DOM changes', async () => {
179200
globalThis.document.body.innerHTML = '<p id="translated">Use <a href="#">this link</a> here.</p>';
180201

0 commit comments

Comments
 (0)