From 429d90fa208ed008b049afd051571f4aa3691407 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 22 May 2026 15:26:39 -0400 Subject: [PATCH 1/4] feat(examples): add rust-doc example --- .gitignore | 1 + .readthedocs.yaml | 4 ++ eslint.config.mjs | 1 + examples/rustdoc/.cargo/config.toml | 5 ++ examples/rustdoc/Cargo.lock | 7 +++ examples/rustdoc/Cargo.toml | 15 ++++++ examples/rustdoc/package-lock.json | 60 +++++++++++++++++++++ examples/rustdoc/package.json | 11 ++++ examples/rustdoc/rustdoc/build.js | 28 ++++++++++ examples/rustdoc/rustdoc/shared-web.html | 20 +++++++ examples/rustdoc/src/lib.rs | 68 ++++++++++++++++++++++++ jsdoc.json | 7 +++ src/css/crowdin-rustdoc.scss | 30 +++++++++++ src/js/crowdin-rustdoc-css.js | 1 + src/js/crowdin.js | 21 ++++++-- tests/crowdin.test.js | 22 +++++++- webpack.config.js | 1 + 17 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 examples/rustdoc/.cargo/config.toml create mode 100644 examples/rustdoc/Cargo.lock create mode 100644 examples/rustdoc/Cargo.toml create mode 100644 examples/rustdoc/package-lock.json create mode 100644 examples/rustdoc/package.json create mode 100644 examples/rustdoc/rustdoc/build.js create mode 100644 examples/rustdoc/rustdoc/shared-web.html create mode 100644 examples/rustdoc/src/lib.rs create mode 100644 src/css/crowdin-rustdoc.scss create mode 100644 src/js/crowdin-rustdoc-css.js diff --git a/.gitignore b/.gitignore index fd133a2..923ebbc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ coverage/ _readthedocs/ build/ dist/ +target/ lizardbyte-shared-web*.tgz diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f94a8a8..e5e5541 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,6 +6,7 @@ build: nodejs: "22" python: "3.13" ruby: "3.3" + rust: "1.91" commands: - 'echo "output directory: ${READTHEDOCS_OUTPUT}html"' # shared-web build @@ -26,5 +27,8 @@ build: - cd examples/sphinx && npm ci # we need to include scripts for postinstall actions - cd examples/sphinx && npm run build - cd examples/sphinx && npm run lint + # rustdoc example build + - cd examples/rustdoc && npm ci --ignore-scripts + - cd examples/rustdoc && npm run build # debug output - cd ${READTHEDOCS_OUTPUT}html && ls -la -R diff --git a/eslint.config.mjs b/eslint.config.mjs index 0b121f1..22fbbc4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,7 @@ export default [ "coverage/**", "dist/**", "docs/**", // generated JSDoc output + "examples/**/build/**", // generated example output ], }, { diff --git a/examples/rustdoc/.cargo/config.toml b/examples/rustdoc/.cargo/config.toml new file mode 100644 index 0000000..11faac5 --- /dev/null +++ b/examples/rustdoc/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +rustdocflags = [ + "--html-after-content", + "rustdoc/shared-web.html", +] diff --git a/examples/rustdoc/Cargo.lock b/examples/rustdoc/Cargo.lock new file mode 100644 index 0000000..c399348 --- /dev/null +++ b/examples/rustdoc/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "shared-web-rustdoc-example" +version = "0.0.0" diff --git a/examples/rustdoc/Cargo.toml b/examples/rustdoc/Cargo.toml new file mode 100644 index 0000000..b6f0717 --- /dev/null +++ b/examples/rustdoc/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "shared-web-rustdoc-example" +version = "0.0.0" +edition = "2021" +description = "Example use of @lizardbyte/shared-web in rustdoc html." +license = "AGPL-3.0-only" +publish = false +exclude = [ + "build", + "node_modules", +] + +[lib] +name = "shared_web_rustdoc_example" +path = "src/lib.rs" diff --git a/examples/rustdoc/package-lock.json b/examples/rustdoc/package-lock.json new file mode 100644 index 0000000..86966b5 --- /dev/null +++ b/examples/rustdoc/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "shared-web-rustdoc-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shared-web-rustdoc-example", + "version": "0.0.0", + "dependencies": { + "@lizardbyte/shared-web": "file:../.." + } + }, + "../..": { + "name": "@lizardbyte/shared-web", + "version": "0.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "@fortawesome/fontawesome-free": "7.2.0", + "bootstrap": "5.3.8" + }, + "devDependencies": { + "@babel/core": "7.29.0", + "@babel/preset-env": "7.29.5", + "@codecov/webpack-plugin": "2.0.1", + "@eslint/js": "10.0.1", + "@jest/globals": "30.4.1", + "babel-loader": "10.1.1", + "clean-jsdoc-theme": "4.3.2", + "cross-env": "10.1.0", + "css-loader": "7.1.4", + "eslint": "10.4.0", + "eslint-plugin-jest": "29.15.2", + "globals": "17.6.0", + "jest": "30.4.2", + "jest-environment-jsdom": "30.4.1", + "jest-junit": "17.0.0", + "jsdoc": "4.0.5", + "mini-css-extract-plugin": "2.10.2", + "node-fetch": "3.3.2", + "npm-run-all2": "9.0.0", + "postcss": "8.5.15", + "postcss-loader": "8.2.1", + "postcss-preset-env": "11.3.0", + "sass": "1.100.0", + "sass-loader": "17.0.0", + "webpack": "5.107.1", + "webpack-cli": "7.0.2", + "webpack-dev-server": "5.2.4" + }, + "funding": { + "url": "https://app.lizardbyte.dev" + } + }, + "node_modules/@lizardbyte/shared-web": { + "resolved": "../..", + "link": true + } + } +} diff --git a/examples/rustdoc/package.json b/examples/rustdoc/package.json new file mode 100644 index 0000000..d82197a --- /dev/null +++ b/examples/rustdoc/package.json @@ -0,0 +1,11 @@ +{ + "name": "shared-web-rustdoc-example", + "version": "0.0.0", + "description": "Example use of @lizardbyte/shared-web in rustdoc html.", + "dependencies": { + "@lizardbyte/shared-web": "file:../.." + }, + "scripts": { + "build": "node rustdoc/build.js" + } +} diff --git a/examples/rustdoc/rustdoc/build.js b/examples/rustdoc/rustdoc/build.js new file mode 100644 index 0000000..e7c2915 --- /dev/null +++ b/examples/rustdoc/rustdoc/build.js @@ -0,0 +1,28 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const exampleDir = path.resolve(__dirname, '..'); +const readTheDocsOutput = process.env.READTHEDOCS_OUTPUT ? + path.resolve(process.env.READTHEDOCS_OUTPUT) : + path.join(exampleDir, 'build'); +const targetDir = path.join(readTheDocsOutput, 'html', 'rustdoc'); +const docDir = path.join(targetDir, 'doc'); +const sharedWebDist = path.join(exampleDir, 'node_modules', '@lizardbyte', 'shared-web', 'dist'); + +const cargoResult = spawnSync('cargo', ['doc', '--no-deps', '--target-dir', targetDir], { + stdio: 'inherit', +}); + +if (cargoResult.status !== 0) { + process.exit(cargoResult.status || 1); +} + +fs.mkdirSync(docDir, { recursive: true }); + +[ + 'crowdin.js', + 'crowdin-rustdoc-css.css', +].forEach((asset) => { + fs.copyFileSync(path.join(sharedWebDist, asset), path.join(docDir, asset)); +}); diff --git a/examples/rustdoc/rustdoc/shared-web.html b/examples/rustdoc/rustdoc/shared-web.html new file mode 100644 index 0000000..9b41cfd --- /dev/null +++ b/examples/rustdoc/rustdoc/shared-web.html @@ -0,0 +1,20 @@ + + + diff --git a/examples/rustdoc/src/lib.rs b/examples/rustdoc/src/lib.rs new file mode 100644 index 0000000..d7fad91 --- /dev/null +++ b/examples/rustdoc/src/lib.rs @@ -0,0 +1,68 @@ +//! # shared-web rustdoc sample +//! +//! This is a sample crate for rustdoc. It demonstrates how the shared-web +//! CrowdIn language selector appears in Rust API documentation. +//! +//! ## Widgets +//! +//! ### CrowdIn +//! +//! Install `@lizardbyte/shared-web`, then add a rustdoc HTML hook file: +//! +//! ```html +//! +//! +//! +//! ``` +//! +//! Configure Cargo to pass the hook to rustdoc: +//! +//! ```toml +//! [build] +//! rustdocflags = [ +//! "--html-after-content", +//! "rustdoc/shared-web.html", +//! ] +//! ``` +//! +//! Copy `crowdin.js` and `crowdin-rustdoc-css.css` from +//! `node_modules/@lizardbyte/shared-web/dist` into the generated rustdoc root +//! so the hook can load them with rustdoc's page-relative `rootPath`. + +/// Returns a greeting for the provided project name. +/// +/// # Examples +/// +/// ``` +/// let greeting = shared_web_rustdoc_example::greeting("shared-web"); +/// assert_eq!(greeting, "Hello, shared-web!"); +/// ``` +pub fn greeting(project: &str) -> String { + format!("Hello, {project}!") +} + +/// Example metadata rendered by rustdoc. +#[derive(Debug, Eq, PartialEq)] +pub struct Widget { + /// Human-readable widget name. + pub name: String, + /// Whether the widget is enabled in the rendered page. + pub enabled: bool, +} diff --git a/jsdoc.json b/jsdoc.json index 06e5364..0cec9c5 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -40,6 +40,13 @@ "class": "", "id": "sphinx-sample" }, + { + "title": "Rustdoc Sample", + "link": "rustdoc/doc/shared_web_rustdoc_example", + "target": "_blank", + "class": "", + "id": "rustdoc-sample" + }, { "title": "❤ Donate", "link": "https://app.lizardbyte.dev", diff --git a/src/css/crowdin-rustdoc.scss b/src/css/crowdin-rustdoc.scss new file mode 100644 index 0000000..946cba8 --- /dev/null +++ b/src/css/crowdin-rustdoc.scss @@ -0,0 +1,30 @@ +#crowdin-language-picker.rustdoc-crowdin-picker { + margin: 1rem; + width: calc(100% - 2rem); +} + +#crowdin-language-picker.rustdoc-crowdin-picker .cr-picker-button { + box-sizing: border-box; + width: 100%; +} + +#crowdin-language-picker .cr-picker-button, +#crowdin-language-picker .cr-picker-submenu { + background-color: var(--sidebar-background-color, var(--main-background-color, #ffffff)) !important; + border: 1px solid var(--border-color, #dddddd) !important; + color: var(--main-color, #111111) !important; +} + +#crowdin-language-picker .cr-picker-button:hover, +#crowdin-language-picker .cr-picker-submenu > a:hover { + background-color: var(--main-color, #111111) !important; + color: var(--main-background-color, #ffffff) !important; +} + +#crowdin-language-picker .cr-picker-submenu > a { + color: var(--main-color, #111111) !important; +} + +#crowdin-language-picker .cr-picker-submenu > a.cr-selected { + color: var(--link-color, #3873ad) !important; +} diff --git a/src/js/crowdin-rustdoc-css.js b/src/js/crowdin-rustdoc-css.js new file mode 100644 index 0000000..7669680 --- /dev/null +++ b/src/js/crowdin-rustdoc-css.js @@ -0,0 +1 @@ +import "../css/crowdin-rustdoc.scss"; diff --git a/src/js/crowdin.js b/src/js/crowdin.js index 69fadc4..1a40e80 100644 --- a/src/js/crowdin.js +++ b/src/js/crowdin.js @@ -49,7 +49,7 @@ function _installCrowdinFetchInterceptor() { /** * Initializes Crowdin translation widget based on project and UI platform. * @param {string} project - Project name ('LizardByte' or 'LizardByte-docs'). - * @param {string|null} platform - UI platform ('sphinx', or null). + * @param {string|null} platform - UI platform ('sphinx', 'rustdoc', or null). */ function initCrowdIn(project = 'LizardByte', platform = null) { // Input validation @@ -57,8 +57,8 @@ function initCrowdIn(project = 'LizardByte', platform = null) { console.error('Invalid project. Must be "LizardByte" or "LizardByte-docs"'); return; } - if (!['sphinx', null].includes(platform)) { - console.error('Invalid UI. Must be "sphinx", or null'); + if (!['sphinx', 'rustdoc', null].includes(platform)) { + console.error('Invalid UI. Must be "sphinx", "rustdoc", or null'); return; } @@ -143,6 +143,21 @@ function initCrowdIn(project = 'LizardByte', platform = null) { // move button to related pages sidebar.appendChild(container); } + + if (platform === 'rustdoc') { + const sidebar = document.querySelector('.sidebar .sidebar-elems') || document.querySelector('.sidebar'); + if (sidebar === null) { + return; + } + + container.classList.remove('cr-position-bottom-left'); + container.classList.add('rustdoc-crowdin-picker'); + container.style.position = 'static'; + container.style.left = 'auto'; + container.style.bottom = 'auto'; + + sidebar.appendChild(container); + } }); } diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index 437e16d..c079b84 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -29,6 +29,11 @@ describe('initCrowdIn', () => { + + + `; // Mock console.error @@ -60,7 +65,7 @@ describe('initCrowdIn', () => { it('should validate platform parameter', () => { initCrowdIn('LizardByte', 'invalidPlatform'); - expect(console.error).toHaveBeenCalledWith('Invalid UI. Must be "sphinx", or null'); + expect(console.error).toHaveBeenCalledWith('Invalid UI. Must be "sphinx", "rustdoc", or null'); }); it('should initialize proxyTranslator with LizardByte settings', () => { @@ -117,6 +122,21 @@ describe('initCrowdIn', () => { expect(container.style.position).toBe('relative'); expect(sidebar.contains(container)).toBe(true); }); + + it('should apply rustdoc styling', () => { + initCrowdIn('LizardByte', 'rustdoc'); + + // Simulate script loading and UI styling timeout + jest.runAllTimers(); + + const container = document.getElementById('crowdin-language-picker'); + const sidebar = document.getElementsByClassName('sidebar-elems')[0]; + + expect(container.classList.contains('cr-position-bottom-left')).toBe(false); + expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true); + expect(container.style.position).toBe('static'); + expect(sidebar.contains(container)).toBe(true); + }); }); describe('Crowdin fetch interceptor', () => { diff --git a/webpack.config.js b/webpack.config.js index d504356..2ffecf2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,6 +11,7 @@ let config = { 'crowdin-clean-jsdoc-css': './src/js/crowdin-clean-jsdoc-css', 'crowdin-doxygen-css': './src/js/crowdin-doxygen-css', 'crowdin-furo-css': './src/js/crowdin-furo-css', + 'crowdin-rustdoc-css': './src/js/crowdin-rustdoc-css', 'format-number': './src/js/format-number', 'levenshtein-distance': './src/js/levenshtein-distance', 'lizardbyte-css': './src/js/lizardbyte-css', From 1edb51b58f5a380cf6539940a204fbb476a03f2f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 22 May 2026 15:52:21 -0400 Subject: [PATCH 2/4] Add rustdoc asset copying, styling retries, and tests Copy crowdin assets beside every generated rustdoc HTML page and simplify the rustdoc hook to load those files directly. Add a recursive finder for generated HTML dirs in the example build script and update example docs to explain the hook location and behavior. Improve crowdin.js by adding retryable platform-specific styling logic for Sphinx and rustdoc, minor constants, and a small refactor to apply styling via a helper; also harden fetch interception edge cases. Add new and expanded Jest tests (platform styling retries, fetch-edge cases, global exposure in Node, and load-script no-callback handling) and tighten test coverage settings in package.json while excluding generated *-css.js files from coverage. --- examples/rustdoc/rustdoc/build.js | 33 +++++- examples/rustdoc/rustdoc/shared-web.html | 19 +--- examples/rustdoc/src/lib.rs | 37 ++----- package.json | 11 +- src/js/crowdin.js | 95 +++++++++++------ tests/crowdin.test.js | 127 +++++++++++++++++++++++ tests/global-exposure.test.js | 31 ++++++ tests/load-script.test.js | 22 ++++ 8 files changed, 295 insertions(+), 80 deletions(-) create mode 100644 tests/global-exposure.test.js diff --git a/examples/rustdoc/rustdoc/build.js b/examples/rustdoc/rustdoc/build.js index e7c2915..01ac9f2 100644 --- a/examples/rustdoc/rustdoc/build.js +++ b/examples/rustdoc/rustdoc/build.js @@ -9,6 +9,30 @@ const readTheDocsOutput = process.env.READTHEDOCS_OUTPUT ? const targetDir = path.join(readTheDocsOutput, 'html', 'rustdoc'); const docDir = path.join(targetDir, 'doc'); const sharedWebDist = path.join(exampleDir, 'node_modules', '@lizardbyte', 'shared-web', 'dist'); +const sharedWebAssets = [ + 'crowdin.js', + 'crowdin-rustdoc-css.css', +]; + +function findHtmlDirectories(dir) { + const directories = new Set(); + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + entries.forEach((entry) => { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + findHtmlDirectories(entryPath).forEach((htmlDir) => directories.add(htmlDir)); + return; + } + + if (entry.isFile() && entry.name.endsWith('.html')) { + directories.add(dir); + } + }); + + return directories; +} const cargoResult = spawnSync('cargo', ['doc', '--no-deps', '--target-dir', targetDir], { stdio: 'inherit', @@ -20,9 +44,8 @@ if (cargoResult.status !== 0) { fs.mkdirSync(docDir, { recursive: true }); -[ - 'crowdin.js', - 'crowdin-rustdoc-css.css', -].forEach((asset) => { - fs.copyFileSync(path.join(sharedWebDist, asset), path.join(docDir, asset)); +findHtmlDirectories(docDir).forEach((htmlDir) => { + sharedWebAssets.forEach((asset) => { + fs.copyFileSync(path.join(sharedWebDist, asset), path.join(htmlDir, asset)); + }); }); diff --git a/examples/rustdoc/rustdoc/shared-web.html b/examples/rustdoc/rustdoc/shared-web.html index 9b41cfd..21b31c8 100644 --- a/examples/rustdoc/rustdoc/shared-web.html +++ b/examples/rustdoc/rustdoc/shared-web.html @@ -1,20 +1,7 @@ + + diff --git a/examples/rustdoc/src/lib.rs b/examples/rustdoc/src/lib.rs index d7fad91..d0921d5 100644 --- a/examples/rustdoc/src/lib.rs +++ b/examples/rustdoc/src/lib.rs @@ -7,30 +7,14 @@ //! //! ### CrowdIn //! -//! Install `@lizardbyte/shared-web`, then add a rustdoc HTML hook file: +//! Install `@lizardbyte/shared-web`, then create the rustdoc HTML hook at +//! `rustdoc/shared-web.html` in your crate root. The crate root is the +//! directory that contains `Cargo.toml`, so this example stores the hook at +//! `examples/rustdoc/rustdoc/shared-web.html`. //! -//! ```html -//! -//! -//! -//! ``` +//! The hook loads `crowdin.js` and `crowdin-rustdoc-css.css` from the same +//! directory as each generated HTML page. The example build script copies +//! those two files beside every generated `.html` file. //! //! Configure Cargo to pass the hook to rustdoc: //! @@ -42,9 +26,10 @@ //! ] //! ``` //! -//! Copy `crowdin.js` and `crowdin-rustdoc-css.css` from -//! `node_modules/@lizardbyte/shared-web/dist` into the generated rustdoc root -//! so the hook can load them with rustdoc's page-relative `rootPath`. +//! When adapting this outside the example, copy `crowdin.js` and +//! `crowdin-rustdoc-css.css` from `node_modules/@lizardbyte/shared-web/dist` +//! beside each generated rustdoc HTML page, or adjust the hook paths to point +//! at a location that every generated page can reach. /// Returns a greeting for the provided project name. /// diff --git a/package.json b/package.json index efce28d..a1beb25 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,17 @@ }, "jest": { "collectCoverageFrom": [ - "src/**/*.{js,jsx}" + "src/**/*.{js,jsx}", + "!src/js/*-css.js" ], + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + }, "testEnvironment": "jsdom" }, "scripts": { diff --git a/src/js/crowdin.js b/src/js/crowdin.js index 1a40e80..875be0c 100644 --- a/src/js/crowdin.js +++ b/src/js/crowdin.js @@ -9,6 +9,8 @@ const loadScript = require('./load-script'); * @type {string} */ const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/i18n@dist'; +const CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS = 100; +const CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS = 50; /** * Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to @@ -46,6 +48,65 @@ function _installCrowdinFetchInterceptor() { }; } +/** + * Re-attempts platform styling while Crowdin inserts the language picker. + * @param {string} platform - UI platform ('sphinx' or 'rustdoc'). + * @param {number} attempt - Current retry count. + */ +function _retryCrowdinPlatformStyling(platform, attempt) { + if (attempt >= CROWDIN_PLATFORM_STYLING_MAX_ATTEMPTS) { + return; + } + + globalThis.setTimeout(function() { + _applyCrowdinPlatformStyling(platform, attempt + 1); + }, CROWDIN_PLATFORM_STYLING_RETRY_DELAY_MS); +} + +/** + * Applies platform-specific placement after the Crowdin picker exists. + * @param {string} platform - UI platform ('sphinx' or 'rustdoc'). + * @param {number} attempt - Current retry count. + */ +function _applyCrowdinPlatformStyling(platform, attempt = 0) { + const container = document.getElementById('crowdin-language-picker'); + + if (platform === 'sphinx') { + const button = document.getElementsByClassName('cr-picker-button')[0]; + const sidebar = document.getElementsByClassName('sidebar-sticky')[0]; + + if (container === null || button === undefined || sidebar === undefined) { + _retryCrowdinPlatformStyling(platform, attempt); + return; + } + + container.classList.remove('cr-position-bottom-left'); + container.style.width = button.offsetWidth + 10 + 'px'; + container.style.position = 'relative'; + container.style.left = '10px'; + container.style.bottom = '10px'; + + // move button to related pages + sidebar.appendChild(container); + return; + } + + const sidebar = document.querySelector('.sidebar .sidebar-elems') || document.querySelector('.sidebar'); + + if (container === null || sidebar === null) { + _retryCrowdinPlatformStyling(platform, attempt); + return; + } + + container.classList.remove('cr-position-bottom-left'); + container.classList.add('rustdoc-crowdin-picker'); + container.style.position = 'static'; + container.style.left = 'auto'; + container.style.bottom = 'auto'; + + sidebar.appendChild(container); +} + /** * Initializes Crowdin translation widget based on project and UI platform. * @param {string} project - Project name ('LizardByte' or 'LizardByte-docs'). @@ -127,42 +188,12 @@ function initCrowdIn(project = 'LizardByte', platform = null) { return; } - const container = document.getElementById('crowdin-language-picker'); - const button = document.getElementsByClassName('cr-picker-button')[0]; - - if (platform === 'sphinx') { - container.classList.remove('cr-position-bottom-left') - container.style.width = button.offsetWidth + 10 + 'px'; - container.style.position = 'relative'; - container.style.left = '10px'; - container.style.bottom = '10px'; - - // get rst versions - const sidebar = document.getElementsByClassName('sidebar-sticky')[0]; - - // move button to related pages - sidebar.appendChild(container); - } - - if (platform === 'rustdoc') { - const sidebar = document.querySelector('.sidebar .sidebar-elems') || document.querySelector('.sidebar'); - if (sidebar === null) { - return; - } - - container.classList.remove('cr-position-bottom-left'); - container.classList.add('rustdoc-crowdin-picker'); - container.style.position = 'static'; - container.style.left = 'auto'; - container.style.bottom = 'auto'; - - sidebar.appendChild(container); - } + _applyCrowdinPlatformStyling(platform); }); } // Expose to the global scope -if (typeof globalThis !== 'undefined' && globalThis.window !== undefined) { +if (globalThis.window !== undefined) { globalThis.initCrowdIn = initCrowdIn; } diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index c079b84..5ee3e5d 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -137,6 +137,98 @@ describe('initCrowdIn', () => { expect(container.style.position).toBe('static'); expect(sidebar.contains(container)).toBe(true); }); + + it('should wait for sphinx language picker before applying styling', () => { + globalThis.document.body.innerHTML = ` + + `; + + initCrowdIn('LizardByte', 'sphinx'); + + expect(() => { + jest.advanceTimersByTime(0); + }).not.toThrow(); + + globalThis.document.body.insertAdjacentHTML('beforeend', ` +
+
+
+
+ `); + + jest.advanceTimersByTime(50); + + const container = document.getElementById('crowdin-language-picker'); + const sidebar = document.getElementsByClassName('sidebar-sticky')[0]; + + expect(container.classList.contains('cr-position-bottom-left')).toBe(false); + expect(container.style.position).toBe('relative'); + expect(sidebar.contains(container)).toBe(true); + }); + + it('should wait for rustdoc language picker before applying styling', () => { + globalThis.document.body.innerHTML = ` + + `; + + initCrowdIn('LizardByte', 'rustdoc'); + + expect(() => { + jest.advanceTimersByTime(0); + }).not.toThrow(); + + globalThis.document.body.insertAdjacentHTML('beforeend', ` +
+
+
+
+ `); + + jest.advanceTimersByTime(50); + + const container = document.getElementById('crowdin-language-picker'); + const sidebar = document.getElementsByClassName('sidebar-elems')[0]; + + expect(container.classList.contains('cr-position-bottom-left')).toBe(false); + expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true); + expect(sidebar.contains(container)).toBe(true); + }); + + it('should move rustdoc language picker to sidebar when sidebar-elems is unavailable', () => { + globalThis.document.body.innerHTML = ` + +
+
+
+
+ `; + + initCrowdIn('LizardByte', 'rustdoc'); + jest.runAllTimers(); + + const container = document.getElementById('crowdin-language-picker'); + const sidebar = document.getElementsByClassName('sidebar')[0]; + + expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true); + expect(sidebar.contains(container)).toBe(true); + }); + + it('should stop retrying platform styling after the retry limit', () => { + globalThis.document.body.innerHTML = ` + + `; + + initCrowdIn('LizardByte', 'rustdoc'); + + jest.advanceTimersByTime(0); + jest.advanceTimersByTime(5000); + + expect(jest.getTimerCount()).toBe(0); + }); }); describe('Crowdin fetch interceptor', () => { @@ -234,4 +326,39 @@ describe('Crowdin fetch interceptor', () => { expect(globalThis.fetch).toBe(fetchAfterFirst); }); + + it('should continue when fetch is unavailable', () => { + delete globalThis.fetch; + + initCrowdIn(); + jest.runAllTimers(); + + expect(globalThis.proxyTranslator.init).toHaveBeenCalled(); + }); + + it('should pass non-string fetch inputs through unchanged', async () => { + const mockFetch = globalThis.fetch; + const requestLike = new URL('https://example.com/data.json'); + + initCrowdIn(); + jest.runAllTimers(); + + await globalThis.fetch(requestLike); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toBe(requestLike); + }); + + it('should pass invalid URL strings through unchanged', async () => { + const mockFetch = globalThis.fetch; + const invalidUrl = 'not a valid absolute URL'; + + initCrowdIn(); + jest.runAllTimers(); + + await globalThis.fetch(invalidUrl); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toBe(invalidUrl); + }); }); diff --git a/tests/global-exposure.test.js b/tests/global-exposure.test.js new file mode 100644 index 0000000..88ff8d1 --- /dev/null +++ b/tests/global-exposure.test.js @@ -0,0 +1,31 @@ +/** + * @jest-environment node + */ + +import { + describe, + expect, + it, + jest, +} from '@jest/globals'; + +const exposedModules = [ + ['formatNumber', '../src/js/format-number'], + ['initCrowdIn', '../src/js/crowdin'], + ['levenshteinDistance', '../src/js/levenshtein-distance'], + ['loadScript', '../src/js/load-script'], + ['rankingSorter', '../src/js/ranking-sorter'], + ['sleep', '../src/js/sleep'], +]; + +describe('global browser exposure', () => { + it.each(exposedModules)('should not expose %s when window is unavailable', (globalName, modulePath) => { + jest.resetModules(); + + const moduleExport = require(modulePath); + + expect(globalThis.window).toBeUndefined(); + expect(moduleExport).toBeDefined(); + expect(globalThis[globalName]).toBeUndefined(); + }); +}); diff --git a/tests/load-script.test.js b/tests/load-script.test.js index 4f4a097..2c905d9 100644 --- a/tests/load-script.test.js +++ b/tests/load-script.test.js @@ -45,4 +45,26 @@ describe('loadScript', () => { expect(script).toBeInstanceOf(HTMLScriptElement); expect(script.src).toBe(url); }); + + it('should handle load without a callback', () => { + const url = 'https://example.com/test-script.js'; + loadScript(url); + + const script = document.head.querySelector('script'); + + expect(() => { + script.onload(); + }).not.toThrow(); + }); + + it('should handle failure without a callback', () => { + const url = 'https://example.com/test-script.js'; + loadScript(url); + + const script = document.head.querySelector('script'); + + expect(() => { + script.onerror(); + }).not.toThrow(); + }); }); From 12bfbaa0cf53a2353b288ddbf8a05ac80b387a5d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 22 May 2026 17:07:29 -0400 Subject: [PATCH 3/4] Run cargo doc in npm script; update build/tests Move cargo doc invocation out of rustdoc/build.js and into the npm "build" script (package.json). Simplify build.js to assume docs are produced under examples/rustdoc/build/html/rustdoc and remove the spawnSync cargo call and READTHEDOCS_OUTPUT branching. Update .readthedocs.yaml to create and copy the built rustdoc HTML into the ReadTheDocs output directory. Refactor crowin tests to add a shared expectDelayedStyling helper and reuse delayed picker markup to reduce duplication. --- .readthedocs.yaml | 2 + examples/rustdoc/package.json | 2 +- examples/rustdoc/rustdoc/build.js | 14 +---- tests/crowdin.test.js | 97 ++++++++++++++----------------- 4 files changed, 49 insertions(+), 66 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e5e5541..7dc18d4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -30,5 +30,7 @@ build: # rustdoc example build - cd examples/rustdoc && npm ci --ignore-scripts - cd examples/rustdoc && npm run build + - mkdir -p ${READTHEDOCS_OUTPUT}html/rustdoc + - cp -r examples/rustdoc/build/html/rustdoc/* ${READTHEDOCS_OUTPUT}html/rustdoc/ # debug output - cd ${READTHEDOCS_OUTPUT}html && ls -la -R diff --git a/examples/rustdoc/package.json b/examples/rustdoc/package.json index d82197a..b423a01 100644 --- a/examples/rustdoc/package.json +++ b/examples/rustdoc/package.json @@ -6,6 +6,6 @@ "@lizardbyte/shared-web": "file:../.." }, "scripts": { - "build": "node rustdoc/build.js" + "build": "cargo doc --no-deps --target-dir build/html/rustdoc && node rustdoc/build.js" } } diff --git a/examples/rustdoc/rustdoc/build.js b/examples/rustdoc/rustdoc/build.js index 01ac9f2..852042e 100644 --- a/examples/rustdoc/rustdoc/build.js +++ b/examples/rustdoc/rustdoc/build.js @@ -1,12 +1,8 @@ const fs = require('node:fs'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); const exampleDir = path.resolve(__dirname, '..'); -const readTheDocsOutput = process.env.READTHEDOCS_OUTPUT ? - path.resolve(process.env.READTHEDOCS_OUTPUT) : - path.join(exampleDir, 'build'); -const targetDir = path.join(readTheDocsOutput, 'html', 'rustdoc'); +const targetDir = path.join(exampleDir, 'build', 'html', 'rustdoc'); const docDir = path.join(targetDir, 'doc'); const sharedWebDist = path.join(exampleDir, 'node_modules', '@lizardbyte', 'shared-web', 'dist'); const sharedWebAssets = [ @@ -34,14 +30,6 @@ function findHtmlDirectories(dir) { return directories; } -const cargoResult = spawnSync('cargo', ['doc', '--no-deps', '--target-dir', targetDir], { - stdio: 'inherit', -}); - -if (cargoResult.status !== 0) { - process.exit(cargoResult.status || 1); -} - fs.mkdirSync(docDir, { recursive: true }); findHtmlDirectories(docDir).forEach((htmlDir) => { diff --git a/tests/crowdin.test.js b/tests/crowdin.test.js index 5ee3e5d..faf4208 100644 --- a/tests/crowdin.test.js +++ b/tests/crowdin.test.js @@ -17,6 +17,37 @@ jest.mock('../src/js/load-script', () => { const initCrowdIn = require('../src/js/crowdin'); +const delayedPickerMarkup = ` +
+
+
+
+`; + +function expectDelayedStyling(options) { + globalThis.document.body.innerHTML = options.initialMarkup; + + initCrowdIn('LizardByte', options.platform); + + expect(() => { + jest.advanceTimersByTime(0); + }).not.toThrow(); + + globalThis.document.body.insertAdjacentHTML('beforeend', delayedPickerMarkup); + + jest.advanceTimersByTime(50); + + const container = document.getElementById('crowdin-language-picker'); + const sidebar = document.getElementsByClassName(options.sidebarClass)[0]; + + expect(container.classList.contains('cr-position-bottom-left')).toBe(false); + expect(container.style.position).toBe(options.position); + if (options.pickerClass !== null) { + expect(container.classList.contains(options.pickerClass)).toBe(true); + } + expect(sidebar.contains(container)).toBe(true); +} + describe('initCrowdIn', () => { beforeEach(() => { // Mock DOM elements @@ -139,61 +170,23 @@ describe('initCrowdIn', () => { }); it('should wait for sphinx language picker before applying styling', () => { - globalThis.document.body.innerHTML = ` - - `; - - initCrowdIn('LizardByte', 'sphinx'); - - expect(() => { - jest.advanceTimersByTime(0); - }).not.toThrow(); - - globalThis.document.body.insertAdjacentHTML('beforeend', ` -
-
-
-
- `); - - jest.advanceTimersByTime(50); - - const container = document.getElementById('crowdin-language-picker'); - const sidebar = document.getElementsByClassName('sidebar-sticky')[0]; - - expect(container.classList.contains('cr-position-bottom-left')).toBe(false); - expect(container.style.position).toBe('relative'); - expect(sidebar.contains(container)).toBe(true); + expectDelayedStyling({ + initialMarkup: '', + pickerClass: null, + platform: 'sphinx', + position: 'relative', + sidebarClass: 'sidebar-sticky', + }); }); it('should wait for rustdoc language picker before applying styling', () => { - globalThis.document.body.innerHTML = ` - - `; - - initCrowdIn('LizardByte', 'rustdoc'); - - expect(() => { - jest.advanceTimersByTime(0); - }).not.toThrow(); - - globalThis.document.body.insertAdjacentHTML('beforeend', ` -
-
-
-
- `); - - jest.advanceTimersByTime(50); - - const container = document.getElementById('crowdin-language-picker'); - const sidebar = document.getElementsByClassName('sidebar-elems')[0]; - - expect(container.classList.contains('cr-position-bottom-left')).toBe(false); - expect(container.classList.contains('rustdoc-crowdin-picker')).toBe(true); - expect(sidebar.contains(container)).toBe(true); + expectDelayedStyling({ + initialMarkup: '', + pickerClass: 'rustdoc-crowdin-picker', + platform: 'rustdoc', + position: 'static', + sidebarClass: 'sidebar-elems', + }); }); it('should move rustdoc language picker to sidebar when sidebar-elems is unavailable', () => { From 3114624182d8174c18bc660991b9a7bda81b8ba5 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 22 May 2026 17:41:25 -0400 Subject: [PATCH 4/4] Add clean JSdoc theme styles and build entry Introduce a new SCSS theme for the JSdoc site and wire it into the build and docs. Added src/css/clean-jsdoc-theme.scss and a small src/js module that imports it, updated webpack.config.js to emit a clean-jsdoc-theme-css bundle, and included the generated CSS in jsdoc.json so the theme CSS is loaded alongside the existing Crowdin CSS. --- jsdoc.json | 5 ++++- src/css/clean-jsdoc-theme.scss | 32 ++++++++++++++++++++++++++++++++ src/js/clean-jsdoc-theme-css.js | 1 + webpack.config.js | 1 + 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/css/clean-jsdoc-theme.scss create mode 100644 src/js/clean-jsdoc-theme-css.js diff --git a/jsdoc.json b/jsdoc.json index 0cec9c5..b72c3c8 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -16,7 +16,10 @@ "add_scripts": "window.initCrowdIn('LizardByte-docs', null);", "default_theme": "fallback-dark", "favicon": "favicon.ico", - "include_css": ["./dist/crowdin-clean-jsdoc-css.css"], + "include_css": [ + "./dist/clean-jsdoc-theme-css.css", + "./dist/crowdin-clean-jsdoc-css.css" + ], "include_js": ["./dist/crowdin.js"], "menu": [ { diff --git a/src/css/clean-jsdoc-theme.scss b/src/css/clean-jsdoc-theme.scss new file mode 100644 index 0000000..32ccff3 --- /dev/null +++ b/src/css/clean-jsdoc-theme.scss @@ -0,0 +1,32 @@ +.navbar-container .navbar { + max-width: 74rem; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.navbar .navbar-left-items { + min-width: 0; +} + +.navbar .navbar-item a { + padding-left: 0.875rem; + padding-right: 0.875rem; + white-space: nowrap; +} + +@media screen and (min-width: 65em) and (max-width: 75em) { + .navbar .navbar-item a { + font-size: 0.8125rem; + padding-left: 0.25rem; + padding-right: 0.25rem; + } + + .navbar .icon-button { + padding: 0.25rem; + } + + .navbar .navbar-right-item { + margin-left: 0; + margin-right: 0; + } +} diff --git a/src/js/clean-jsdoc-theme-css.js b/src/js/clean-jsdoc-theme-css.js new file mode 100644 index 0000000..0b03d32 --- /dev/null +++ b/src/js/clean-jsdoc-theme-css.js @@ -0,0 +1 @@ +import "../css/clean-jsdoc-theme.scss"; diff --git a/webpack.config.js b/webpack.config.js index 2ffecf2..1203465 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ let production = process.env.NODE_ENV === 'production'; let config = { entry: { + 'clean-jsdoc-theme-css': './src/js/clean-jsdoc-theme-css', 'crowdin': './src/js/crowdin', 'crowdin-bootstrap-css': './src/js/crowdin-bootstrap-css', 'crowdin-clean-jsdoc-css': './src/js/crowdin-clean-jsdoc-css',