Skip to content

Commit 0a3b8a9

Browse files
fix(crowdin): preserve whitespace within translation pipeline (#382)
1 parent 3287309 commit 0a3b8a9

2 files changed

Lines changed: 161 additions & 262 deletions

File tree

‎src/js/crowdin.js‎

Lines changed: 53 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -12,198 +12,71 @@ const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist';
1212
const CROWDIN_CODE_BLOCK_CLASS = 'crowdin-ignore-code';
1313
const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100;
1414
const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50;
15-
const CROWDIN_INLINE_ELEMENT_SELECTOR = [
16-
'a',
17-
'abbr',
18-
'b',
19-
'cite',
20-
'code',
21-
'del',
22-
'em',
23-
'i',
24-
'ins',
25-
'kbd',
26-
'mark',
27-
'q',
28-
's',
29-
'samp',
30-
'small',
31-
'span',
32-
'strong',
33-
'sub',
34-
'sup',
35-
'time',
36-
'u',
37-
'var',
38-
].join(',');
15+
const CROWDIN_WHITESPACE_POST_PROCESSOR = 'crowdin-preserve-whitespace';
16+
const CROWDIN_WHITESPACE_RENDERERS = new WeakSet();
3917

4018
/**
41-
* Returns a sibling when it is an inline element whose boundary Crowdin may rewrite.
42-
* @param {Node|null} sibling Candidate sibling.
43-
* @returns {Element|null} Matching inline element.
19+
* Preserves source whitespace in translation results before i18nextify records
20+
* or renders them. Its cleanWhitespace handling only recognizes single-line
21+
* text; multiline missing keys and translations can lose their boundary spaces.
22+
* @param {*} value Translated value.
23+
* @param {string|string[]} key Translation lookup keys.
24+
* @param {Object} options Translation options, including the source defaultValue.
25+
* @returns {*} Translation with the source's exact boundary whitespace.
4426
*/
45-
function _getCrowdinInlineSibling(sibling) {
46-
if (!(sibling instanceof globalThis.Element)) return null;
47-
return sibling.matches(CROWDIN_INLINE_ELEMENT_SELECTOR) ? sibling : null;
27+
function _preserveCrowdinWhitespace(value, key, options) {
28+
const source = options?.defaultValue;
29+
if (typeof value !== 'string' || typeof source !== 'string') return value;
30+
if (source.trim() === '') return source;
31+
32+
const leadingWhitespace = source.slice(0, source.length - source.trimStart().length);
33+
const trailingWhitespace = source.slice(source.trimEnd().length);
34+
return leadingWhitespace + value.trim() + trailingWhitespace;
4835
}
4936

5037
/**
51-
* Returns the exact whitespace at the start of a string.
52-
* @param {string} text Text to inspect.
53-
* @returns {string} Leading whitespace.
38+
* Adds a postprocessor to Crowdin's renderer before its initial translation.
39+
* Keeping i18nextify's cached translation and DOM text identical avoids the
40+
* mutation-observer loop caused by repairing whitespace after rendering.
5441
*/
55-
function _getLeadingWhitespace(text) {
56-
const trimmedText = text.trimStart();
57-
return text.slice(0, text.length - trimmedText.length);
58-
}
59-
60-
/**
61-
* Returns the exact whitespace at the end of a string.
62-
* @param {string} text Text to inspect.
63-
* @returns {string} Trailing whitespace.
64-
*/
65-
function _getTrailingWhitespace(text) {
66-
const trimmedText = text.trimEnd();
67-
return text.slice(trimmedText.length);
68-
}
69-
70-
/**
71-
* Records whitespace that separates text from inline elements before Crowdin translates the page.
72-
* @returns {Array<{
73-
* node: Text,
74-
* leading: boolean,
75-
* leadingWhitespace: string,
76-
* trailing: boolean,
77-
* trailingWhitespace: string,
78-
* previousInline: Element|null,
79-
* nextInline: Element|null,
80-
* whitespaceOnly: boolean,
81-
* lastRestoredNode: Text|null,
82-
* lastRestoredFrom: string|null
83-
* }>} Recorded text-node boundaries.
84-
*/
85-
function _captureCrowdinWhitespaceBoundaries() {
86-
const boundaries = [];
87-
const walker = document.createTreeWalker(document.body, globalThis.NodeFilter.SHOW_TEXT);
88-
let node = walker.nextNode();
89-
90-
while (node !== null) {
91-
const previousInline = _getCrowdinInlineSibling(node.previousSibling);
92-
const nextInline = _getCrowdinInlineSibling(node.nextSibling);
93-
const leadingWhitespace = previousInline === null ? '' : _getLeadingWhitespace(node.data);
94-
const trailingWhitespace = nextInline === null ? '' : _getTrailingWhitespace(node.data);
95-
const leading = leadingWhitespace !== '';
96-
const trailing = trailingWhitespace !== '';
97-
98-
if (leading || trailing) {
99-
boundaries.push({
100-
node,
101-
leading,
102-
leadingWhitespace,
103-
trailing,
104-
trailingWhitespace,
105-
previousInline,
106-
nextInline,
107-
whitespaceOnly: node.data.trim() === '',
108-
lastRestoredNode: null,
109-
lastRestoredFrom: null,
110-
});
111-
}
112-
113-
node = walker.nextNode();
114-
}
115-
116-
return boundaries;
117-
}
118-
119-
/**
120-
* Finds the current text node for a boundary after Crowdin changes the DOM.
121-
* @param {Object} boundary Recorded whitespace boundary.
122-
* @returns {Text|null} Current or recreated text node.
123-
*/
124-
function _resolveCrowdinWhitespaceNode(boundary) {
125-
if (boundary.node.isConnected) return boundary.node;
126-
127-
const previousReplacement = boundary.previousInline?.nextSibling;
128-
if (previousReplacement instanceof globalThis.Text && previousReplacement.isConnected) {
129-
boundary.node = previousReplacement;
130-
return boundary.node;
131-
}
132-
const nextReplacement = boundary.nextInline?.previousSibling;
133-
if (nextReplacement instanceof globalThis.Text && nextReplacement.isConnected) {
134-
boundary.node = nextReplacement;
135-
return boundary.node;
136-
}
137-
if (!boundary.whitespaceOnly) return null;
138-
139-
const replacement = document.createTextNode('');
140-
if (boundary.previousInline?.isConnected) {
141-
boundary.previousInline.after(replacement);
142-
} else if (boundary.nextInline?.isConnected) {
143-
boundary.nextInline.before(replacement);
144-
} else {
145-
return null;
146-
}
147-
148-
boundary.node = replacement;
149-
return boundary.node;
150-
}
151-
152-
/**
153-
* Restores whitespace that Crowdin removed from translated text-node boundaries.
154-
* @param {Array<Object>} boundaries Recorded text-node boundaries.
155-
*/
156-
function _restoreCrowdinWhitespaceBoundaries(boundaries) {
157-
boundaries.forEach((boundary) => {
158-
const node = _resolveCrowdinWhitespaceNode(boundary);
159-
if (node === null) return;
160-
if (boundary.lastRestoredNode === node && boundary.lastRestoredFrom === node.data) return;
161-
162-
const originalText = node.data;
163-
164-
const currentLeadingWhitespace = _getLeadingWhitespace(node.data);
165-
if (boundary.leading && currentLeadingWhitespace !== boundary.leadingWhitespace) {
166-
node.data = boundary.leadingWhitespace + node.data.slice(currentLeadingWhitespace.length);
167-
}
168-
const currentTrailingWhitespace = _getTrailingWhitespace(node.data);
169-
if (boundary.trailing && currentTrailingWhitespace !== boundary.trailingWhitespace) {
170-
const translatedTextEnd = node.data.length - currentTrailingWhitespace.length;
171-
node.data = node.data.slice(0, translatedTextEnd) + boundary.trailingWhitespace;
172-
}
173-
if (node.data !== originalText) {
174-
// A translator may observe this correction and remove the same
175-
// whitespace again. Do not repeat the correction indefinitely.
176-
boundary.lastRestoredNode = node;
177-
boundary.lastRestoredFrom = originalText;
178-
}
42+
function _installCrowdinWhitespacePreserver() {
43+
const renderer = globalThis.i18nextify;
44+
if (CROWDIN_WHITESPACE_RENDERERS.has(renderer)) return;
45+
46+
renderer.i18next.use({
47+
type: 'postProcessor',
48+
name: CROWDIN_WHITESPACE_POST_PROCESSOR,
49+
process: _preserveCrowdinWhitespace,
17950
});
180-
}
181-
182-
/**
183-
* Creates the Website Translator callback and observes later translation mutations.
184-
* @param {Array<Object>} boundaries Recorded text-node boundaries.
185-
* @returns {Function} Website Translator callback.
186-
*/
187-
function _createCrowdinTranslationCallback(boundaries) {
188-
const translationObserver = new globalThis.MutationObserver(restoreAndObserve);
18951

190-
function restoreAndObserve() {
191-
translationObserver.disconnect();
192-
_restoreCrowdinWhitespaceBoundaries(boundaries);
193-
translationObserver.observe(document.body, {
194-
characterData: true,
195-
childList: true,
196-
subtree: true,
197-
});
198-
}
199-
200-
return restoreAndObserve;
52+
const originalInit = renderer.init;
53+
renderer.init = function(options, ...args) {
54+
const postProcess = options.postProcess ?? [];
55+
const rendererOptions = {
56+
...options,
57+
postProcess: [
58+
...(Array.isArray(postProcess) ? postProcess : [postProcess]),
59+
CROWDIN_WHITESPACE_POST_PROCESSOR,
60+
],
61+
};
62+
// i18next calls this handler after postprocessing a missing key, so
63+
// preserve the boundaries of fallback and instant translations too.
64+
if (typeof options.parseMissingKeyHandler === 'function') {
65+
const originalHandler = options.parseMissingKeyHandler;
66+
rendererOptions.parseMissingKeyHandler = function(key, defaultValue, translationOptions) {
67+
const value = originalHandler.call(this, key, defaultValue, translationOptions);
68+
return _preserveCrowdinWhitespace(value, key, translationOptions);
69+
};
70+
}
71+
return originalInit.call(this, rendererOptions, ...args);
72+
};
73+
CROWDIN_WHITESPACE_RENDERERS.add(renderer);
20174
}
20275

20376
/**
20477
* Keeps preformatted examples out of Crowdin's text-node rewriting. Syntax
205-
* highlighters can replace their children after translation starts, leaving
206-
* the whitespace boundary snapshots pointing at detached nodes.
78+
* highlighters can replace their children after translation starts. Leave their
79+
* markup and whitespace under the highlighter's control.
20780
*/
20881
function _excludeCrowdinCodeBlocks() {
20982
document.querySelectorAll('pre, .fragment').forEach((block) => {
@@ -356,11 +229,10 @@ function initCrowdIn(project = 'LizardByte', platform = 'jekyll') {
356229

357230
// Initialize Crowdin translator
358231
_excludeCrowdinCodeBlocks();
359-
const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries();
232+
_installCrowdinWhitespacePreserver();
360233

361234
globalThis.proxyTranslator.init({
362235
baseUrl: currentBaseUrl,
363-
callback: _createCrowdinTranslationCallback(whitespaceBoundaries),
364236
crawlerIgnoreClasses: [CROWDIN_CODE_BLOCK_CLASS],
365237
distribution: projectSettings[project].distribution,
366238
defaultLanguage: "en",

0 commit comments

Comments
 (0)