@@ -11,6 +11,143 @@ const loadScript = require('./load-script');
1111const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist' ;
1212const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100 ;
1313const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50 ;
14+ const CROWDIN_INLINE_ELEMENT_SELECTOR = [
15+ 'a' ,
16+ 'abbr' ,
17+ 'b' ,
18+ 'cite' ,
19+ 'code' ,
20+ 'del' ,
21+ 'em' ,
22+ 'i' ,
23+ 'ins' ,
24+ 'kbd' ,
25+ 'mark' ,
26+ 'q' ,
27+ 's' ,
28+ 'samp' ,
29+ 'small' ,
30+ 'span' ,
31+ 'strong' ,
32+ 'sub' ,
33+ 'sup' ,
34+ 'time' ,
35+ 'u' ,
36+ 'var' ,
37+ ] . join ( ',' ) ;
38+
39+ /**
40+ * Records whitespace that separates text from inline elements before Crowdin translates the page.
41+ * @returns {Array<{
42+ * node: Text,
43+ * leading: boolean,
44+ * trailing: boolean,
45+ * previousInline: Element|null,
46+ * nextInline: Element|null,
47+ * whitespaceOnly: boolean
48+ * }>} Recorded text-node boundaries.
49+ */
50+ function _captureCrowdinWhitespaceBoundaries ( ) {
51+ const boundaries = [ ] ;
52+ const walker = document . createTreeWalker ( document . body , globalThis . NodeFilter . SHOW_TEXT ) ;
53+ let node = walker . nextNode ( ) ;
54+
55+ 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 ) ;
62+
63+ if ( leading || trailing ) {
64+ boundaries . push ( {
65+ node,
66+ leading,
67+ trailing,
68+ previousInline : previousIsInline ? node . previousSibling : null ,
69+ nextInline : nextIsInline ? node . nextSibling : null ,
70+ whitespaceOnly : / ^ \s * $ / . test ( node . data ) ,
71+ } ) ;
72+ }
73+
74+ node = walker . nextNode ( ) ;
75+ }
76+
77+ return boundaries ;
78+ }
79+
80+ /**
81+ * Finds the current text node for a boundary after Crowdin changes the DOM.
82+ * @param {Object } boundary Recorded whitespace boundary.
83+ * @returns {Text|null } Current or recreated text node.
84+ */
85+ function _resolveCrowdinWhitespaceNode ( boundary ) {
86+ if ( boundary . node . isConnected ) return boundary . node ;
87+
88+ const previousReplacement = boundary . previousInline ?. nextSibling ;
89+ if ( previousReplacement instanceof globalThis . Text && previousReplacement . isConnected ) {
90+ boundary . node = previousReplacement ;
91+ return boundary . node ;
92+ }
93+ const nextReplacement = boundary . nextInline ?. previousSibling ;
94+ if ( nextReplacement instanceof globalThis . Text && nextReplacement . isConnected ) {
95+ boundary . node = nextReplacement ;
96+ return boundary . node ;
97+ }
98+ if ( ! boundary . whitespaceOnly ) return null ;
99+
100+ const replacement = document . createTextNode ( '' ) ;
101+ if ( boundary . previousInline ?. isConnected ) {
102+ boundary . previousInline . after ( replacement ) ;
103+ } else if ( boundary . nextInline ?. isConnected ) {
104+ boundary . nextInline . before ( replacement ) ;
105+ } else {
106+ return null ;
107+ }
108+
109+ boundary . node = replacement ;
110+ return boundary . node ;
111+ }
112+
113+ /**
114+ * Restores whitespace that Crowdin removed from translated text-node boundaries.
115+ * @param {Array<Object> } boundaries Recorded text-node boundaries.
116+ */
117+ function _restoreCrowdinWhitespaceBoundaries ( boundaries ) {
118+ boundaries . forEach ( ( boundary ) => {
119+ const node = _resolveCrowdinWhitespaceNode ( boundary ) ;
120+ if ( node === null ) return ;
121+
122+ if ( boundary . leading && ! / ^ \s / . test ( node . data ) ) {
123+ node . data = ' ' + node . data ;
124+ }
125+ if ( boundary . trailing && ! / \s $ / . test ( node . data ) ) {
126+ node . data += ' ' ;
127+ }
128+ } ) ;
129+ }
130+
131+ /**
132+ * Creates the Website Translator callback and observes later translation mutations.
133+ * @param {Array<Object> } boundaries Recorded text-node boundaries.
134+ * @returns {Function } Website Translator callback.
135+ */
136+ function _createCrowdinTranslationCallback ( boundaries ) {
137+ const translationObserver = new globalThis . MutationObserver ( restoreAndObserve ) ;
138+
139+ function restoreAndObserve ( ) {
140+ translationObserver . disconnect ( ) ;
141+ _restoreCrowdinWhitespaceBoundaries ( boundaries ) ;
142+ translationObserver . observe ( document . body , {
143+ characterData : true ,
144+ childList : true ,
145+ subtree : true ,
146+ } ) ;
147+ }
148+
149+ return restoreAndObserve ;
150+ }
14151
15152/**
16153 * Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
@@ -171,8 +308,11 @@ function initCrowdIn(project = 'LizardByte', platform = null) {
171308 let currentBaseUrl = globalThis . location . origin ;
172309
173310 // Initialize Crowdin translator
311+ const whitespaceBoundaries = _captureCrowdinWhitespaceBoundaries ( ) ;
312+
174313 globalThis . proxyTranslator . init ( {
175314 baseUrl : currentBaseUrl ,
315+ callback : _createCrowdinTranslationCallback ( whitespaceBoundaries ) ,
176316 distribution : projectSettings [ project ] . distribution ,
177317 defaultLanguage : "en" ,
178318 languageTitles : languageTitles ,
0 commit comments