Skip to content

Commit 2624850

Browse files
Add Crowdin picker retry logic and tests
Add retry logic and platform-specific styling for the Crowdin language picker to handle dynamic insertion (CROWDIN_PLATFORM_STYLING_* constants, _applyCrowdinPlatformStyling and _retryCrowdinPlatformStyling). Refactor initCrowdIn to use the new styling helper and adjust global exposure check. Update example rustdoc docs to clarify where to place the hook file. Tighten Jest config: exclude src/js/*-css.js from coverage and enforce 100% global coverage thresholds. Add/extend tests: new global-exposure.test.js, expanded crowdin tests for sphinx/rustdoc retries and fetch behaviors, and additional load-script tests for callbackless load/error handling.
1 parent 429d90f commit 2624850

6 files changed

Lines changed: 257 additions & 34 deletions

File tree

examples/rustdoc/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
//!
88
//! ### CrowdIn
99
//!
10-
//! Install `@lizardbyte/shared-web`, then add a rustdoc HTML hook file:
10+
//! Install `@lizardbyte/shared-web`, then create `rustdoc/shared-web.html`
11+
//! in your crate root. The crate root is the directory that contains
12+
//! `Cargo.toml`, so this example stores the hook at
13+
//! `examples/rustdoc/rustdoc/shared-web.html`.
1114
//!
1215
//! ```html
1316
//! <!--LIZARDBYTE/SHARED-WEB START-->

package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,17 @@
5454
},
5555
"jest": {
5656
"collectCoverageFrom": [
57-
"src/**/*.{js,jsx}"
57+
"src/**/*.{js,jsx}",
58+
"!src/js/*-css.js"
5859
],
60+
"coverageThreshold": {
61+
"global": {
62+
"branches": 100,
63+
"functions": 100,
64+
"lines": 100,
65+
"statements": 100
66+
}
67+
},
5968
"testEnvironment": "jsdom"
6069
},
6170
"scripts": {

src/js/crowdin.js

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const loadScript = require('./load-script');
99
* @type {string}
1010
*/
1111
const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist';
12+
const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100;
13+
const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50;
1214

1315
/**
1416
* Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
@@ -46,6 +48,65 @@ function _installCrowdinFetchInterceptor() {
4648
};
4749
}
4850

51+
/**
52+
* Re-attempts platform styling while Crowdin inserts the language picker.
53+
* @param {string} platform - UI platform ('sphinx' or 'rustdoc').
54+
* @param {number} attempt - Current retry count.
55+
*/
56+
function _retryCrowdinPlatformStyling(platform, attempt) {
57+
if (attempt >= CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS) {
58+
return;
59+
}
60+
61+
globalThis.setTimeout(function() {
62+
_applyCrowdinPlatformStyling(platform, attempt + 1);
63+
}, CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS);
64+
}
65+
66+
/**
67+
* Applies platform-specific placement after the Crowdin picker exists.
68+
* @param {string} platform - UI platform ('sphinx' or 'rustdoc').
69+
* @param {number} attempt - Current retry count.
70+
*/
71+
function _applyCrowdinPlatformStyling(platform, attempt = 0) {
72+
const container = document.getElementById('crowdin-language-picker');
73+
74+
if (platform === 'sphinx') {
75+
const button = document.getElementsByClassName('cr-picker-button')[0];
76+
const sidebar = document.getElementsByClassName('sidebar-sticky')[0];
77+
78+
if (container === null || button === undefined || sidebar === undefined) {
79+
_retryCrowdinPlatformStyling(platform, attempt);
80+
return;
81+
}
82+
83+
container.classList.remove('cr-position-bottom-left');
84+
container.style.width = button.offsetWidth + 10 + 'px';
85+
container.style.position = 'relative';
86+
container.style.left = '10px';
87+
container.style.bottom = '10px';
88+
89+
// move button to related pages
90+
sidebar.appendChild(container);
91+
return;
92+
}
93+
94+
const sidebar = document.querySelector('.sidebar .sidebar-elems') || document.querySelector('.sidebar');
95+
96+
if (container === null || sidebar === null) {
97+
_retryCrowdinPlatformStyling(platform, attempt);
98+
return;
99+
}
100+
101+
container.classList.remove('cr-position-bottom-left');
102+
container.classList.add('rustdoc-crowdin-picker');
103+
container.style.position = 'static';
104+
container.style.left = 'auto';
105+
container.style.bottom = 'auto';
106+
107+
sidebar.appendChild(container);
108+
}
109+
49110
/**
50111
* Initializes Crowdin translation widget based on project and UI platform.
51112
* @param {string} project - Project name ('LizardByte' or 'LizardByte-docs').
@@ -127,42 +188,12 @@ function initCrowdIn(project = 'LizardByte', platform = null) {
127188
return;
128189
}
129190

130-
const container = document.getElementById('crowdin-language-picker');
131-
const button = document.getElementsByClassName('cr-picker-button')[0];
132-
133-
if (platform === 'sphinx') {
134-
container.classList.remove('cr-position-bottom-left')
135-
container.style.width = button.offsetWidth + 10 + 'px';
136-
container.style.position = 'relative';
137-
container.style.left = '10px';
138-
container.style.bottom = '10px';
139-
140-
// get rst versions
141-
const sidebar = document.getElementsByClassName('sidebar-sticky')[0];
142-
143-
// move button to related pages
144-
sidebar.appendChild(container);
145-
}
146-
147-
if (platform === 'rustdoc') {
148-
const sidebar = document.querySelector('.sidebar .sidebar-elems') || document.querySelector('.sidebar');
149-
if (sidebar === null) {
150-
return;
151-
}
152-
153-
container.classList.remove('cr-position-bottom-left');
154-
container.classList.add('rustdoc-crowdin-picker');
155-
container.style.position = 'static';
156-
container.style.left = 'auto';
157-
container.style.bottom = 'auto';
158-
159-
sidebar.appendChild(container);
160-
}
191+
_applyCrowdinPlatformStyling(platform);
161192
});
162193
}
163194

164195
// Expose to the global scope
165-
if (typeof globalThis !== 'undefined' && globalThis.window !== undefined) {
196+
if (globalThis.window !== undefined) {
166197
globalThis.initCrowdIn = initCrowdIn;
167198
}
168199

tests/crowdin.test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,98 @@ describe('initCrowdIn', () => {
137137
expect(container.style.position).toBe('static');
138138
expect(sidebar.contains(container)).toBe(true);
139139
});
140+
141+
it('should wait for sphinx language picker before applying styling', () => {
142+
globalThis.document.body.innerHTML = `
143+
<div class="sidebar-sticky"></div>
144+
`;
145+
146+
initCrowdIn('LizardByte', 'sphinx');
147+
148+
expect(() => {
149+
jest.advanceTimersByTime(0);
150+
}).not.toThrow();
151+
152+
globalThis.document.body.insertAdjacentHTML('beforeend', `
153+
<div id="crowdin-language-picker" class="cr-position-bottom-left">
154+
<div class="cr-picker-button"></div>
155+
<div class="cr-picker-submenu"></div>
156+
</div>
157+
`);
158+
159+
jest.advanceTimersByTime(50);
160+
161+
const container = document.getElementById('crowdin-language-picker');
162+
const sidebar = document.getElementsByClassName('sidebar-sticky')[0];
163+
164+
expect(container.classList.contains('cr-position-bottom-left')).toBe(false);
165+
expect(container.style.position).toBe('relative');
166+
expect(sidebar.contains(container)).toBe(true);
167+
});
168+
169+
it('should wait for rustdoc language picker before applying styling', () => {
170+
globalThis.document.body.innerHTML = `
171+
<nav class="sidebar">
172+
<div class="sidebar-elems"></div>
173+
</nav>
174+
`;
175+
176+
initCrowdIn('LizardByte', 'rustdoc');
177+
178+
expect(() => {
179+
jest.advanceTimersByTime(0);
180+
}).not.toThrow();
181+
182+
globalThis.document.body.insertAdjacentHTML('beforeend', `
183+
<div id="crowdin-language-picker" class="cr-position-bottom-left">
184+
<div class="cr-picker-button"></div>
185+
<div class="cr-picker-submenu"></div>
186+
</div>
187+
`);
188+
189+
jest.advanceTimersByTime(50);
190+
191+
const container = document.getElementById('crowdin-language-picker');
192+
const sidebar = document.getElementsByClassName('sidebar-elems')[0];
193+
194+
expect(container.classList.contains('cr-position-bottom-left')).toBe(false);
195+
expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true);
196+
expect(sidebar.contains(container)).toBe(true);
197+
});
198+
199+
it('should move rustdoc language picker to sidebar when sidebar-elems is unavailable', () => {
200+
globalThis.document.body.innerHTML = `
201+
<nav class="sidebar"></nav>
202+
<div id="crowdin-language-picker" class="cr-position-bottom-left">
203+
<div class="cr-picker-button"></div>
204+
<div class="cr-picker-submenu"></div>
205+
</div>
206+
`;
207+
208+
initCrowdIn('LizardByte', 'rustdoc');
209+
jest.runAllTimers();
210+
211+
const container = document.getElementById('crowdin-language-picker');
212+
const sidebar = document.getElementsByClassName('sidebar')[0];
213+
214+
expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true);
215+
expect(sidebar.contains(container)).toBe(true);
216+
});
217+
218+
it('should stop retrying platform styling after the retry limit', () => {
219+
globalThis.document.body.innerHTML = `
220+
<nav class="sidebar">
221+
<div class="sidebar-elems"></div>
222+
</nav>
223+
`;
224+
225+
initCrowdIn('LizardByte', 'rustdoc');
226+
227+
jest.advanceTimersByTime(0);
228+
jest.advanceTimersByTime(5000);
229+
230+
expect(jest.getTimerCount()).toBe(0);
231+
});
140232
});
141233

142234
describe('Crowdin fetch interceptor', () => {
@@ -234,4 +326,39 @@ describe('Crowdin fetch interceptor', () => {
234326

235327
expect(globalThis.fetch).toBe(fetchAfterFirst);
236328
});
329+
330+
it('should continue when fetch is unavailable', () => {
331+
delete globalThis.fetch;
332+
333+
initCrowdIn();
334+
jest.runAllTimers();
335+
336+
expect(globalThis.proxyTranslator.init).toHaveBeenCalled();
337+
});
338+
339+
it('should pass non-string fetch inputs through unchanged', async () => {
340+
const mockFetch = globalThis.fetch;
341+
const requestLike = new URL('https://example.com/data.json');
342+
343+
initCrowdIn();
344+
jest.runAllTimers();
345+
346+
await globalThis.fetch(requestLike);
347+
348+
const calledUrl = mockFetch.mock.calls[0][0];
349+
expect(calledUrl).toBe(requestLike);
350+
});
351+
352+
it('should pass invalid URL strings through unchanged', async () => {
353+
const mockFetch = globalThis.fetch;
354+
const invalidUrl = 'not a valid absolute URL';
355+
356+
initCrowdIn();
357+
jest.runAllTimers();
358+
359+
await globalThis.fetch(invalidUrl);
360+
361+
const calledUrl = mockFetch.mock.calls[0][0];
362+
expect(calledUrl).toBe(invalidUrl);
363+
});
237364
});

tests/global-exposure.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
5+
import {
6+
describe,
7+
expect,
8+
it,
9+
jest,
10+
} from '@jest/globals';
11+
12+
const exposedModules = [
13+
['formatNumber', '../src/js/format-number'],
14+
['initCrowdIn', '../src/js/crowdin'],
15+
['levenshteinDistance', '../src/js/levenshtein-distance'],
16+
['loadScript', '../src/js/load-script'],
17+
['rankingSorter', '../src/js/ranking-sorter'],
18+
['sleep', '../src/js/sleep'],
19+
];
20+
21+
describe('global browser exposure', () => {
22+
it.each(exposedModules)('should not expose %s when window is unavailable', (globalName, modulePath) => {
23+
jest.resetModules();
24+
25+
const moduleExport = require(modulePath);
26+
27+
expect(globalThis.window).toBeUndefined();
28+
expect(moduleExport).toBeDefined();
29+
expect(globalThis[globalName]).toBeUndefined();
30+
});
31+
});

tests/load-script.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,26 @@ describe('loadScript', () => {
4545
expect(script).toBeInstanceOf(HTMLScriptElement);
4646
expect(script.src).toBe(url);
4747
});
48+
49+
it('should handle load without a callback', () => {
50+
const url = 'https://example.com/test-script.js';
51+
loadScript(url);
52+
53+
const script = document.head.querySelector('script');
54+
55+
expect(() => {
56+
script.onload();
57+
}).not.toThrow();
58+
});
59+
60+
it('should handle failure without a callback', () => {
61+
const url = 'https://example.com/test-script.js';
62+
loadScript(url);
63+
64+
const script = document.head.querySelector('script');
65+
66+
expect(() => {
67+
script.onerror();
68+
}).not.toThrow();
69+
});
4870
});

0 commit comments

Comments
 (0)