UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

278 lines (277 loc) 11.7 kB
/* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import i18next, { createInstance } from 'i18next'; import { useEffect, useState } from 'react'; import { useTranslation as useReactI18nTranslation } from 'react-i18next'; import { getAppUrl } from '../helpers/getAppUrl'; // Store for plugin i18n instances and their paths const pluginI18nInstances = {}; const pluginPaths = {}; const pluginSupportedLocales = {}; /** * Load translations for a plugin from its directory * Always looks in the same directory as package.json: {pluginPath}/locales/{locale}/translation.json */ async function loadPluginTranslations(pluginPath, locale) { try { const response = await fetch(`${getAppUrl()}${pluginPath}/locales/${locale}/translation.json`); if (response.ok) { return response.json(); } } catch (error) { // Translation file doesn't exist for this locale } return {}; } /** * Create or get an i18next instance for a plugin * Uses hard defaults: namespace = plugin name, loads from plugin path */ async function getPluginI18nInstance(pluginName, pluginPath, supportedLocales) { if (pluginI18nInstances[pluginName]) { return pluginI18nInstances[pluginName]; } const instance = createInstance(); // Use supported locales from package.json or fall back to common locales const locales = supportedLocales || ['en', 'es', 'fr', 'de', 'pt', 'it', 'zh', 'ko', 'ja']; const resources = {}; // Load translations for each locale that exists for (const locale of locales) { const translations = await loadPluginTranslations(pluginPath, locale); if (Object.keys(translations).length > 0) { resources[locale] = { [pluginName]: translations, }; } } // If English is not in the supported locales but is the fallback language, // provide an empty English resource to prevent warnings when no English // translation file exists. This allows plugins to use their default strings // (which may already be in English) without requiring explicit English translations. const hasEnglishTranslations = resources['en'] !== undefined; const supportsEnglish = supportedLocales?.includes('en') ?? true; if (!hasEnglishTranslations && !supportsEnglish) { // Provide empty English translations to prevent i18next warnings // This allows the plugin to fall back to its original string keys resources['en'] = { [pluginName]: {}, }; } await instance.init({ lng: i18next.language || 'en', fallbackLng: 'en', defaultNS: pluginName, ns: [pluginName], resources, interpolation: { escapeValue: false, }, // Disable debug warnings for plugins that don't explicitly support English // This prevents warnings about missing English translations when plugins // may already have English strings as their default content debug: false, // Always return the key if no translation is found, which works well // for plugins that already have English strings as their keys/content returnEmptyString: false, // Don't save missing keys to avoid console noise saveMissing: false, }); pluginI18nInstances[pluginName] = instance; return instance; } /** * Register translations for a plugin programmatically * @deprecated This function is deprecated. Use automatic translation file detection instead. * Just place translation files in locales/{language}/translation.json and use useTranslation(). */ export function registerPluginTranslations(pluginName, translations) { console.warn(`registerPluginTranslations is deprecated. Plugin ${pluginName} should use automatic translation loading instead.`); const instance = createInstance(); const namespace = pluginName; instance.init({ lng: i18next.language || 'en', fallbackLng: 'en', defaultNS: namespace, ns: [namespace], resources: Object.keys(translations).reduce((acc, locale) => { acc[locale] = { [namespace]: translations[locale], }; return acc; }, {}), interpolation: { escapeValue: false, }, debug: false, returnEmptyString: false, saveMissing: false, }); pluginI18nInstances[pluginName] = instance; } /** * Hook to use translations in a plugin * Automatically detects the plugin name from the build-time injected constants * @param pluginNameParam Plugin name (automatically injected by build system) */ export function useTranslation(pluginNameParam) { const [ready, setReady] = useState(false); const [instance, setInstance] = useState(null); const [pluginName] = useState(() => { if (pluginNameParam) { return pluginNameParam; } // If no plugin name is provided, it means the build system transformation didn't work console.error('useTranslation: No plugin name provided. This usually means the build system transformation failed.'); return null; }); const { i18n: mainI18n } = useReactI18nTranslation(); // Initialize plugin translations when plugin name is available useEffect(() => { async function initializeTranslations() { if (!pluginName) { console.error('useTranslation: No plugin name available. Plugin i18n will not work.'); setReady(true); return; } try { const currentPluginName = pluginName; // Try to resolve the plugin path. Use the module-level pluginPaths mapping. let pluginPath = pluginPaths[currentPluginName]; // As a last resort, try deriving a path from the package name by // stripping the scope (if any) to match common directory setups. if (!pluginPath && currentPluginName) { const unscoped = currentPluginName.includes('/') ? currentPluginName.split('/').slice(-1)[0] : currentPluginName; // Common plugin path shape is "/plugins/<name>" const derived = `/plugins/${unscoped}`; // We don't have a server filesystem check here; try to use the // derived path optimistically. It will fail later when fetching // translations, but gives us a chance to proceed. pluginPath = derived; } if (!pluginPath) { console.warn(`No plugin path found for ${currentPluginName}. i18n initialization skipped.`); setReady(true); return; } // Initialize plugin i18n instance const supportedLocales = pluginSupportedLocales[currentPluginName]; const pluginInstance = await getPluginI18nInstance(currentPluginName, pluginPath, supportedLocales); setInstance(pluginInstance); setReady(true); } catch (error) { console.error(`Failed to initialize translations for plugin ${pluginName}:`, error); setReady(true); // Set ready to true even on error to prevent hanging } } initializeTranslations(); }, [pluginName]); // Sync language changes from main i18n useEffect(() => { if (instance && mainI18n?.language !== instance.language) { instance.changeLanguage(mainI18n.language); } }, [mainI18n?.language, instance]); // Translation function const t = (key, options) => { // Avoid flashing the raw key in the UI before plugin translations are ready. // While the plugin i18n instance is initializing (ready === false) // return an empty string to avoid briefly showing the key. if (!ready) { return ''; } // If initialization completed but we don't have an instance (for // example the plugin has no translations or initialization failed), // return the original key so the UI still shows a reasonable value. if (!instance || !pluginName) { return key; } return instance.t(key, options); }; return { t: t, i18n: instance, ready, }; } /** * Get all registered plugin translations info */ export function getPluginTranslationsInfo() { return []; } /** * Change language for all plugin instances */ export function changePluginLanguage(language) { Object.values(pluginI18nInstances).forEach(instance => { instance.changeLanguage(language); }); } /** * Check if a locale is supported by a plugin */ export function isLocaleSupported(pluginName, locale) { const supportedLocales = pluginSupportedLocales[pluginName]; return Array.isArray(supportedLocales) && supportedLocales.includes(locale); } /** * Get the list of supported locales for a plugin */ export function getSupportedLocales(pluginName) { return pluginSupportedLocales[pluginName] || []; } /** * Initialize plugin i18n - simplified approach using package.json configuration * This is called automatically during plugin loading */ export async function initializePluginI18n(pluginName, packageInfo, pluginPath) { // Store the plugin path for later use by useTranslation hook pluginPaths[pluginName] = pluginPath; // Detect duplicate pluginPath mappings: if another plugin name already // points to the same path, warn because translations may be loaded from // the wrong folder and cause cross-plugin leaks. const duplicates = Object.keys(pluginPaths).filter(name => name !== pluginName && pluginPaths[name] === pluginPath); if (duplicates.length > 0) { console.warn(`Plugin path collision: ${pluginName} and ${duplicates.join(', ')} map to the same path ${pluginPath}`); } // Check if plugin has i18n enabled in package.json (array of supported locales) const i18nConfig = packageInfo.headlamp?.i18n; let supportedLocales = []; let i18nEnabled = false; if (Array.isArray(i18nConfig)) { // Array of supported locales supportedLocales = i18nConfig; i18nEnabled = supportedLocales.length > 0; } if (i18nEnabled) { // Store supported locales for later use pluginSupportedLocales[pluginName] = supportedLocales; console.debug(`Initializing i18n for plugin ${pluginName} (supported locales: ${supportedLocales.join(', ')})`); try { await getPluginI18nInstance(pluginName, pluginPath, supportedLocales); } catch (error) { console.error(`Failed to initialize i18n for plugin ${pluginName}:`, error); } } else { console.debug(`Plugin ${pluginName} does not have i18n enabled in package.json`); } }