Skip to content

Commit 350c2c5

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 350c2c5

2 files changed

Lines changed: 34 additions & 4 deletions

File tree

src/js/crowdin.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ const CROWDIN_INLINE_ELEMENT_SELECTOR = [
4141
* @returns {Array<{
4242
* node: Text,
4343
* leading: boolean,
44+
* leadingWhitespace: string,
4445
* trailing: boolean,
46+
* trailingWhitespace: string,
4547
* previousInline: Element|null,
4648
* nextInline: Element|null,
4749
* whitespaceOnly: boolean
@@ -59,12 +61,16 @@ function _captureCrowdinWhitespaceBoundaries() {
5961
node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR);
6062
const leading = previousIsInline && /^\s/.test(node.data);
6163
const trailing = nextIsInline && /\s$/.test(node.data);
64+
const leadingWhitespace = leading ? /^\s+/.exec(node.data)[0] : '';
65+
const trailingWhitespace = trailing ? /\s+$/.exec(node.data)[0] : '';
6266

6367
if (leading || trailing) {
6468
boundaries.push({
6569
node,
6670
leading,
71+
leadingWhitespace,
6772
trailing,
73+
trailingWhitespace,
6874
previousInline: previousIsInline ? node.previousSibling : null,
6975
nextInline: nextIsInline ? node.nextSibling : null,
7076
whitespaceOnly: /^\s*$/.test(node.data),
@@ -119,11 +125,14 @@ function _restoreCrowdinWhitespaceBoundaries(boundaries) {
119125
const node = _resolveCrowdinWhitespaceNode(boundary);
120126
if (node === null) return;
121127

122-
if (boundary.leading && !/^\s/.test(node.data)) {
123-
node.data = ' ' + node.data;
128+
const currentLeadingWhitespace = /^\s*/.exec(node.data)[0];
129+
if (boundary.leading && currentLeadingWhitespace !== boundary.leadingWhitespace) {
130+
node.data = boundary.leadingWhitespace + node.data.slice(currentLeadingWhitespace.length);
124131
}
125-
if (boundary.trailing && !/\s$/.test(node.data)) {
126-
node.data += ' ';
132+
const currentTrailingWhitespace = /\s*$/.exec(node.data)[0];
133+
if (boundary.trailing && currentTrailingWhitespace !== boundary.trailingWhitespace) {
134+
const translatedTextEnd = node.data.length - currentTrailingWhitespace.length;
135+
node.data = node.data.slice(0, translatedTextEnd) + boundary.trailingWhitespace;
127136
}
128137
});
129138
}

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)