@@ -12,198 +12,71 @@ const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist';
1212const CROWDIN_CODE_BLOCK_CLASS = 'crowdin-ignore-code' ;
1313const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100 ;
1414const 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 */
20881function _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