From 0ca55b0bd9b38d8182eb84f26beec9846b5c2a31 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:12:09 -0400 Subject: [PATCH] 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. --- examples/sphinx/source/index.rst | 11 ++++++ src/js/crowdin.js | 63 +++++++++++++++++++++++++------- tests/crowdin.test.js | 21 +++++++++++ 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/examples/sphinx/source/index.rst b/examples/sphinx/source/index.rst index 8e47d94..828a2c4 100644 --- a/examples/sphinx/source/index.rst +++ b/examples/sphinx/source/index.rst @@ -27,3 +27,14 @@ Then create a file named ``js/crowdin.js`` located in the ``html_static_path`` d .. code-block:: javascript window.initCrowdIn('LizardByte-docs', 'sphinx') + +Whitespace restoration example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The translated block below intentionally places each item on its own syntax-highlighted line. It provides a visual +regression check that CrowdIn preserves line breaks between adjacent inline elements. + +.. code-block:: markdown + + - [x] This is a complete item + - [ ] This is an incomplete item diff --git a/src/js/crowdin.js b/src/js/crowdin.js index ffddebc..e3337cc 100644 --- a/src/js/crowdin.js +++ b/src/js/crowdin.js @@ -36,12 +36,44 @@ const CROWDIN_INLINE_ELEMENT_SELECTOR = [ 'var', ].join(','); +/** + * Returns a sibling when it is an inline element whose boundary Crowdin may rewrite. + * @param {Node|null} sibling Candidate sibling. + * @returns {Element|null} Matching inline element. + */ +function _getCrowdinInlineSibling(sibling) { + if (!(sibling instanceof globalThis.Element)) return null; + return sibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR) ? sibling : null; +} + +/** + * Returns the exact whitespace at the start of a string. + * @param {string} text Text to inspect. + * @returns {string} Leading whitespace. + */ +function _getLeadingWhitespace(text) { + const trimmedText = text.trimStart(); + return text.slice(0, text.length - trimmedText.length); +} + +/** + * Returns the exact whitespace at the end of a string. + * @param {string} text Text to inspect. + * @returns {string} Trailing whitespace. + */ +function _getTrailingWhitespace(text) { + const trimmedText = text.trimEnd(); + return text.slice(trimmedText.length); +} + /** * Records whitespace that separates text from inline elements before Crowdin translates the page. * @returns {Array<{ * node: Text, * leading: boolean, + * leadingWhitespace: string, * trailing: boolean, + * trailingWhitespace: string, * previousInline: Element|null, * nextInline: Element|null, * whitespaceOnly: boolean @@ -53,21 +85,23 @@ function _captureCrowdinWhitespaceBoundaries() { let node = walker.nextNode(); while (node !== null) { - const previousIsInline = node.previousSibling instanceof globalThis.Element && - node.previousSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR); - const nextIsInline = node.nextSibling instanceof globalThis.Element && - node.nextSibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR); - const leading = previousIsInline && /^\s/.test(node.data); - const trailing = nextIsInline && /\s$/.test(node.data); + const previousInline = _getCrowdinInlineSibling(node.previousSibling); + const nextInline = _getCrowdinInlineSibling(node.nextSibling); + const leadingWhitespace = previousInline === null ? '' : _getLeadingWhitespace(node.data); + const trailingWhitespace = nextInline === null ? '' : _getTrailingWhitespace(node.data); + const leading = leadingWhitespace !== ''; + const trailing = trailingWhitespace !== ''; if (leading || trailing) { boundaries.push({ node, leading, + leadingWhitespace, trailing, - previousInline: previousIsInline ? node.previousSibling : null, - nextInline: nextIsInline ? node.nextSibling : null, - whitespaceOnly: /^\s*$/.test(node.data), + trailingWhitespace, + previousInline, + nextInline, + whitespaceOnly: node.data.trim() === '', }); } @@ -119,11 +153,14 @@ function _restoreCrowdinWhitespaceBoundaries(boundaries) { const node = _resolveCrowdinWhitespaceNode(boundary); if (node === null) return; - if (boundary.leading && !/^\s/.test(node.data)) { - node.data = ' ' + node.data; + const currentLeadingWhitespace = _getLeadingWhitespace(node.data); + if (boundary.leading && currentLeadingWhitespace !== boundary.leadingWhitespace) { + node.data = boundary.leadingWhitespace + node.data.slice(currentLeadingWhitespace.length); } - if (boundary.trailing && !/\s$/.test(node.data)) { - node.data += ' '; + const currentTrailingWhitespace = _getTrailingWhitespace(node.data); + if (boundary.trailing && currentTrailingWhitespace !== boundary.trailingWhitespace) { + const translatedTextEnd = node.data.length - currentTrailingWhitespace.length; + node.data = node.data.slice(0, translatedTextEnd) + boundary.trailingWhitespace; } }); } diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index 33071ce..c69b869 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -175,6 +175,27 @@ describe('initCrowdIn', () => { expect(nextAnchor.textContent).toBe(' kept'); }); + it('should restore line breaks between translated syntax-highlighted lines', () => { + globalThis.document.body.innerHTML = [ + '
- [x] This is a complete item\n', + '- [ ] This is an incomplete item\n', + ].join(''); + + initCrowdIn(); + jest.runAllTimers(); + + const options = globalThis.proxyTranslator.init.mock.calls[0][0]; + const codeBlock = globalThis.document.getElementById('code-block'); + const firstLineText = codeBlock.querySelector('.k').nextSibling; + + firstLineText.data = firstLineText.data.trim(); + options.callback(); + + expect(codeBlock.textContent).toBe( + '- [x] This is a complete item\n- [ ] This is an incomplete item\n' + ); + }); + it('should restore whitespace after later asynchronous DOM changes', async () => { globalThis.document.body.innerHTML = '
Use this link here.
';