crowdin.js

const loadScript = require('./load-script');

/**
 * jsDelivr CDN URL serving Crowdin distribution files from the crowdin-dist
 * git branch.  jsDelivr unconditionally sets Access-Control-Allow-Origin: *,
 * so cross-origin fetch() calls succeed without any browser plugin.
 * The branch is refreshed daily by the "Sync Crowdin Distribution" workflow.
 * Structure mirrors https://distributions.crowdin.net/<hash>/… exactly.
 * @type {string}
 */
const CROWDIN_DIST_MIRROR = 'https://cdn.jsdelivr.net/gh/LizardByte/shared-web@crowdin-dist';

/**
 * Monkey-patches globalThis.fetch to redirect Crowdin distribution requests to
 * the self-hosted GitHub Pages mirror.
 *
 * Must be called BEFORE proxy-translator.js is loaded so that every fetch()
 * the script makes is already intercepted.
 *
 * Idempotent – installs the interceptor at most once per page.
 */
function _installCrowdinFetchInterceptor() {
    if (typeof globalThis.fetch !== 'function') return;
    if (globalThis._crowdinMirrorInstalled) return;
    globalThis._crowdinMirrorInstalled = true;

    const _origFetch = globalThis.fetch.bind(globalThis);

    globalThis.fetch = function crowdinMirrorFetch(url, options) {
        if (typeof url === 'string') {
            let parsed;
            try {
                parsed = new URL(url);
            } catch {
                // Not a valid absolute URL – pass through unchanged.
            }
            // Use exact hostname comparison to avoid prefix-match bypasses
            // (e.g. distributions.crowdin.net.evil.com) that would be flagged
            // by incomplete URL sanitisation checks.
            if (parsed?.protocol === 'https:' && parsed.hostname === 'distributions.crowdin.net') {
                const mirroredUrl = CROWDIN_DIST_MIRROR + parsed.pathname + parsed.search + parsed.hash;
                return _origFetch(mirroredUrl, options);
            }
        }
        return _origFetch(url, options);
    };
}

/**
 * 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).
 */
function initCrowdIn(project = 'LizardByte', platform = null) {
    // Input validation
    if (!['LizardByte', 'LizardByte-docs'].includes(project)) {
        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');
        return;
    }

    // Redirect distribution CDN requests to our self-hosted GitHub Pages mirror
    // before the script is even loaded so every fetch() it makes is intercepted.
    _installCrowdinFetchInterceptor();

    loadScript('https://website-translator.app.crowdin.net/assets/proxy-translator.js', function() {
        // Configure base settings based on project
        const projectSettings = {
            'LizardByte': {
                baseUrl: "https://app.lizardbyte.dev",
                distribution: "458f881791aebba1d4dde491bw4",
            },
            'LizardByte-docs': {
                baseUrl: "https://docs.lizardbyte.dev",
                distribution: "d6c830ba4b41106fefe5d391bw4",
            }
        };

        let languageTitles = {
            "bg": "Български (Bulgarian)",
            "cs": "Čeština (Czech)",
            "de": "Deutsch (German)",
            "en": "English",
            "en-GB": "English, United Kingdom",
            "en-US": "English, United States",
            "es-ES": "Español (Spanish)",
            "fr": "Français (French)",
            "hu": "Magyar (Hungarian)",
            "it": "Italiano (Italian)",
            "ja": "日本語 (Japanese)",
            "ko": "한국어 (Korean)",
            "pl": "Polski (Polish)",
            "pt-BR": "Português, Brasileiro (Portuguese, Brazilian)",
            "pt-PT": "Português (Portuguese)",
            "ru": "Русский (Russian)",
            "sv-SE": "svenska (Swedish)",
            "tr": "Türkçe (Turkish)",
            "uk": "Українська (Ukranian)",
            "vi": "Tiếng Việt (Vietnamese)",
            "zh-CN": "简体中文 (Chinese Simplified)",
            "zh-TW": "繁體中文 (Chinese Traditional)",
        };
        // sort languages by name
        languageTitles = Object.fromEntries(Object.entries(languageTitles).sort((a, b) => a[1].localeCompare(b[1])));

        // use this to allow translations to work on PR preview builds
        let currentBaseUrl = globalThis.location.origin;

        // Initialize Crowdin translator
        globalThis.proxyTranslator.init({
            baseUrl: currentBaseUrl,
            distribution: projectSettings[project].distribution,
            defaultLanguage: "en",
            languageTitles: languageTitles,
            showDefaultLanguageInUrl: false,
            languageRoutingMethod: "query",
            position: "bottom-left",
            submenuPosition: "top-left",
            poweredBy: false,
        });

        // Apply styling based on UI framework
        if (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);
        }
    });
}

// Expose to the global scope
if (typeof globalThis !== 'undefined' && globalThis.window !== undefined) {
    globalThis.initCrowdIn = initCrowdIn;
}

module.exports = initCrowdIn;