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..7dc18d4 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,10 @@ 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 + - 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/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..b423a01 --- /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": "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 new file mode 100644 index 0000000..852042e --- /dev/null +++ b/examples/rustdoc/rustdoc/build.js @@ -0,0 +1,39 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const exampleDir = path.resolve(__dirname, '..'); +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 = [ + '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; +} + +fs.mkdirSync(docDir, { recursive: true }); + +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 new file mode 100644 index 0000000..21b31c8 --- /dev/null +++ b/examples/rustdoc/rustdoc/shared-web.html @@ -0,0 +1,7 @@ + + + + + diff --git a/examples/rustdoc/src/lib.rs b/examples/rustdoc/src/lib.rs new file mode 100644 index 0000000..d0921d5 --- /dev/null +++ b/examples/rustdoc/src/lib.rs @@ -0,0 +1,53 @@ +//! # 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 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`. +//! +//! 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: +//! +//! ```toml +//! [build] +//! rustdocflags = [ +//! "--html-after-content", +//! "rustdoc/shared-web.html", +//! ] +//! ``` +//! +//! 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. +/// +/// # 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..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": [ { @@ -40,6 +43,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/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/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/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/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/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..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,10 +48,69 @@ 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'). - * @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 +118,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; } @@ -127,27 +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); - } + _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 437e16d..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 = ` +