This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 210a6c60714 Allow plugins to add or override UI translations (#72648)
210a6c60714 is described below

commit 210a6c6071476ec47b93c98d2af1151981cf479d
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Sep 10 11:41:17 2026 +0200

    Allow plugins to add or override UI translations (#72648)
    
    Deployments often need to localize the Airflow UI into a language Airflow 
does not ship, or
    override specific strings, without forking the frontend. Plugins can now 
contribute UI
    translations (a <language>/<namespace>.json directory tree or an inline 
mapping) that are
    deep-merged over the built-in ones and served, pre-merged at startup, only 
for the files a
    plugin changes; everything else keeps being served from the static mount. 
Translations are not
    versioned in lockstep with Airflow, so a broken source is skipped rather 
than failing startup,
    and keys absent from the English reference are logged. Right-to-left 
languages render correctly.
---
 .../docs/administration-and-deployment/plugins.rst |  45 ++++++
 .../src/airflow/api_fastapi/core_api/app.py        | 108 +++++++++++++-
 airflow-core/src/airflow/plugins_manager.py        | 102 +++++++++++++
 .../src/airflow/ui/src/i18n/config.test.ts         |  38 ++++-
 airflow-core/src/airflow/ui/src/i18n/config.ts     |  17 ++-
 .../src/airflow/ui/src/layouts/BaseLayout.tsx      |   3 +
 .../ui/src/layouts/Nav/LanguageSelector.test.tsx   |  73 +++++++++
 .../ui/src/layouts/Nav/LanguageSelector.tsx        |  30 +++-
 .../tests/unit/api_fastapi/core_api/test_app.py    | 122 ++++++++++++++-
 .../tests/unit/plugins/test_plugins_manager.py     | 165 +++++++++++++++++++++
 .../src/tests_common/test_utils/mock_plugins.py    |   1 +
 .../plugins_manager/plugins_manager.py             |   1 +
 12 files changed, 694 insertions(+), 11 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst 
b/airflow-core/docs/administration-and-deployment/plugins.rst
index 240bb97a70f..a4b699fb163 100644
--- a/airflow-core/docs/administration-and-deployment/plugins.rst
+++ b/airflow-core/docs/administration-and-deployment/plugins.rst
@@ -131,6 +131,10 @@ looks like:
         # Note: React apps are only supported in Airflow 3.1 and later.
         # Note: The React app integration is experimental and interfaces might 
change in future versions. Particularly, dependency and state interactions 
between the UI and plugins may need to be refactored for more complex plugin 
apps.
         react_apps = []
+        # A list of UI translation sources to add languages to, or override 
translations in, the UI.
+        # Each entry is a path to a ``<language>/<namespace>.json`` directory 
tree or an inline
+        # ``{language: {namespace: {key: value}}}`` mapping. See the example 
below.
+        ui_translations = []
 
         # A callback to perform actions when Airflow starts and the plugin is 
loaded.
         # NOTE: Ensure your plugin has *args, and **kwargs in the method 
definition
@@ -400,6 +404,47 @@ The props available depend on where the app is mounted 
(its ``destination`` and
   On routes or ``destination`` values without those identifiers (e.g. ``nav``, 
``base``,
   ``dashboard``), the corresponding objects are ``undefined``.
 
+Adding or overriding UI translations
+------------------------------------
+
+The ``ui_translations`` attribute lets a plugin add a language the Airflow UI 
does not ship, or
+override individual strings in a language it does. Each entry is either a path 
to a
+``<language>/<namespace>.json`` directory tree (mirroring Airflow's own
+``airflow/ui/public/i18n/locales`` layout) or an inline
+``{language: {namespace: {key: value}}}`` mapping. Both forms can be mixed in 
the same list.
+
+.. code-block:: python
+
+    from pathlib import Path
+
+    from airflow.plugins_manager import AirflowPlugin
+
+
+    class TranslationsPlugin(AirflowPlugin):
+        name = "translations"
+        ui_translations = [
+            # A directory tree, e.g. locales/eo/common.json, adding Esperanto 
as a new language.
+            Path(__file__).parent / "locales",
+            # Override individual keys in a language Airflow already ships.
+            {"en": {"dags": {"dag_one": "Pipeline"}}},
+        ]
+
+The plugin's values are deep-merged on top of the built-in translations, so an 
override replaces
+only the keys it names (at any nesting depth) and leaves the rest untouched. 
Keys a plugin does not
+provide fall back to the built-in language, and ultimately to English.
+
+Because translations are not versioned in lockstep with Airflow, robustness is 
built in:
+
+- A malformed or unreadable translation source is skipped with a warning in 
the API server log; it
+  never stops the API server from starting or keeps other plugins from loading.
+- English is the reference for which keys exist. When translations are 
consolidated at startup, any
+  plugin key that is **not** present in the English file for its namespace 
(likely renamed or removed
+  upstream) is logged as a warning in the API server log and otherwise ignored.
+
+Right-to-left languages are handled automatically: the UI derives text 
direction from the language
+code (via the browser's locale data, e.g. Persian ``fa`` or Urdu ``ur``), so a 
custom RTL language
+flips the whole UI to right-to-left without any extra configuration.
+
 Exclude views from CSRF protection
 ----------------------------------
 
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py 
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index eac48ccbd7c..841c46c2291 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -16,15 +16,18 @@
 # under the License.
 from __future__ import annotations
 
+import json
 import logging
 import os
+import re
 import warnings
+from hashlib import md5
 from pathlib import Path
 
 from fastapi import FastAPI, Request
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.gzip import GZipMiddleware
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.responses import HTMLResponse, JSONResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi.templating import Jinja2Templates
 
@@ -35,6 +38,106 @@ log = logging.getLogger(__name__)
 
 _AIRFLOW_PATH = Path(__file__).parents[3]
 
+# Language codes ("en", "zh-CN") and namespaces ("common") of the UI 
translation files. Anchored
+# so a path parameter cannot smuggle in path separators or ``..`` and escape 
the locales directory.
+_I18N_SEGMENT = re.compile(r"^[A-Za-z0-9_-]+$")
+
+
+def init_ui_translation_views(app: FastAPI, *, dev_mode: bool, dist_directory: 
Path) -> None:
+    """
+    Register the routes that serve plugin-contributed UI translations.
+
+    Translations are merged with the bundled files and serialized once here at 
startup (not per
+    request); only the files a plugin changes get a route, so every other 
locale file keeps being
+    served from the static mount. A bundled-language override gets a route per 
overridden namespace;
+    a brand-new language gets one route for all its namespaces (empty object 
for those it omits, so
+    i18next falls back to English). These stay off the authenticated 
``ui_router`` because
+    translations load before login, and are registered ahead of the static 
mount to take precedence.
+    """
+    from airflow import plugins_manager
+
+    plugin_translations = plugins_manager.get_ui_translations()
+
+    @app.get("/static/i18n/languages.json", include_in_schema=False)
+    def ui_translation_languages():
+        """List the plugin-contributed languages so the UI can offer them for 
selection."""
+        # No version cache-buster, so revalidate rather than risk hiding a 
newly added language.
+        return JSONResponse({"languages": sorted(plugin_translations)}, 
headers={"Cache-Control": "no-cache"})
+
+    if not plugin_translations:
+        return
+
+    locales_directory = (
+        _AIRFLOW_PATH / "airflow/ui/public/i18n/locales" if dev_mode else 
dist_directory / "i18n/locales"
+    )
+
+    # English is the reference for which keys exist; warn (never fail) about 
plugin keys missing
+    # from it, since translations are not versioned in lockstep with Airflow.
+    try:
+        
plugins_manager.warn_about_unknown_translation_keys(plugin_translations, 
locales_directory / "en")
+    except Exception:
+        log.exception("Failed to check plugin UI translations against the 
English reference")
+
+    bundled_languages: set[str] = set()
+    if locales_directory.is_dir():
+        bundled_languages = {entry.name for entry in 
locales_directory.iterdir() if entry.is_dir()}
+
+    def merged_body(language: str, namespace: str, keys: dict) -> bytes:
+        base: dict = {}
+        base_file = locales_directory / language / f"{namespace}.json"
+        if base_file.is_file():
+            try:
+                base = json.loads(base_file.read_text("utf-8"))
+            except (OSError, ValueError):
+                log.warning("Could not read bundled translation %s/%s.json", 
language, namespace)
+        return json.dumps(plugins_manager.merge_translations(base, 
keys)).encode()
+
+    def json_with_etag(request: Request, body: bytes) -> Response:
+        # ETag so the browser revalidates with a conditional GET (304), like 
the static mount does.
+        etag = f'"{md5(body, usedforsecurity=False).hexdigest()}"'
+        if request.headers.get("if-none-match") == etag:
+            return Response(status_code=304, headers={"ETag": etag})
+        return Response(content=body, media_type="application/json", 
headers={"ETag": etag})
+
+    def serve_body(body: bytes):
+        def route(request: Request) -> Response:
+            return json_with_etag(request, body)
+
+        return route
+
+    def serve_language(language_bodies: dict[str, bytes]):
+        def route(request: Request, namespace: str) -> Response:
+            if not _I18N_SEGMENT.match(namespace):
+                return JSONResponse(status_code=404, content={"error": "Not 
found"})
+            return json_with_etag(request, language_bodies.get(namespace, 
b"{}"))
+
+        return route
+
+    for language, namespaces in plugin_translations.items():
+        if not _I18N_SEGMENT.match(language):
+            log.warning("Skipping plugin UI translations for invalid language 
code %r", language)
+            continue
+
+        bodies = {
+            namespace: merged_body(language, namespace, keys)
+            for namespace, keys in namespaces.items()
+            if _I18N_SEGMENT.match(namespace)
+        }
+
+        if language in bundled_languages:
+            for namespace, body in bodies.items():
+                app.add_api_route(
+                    f"/static/i18n/locales/{language}/{namespace}.json",
+                    serve_body(body),
+                    include_in_schema=False,
+                )
+        else:
+            app.add_api_route(
+                f"/static/i18n/locales/{language}/{{namespace}}.json",
+                serve_language(bodies),
+                include_in_schema=False,
+            )
+
 
 def init_views(app: FastAPI) -> None:
     """Init views by registering the different routers."""
@@ -54,6 +157,9 @@ def init_views(app: FastAPI) -> None:
 
     templates = Jinja2Templates(directory=directory)
 
+    # Ahead of the static mounts below so plugin-overridden locales take 
precedence.
+    init_ui_translation_views(app, dev_mode=dev_mode, dist_directory=directory)
+
     if dev_mode:
         app.mount(
             "/static/i18n/locales",
diff --git a/airflow-core/src/airflow/plugins_manager.py 
b/airflow-core/src/airflow/plugins_manager.py
index ccc29fa8288..b4defb3f39c 100644
--- a/airflow-core/src/airflow/plugins_manager.py
+++ b/airflow-core/src/airflow/plugins_manager.py
@@ -20,9 +20,11 @@
 from __future__ import annotations
 
 import inspect
+import json
 import logging
 from collections.abc import Iterable
 from functools import cache
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 from airflow import settings
@@ -348,6 +350,106 @@ def get_fastapi_plugins() -> tuple[list[Any], list[Any]]:
     return fastapi_apps, fastapi_root_middlewares
 
 
+PluginTranslations = dict[str, dict[str, dict[str, Any]]]
+
+
+def merge_translations(base: dict[str, Any], override: dict[str, Any]) -> 
dict[str, Any]:
+    """Deep-merge ``override`` onto ``base`` (recursing into nested dicts) and 
return a new dict."""
+    merged = dict(base)
+    for key, value in override.items():
+        existing = merged.get(key)
+        if isinstance(existing, dict) and isinstance(value, dict):
+            merged[key] = merge_translations(existing, value)
+        else:
+            merged[key] = value
+    return merged
+
+
+def _load_translation_source(source: Any) -> PluginTranslations:
+    """Load one ``ui_translations`` entry (inline mapping or 
``<language>/<namespace>.json`` tree)."""
+    result: PluginTranslations = {}
+
+    if isinstance(source, dict):
+        for language, namespaces in source.items():
+            for namespace, keys in namespaces.items():
+                if not isinstance(keys, dict):
+                    raise ValueError(f"translations for 
{language!r}/{namespace!r} must be a mapping")
+                result.setdefault(language, {})[namespace] = keys
+        return result
+
+    directory = Path(source)
+    if not directory.is_dir():
+        raise ValueError(f"translation source {source!r} is neither a 
directory nor an inline mapping")
+    for language_dir in sorted(directory.iterdir()):
+        if not language_dir.is_dir():
+            continue
+        for namespace_file in sorted(language_dir.glob("*.json")):
+            try:
+                content = json.loads(namespace_file.read_text("utf-8"))
+            except (OSError, ValueError):
+                log.warning("Skipping unreadable UI translation file %s", 
namespace_file)
+                continue
+            result.setdefault(language_dir.name, {})[namespace_file.stem] = 
content
+    return result
+
+
+@cache
+def get_ui_translations() -> PluginTranslations:
+    """
+    Collect and deep-merge the ``language -> namespace -> keys`` UI 
translations from all plugins.
+
+    Never raises: a broken source (unreadable file, bad ``ui_translations`` 
value) is skipped with a
+    warning so it cannot stop the API server starting or keep other plugins 
from loading.
+    """
+    plugin_translations: PluginTranslations = {}
+    for plugin in _get_plugins()[0]:
+        try:
+            for source in plugin.ui_translations:
+                contributed = _load_translation_source(source)
+                for language, namespaces in contributed.items():
+                    language_translations = 
plugin_translations.setdefault(language, {})
+                    for namespace, keys in namespaces.items():
+                        language_translations[namespace] = merge_translations(
+                            language_translations.get(namespace, {}), keys
+                        )
+        except Exception:
+            log.exception("Skipping invalid UI translations from plugin %s", 
plugin.name)
+            continue
+    return plugin_translations
+
+
+def warn_about_unknown_translation_keys(plugin_translations: 
PluginTranslations, reference_dir: Path) -> None:
+    """Warn about plugin keys absent from the English reference (likely 
stale). Only logs; never raises."""
+
+    def _warn(keys: dict[str, Any], reference: Any, language: str, namespace: 
str, prefix: str = "") -> None:
+        for key, value in keys.items():
+            in_reference = isinstance(reference, dict) and key in reference
+            if isinstance(value, dict):
+                _warn(
+                    value,
+                    reference.get(key) if in_reference else None,
+                    language,
+                    namespace,
+                    f"{prefix}{key}.",
+                )
+            elif not in_reference:
+                log.warning(
+                    "Plugin UI translation for %s/%s sets key %r, which is not 
in the English "
+                    "reference and may be stale.",
+                    language,
+                    namespace,
+                    f"{prefix}{key}",
+                )
+
+    for language, namespaces in plugin_translations.items():
+        for namespace, keys in namespaces.items():
+            try:
+                reference = json.loads((reference_dir / 
f"{namespace}.json").read_text("utf-8"))
+            except (OSError, ValueError):
+                reference = {}
+            _warn(keys, reference, language, namespace)
+
+
 @cache
 def _get_extra_operators_links_plugins() -> tuple[list[Any], list[Any]]:
     """Create and get modules for loaded extension from extra operators links 
plugins."""
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.test.ts 
b/airflow-core/src/airflow/ui/src/i18n/config.test.ts
index fabde912791..272396c5b21 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.test.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.test.ts
@@ -17,11 +17,16 @@
  * under the License.
  */
 import { createInstance } from "i18next";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
 
 import { VersionService } from "openapi/requests/services.gen";
 
-import { convertDetectedLanguage, i18nBaseOptions, resolveI18nVersion } from 
"./config";
+import {
+  convertDetectedLanguage,
+  i18nBaseOptions,
+  resolveExtraLanguages,
+  resolveI18nVersion,
+} from "./config";
 
 // getBestMatchFromCodes is the resolver i18next runs on the array the
 // LanguageDetector returns. It is not part of i18next's public types
@@ -105,3 +110,32 @@ describe("resolveI18nVersion", () => {
     await expect(resolveI18nVersion()).resolves.not.toBe("");
   });
 });
+
+describe("resolveExtraLanguages", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("returns the plugin languages listed by the manifest", async () => {
+    vi.stubGlobal(
+      "fetch",
+      vi.fn().mockResolvedValue({ json: () => Promise.resolve({ languages: 
["eo", "tlh"] }), ok: true }),
+    );
+
+    await expect(resolveExtraLanguages()).resolves.toStrictEqual(["eo", 
"tlh"]);
+  });
+
+  it("degrades to no extra languages when the manifest is missing or 
malformed", async () => {
+    vi.stubGlobal(
+      "fetch",
+      vi.fn().mockResolvedValue({ json: () => 
Promise.resolve("not-a-manifest"), ok: true }),
+    );
+    await expect(resolveExtraLanguages()).resolves.toStrictEqual([]);
+
+    vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false }));
+    await expect(resolveExtraLanguages()).resolves.toStrictEqual([]);
+
+    vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network 
error")));
+    await expect(resolveExtraLanguages()).resolves.toStrictEqual([]);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts 
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 50b7f6b7f3f..34a5e2ee20e 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -131,7 +131,17 @@ export const i18nBaseOptions = {
   supportedLngs: supportedCodes,
 };
 
-const initI18n = (version: string) => {
+// Plugin-contributed languages listed by the server; fail soft to none if it 
cannot be fetched.
+export const resolveExtraLanguages = (): Promise<Array<string>> =>
+  Promise.resolve()
+    .then(() => fetch(`${basePath}/static/i18n/languages.json`))
+    .then((response) =>
+      response.ok ? (response.json() as Promise<{ languages?: Array<string> 
}>) : { languages: [] },
+    )
+    .then((data) => data.languages ?? [])
+    .catch(() => []);
+
+const initI18n = (version: string, extraLanguages: Array<string>) => {
   const queryString = version ? `?v=${version}` : "";
 
   // Subscribed before init so it precedes every react-i18next component 
listener, and
@@ -147,6 +157,7 @@ const initI18n = (version: string) => {
       backend: {
         loadPath: 
`${basePath}/static/i18n/locales/{{lng}}/{{ns}}.json${queryString}`,
       },
+      supportedLngs: [...new Set([...supportedCodes, ...extraLanguages])],
     });
 };
 
@@ -160,6 +171,8 @@ export const resolveI18nVersion = (): Promise<string> =>
     .then((data) => data.version)
     .catch(() => Date.now().toString());
 
-void resolveI18nVersion().then(initI18n);
+void Promise.all([resolveI18nVersion(), 
resolveExtraLanguages()]).then(([version, extraLanguages]) =>
+  initI18n(version, extraLanguages),
+);
 
 export { default } from "i18next";
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index ed9aa7ab451..351081a8733 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -54,6 +54,9 @@ export const BaseLayout = ({ children }: PropsWithChildren) 
=> {
       }
     };
 
+    // On mount too: i18next's initial `languageChanged` fires before this 
listener attaches, so a
+    // page loaded directly in an RTL language would otherwise stay ltr until 
the next change.
+    updateHtml(i18n.resolvedLanguage ?? i18n.language);
     i18n.on("languageChanged", updateHtml);
 
     return () => {
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.test.tsx
new file mode 100644
index 00000000000..33dadb27ae8
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.test.tsx
@@ -0,0 +1,73 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { createInstance } from "i18next";
+import { I18nextProvider, initReactI18next } from "react-i18next";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import LanguageSelector from "./LanguageSelector";
+
+// The selector lists plugin languages from the /static/i18n/languages.json 
manifest; stub the
+// fetch so tests control which plugin languages are offered.
+const renderWithLanguages = async (pluginLanguages: Array<string>, lng: 
string) => {
+  vi.stubGlobal(
+    "fetch",
+    vi.fn().mockResolvedValue({ json: () => Promise.resolve({ languages: 
pluginLanguages }), ok: true }),
+  );
+
+  const instance = createInstance();
+
+  await instance.use(initReactI18next).init({
+    fallbackLng: false,
+    lng,
+    react: { useSuspense: false },
+    resources: {},
+    supportedLngs: [...new Set(["en", lng, ...pluginLanguages])],
+  });
+
+  render(
+    <I18nextProvider i18n={instance}>
+      <LanguageSelector />
+    </I18nextProvider>,
+    { wrapper: Wrapper },
+  );
+};
+
+describe("LanguageSelector", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("labels a built-in language with its curated name", async () => {
+    await renderWithLanguages([], "fr");
+
+    expect(await screen.findByText(/Français \(fr\)/u)).toBeInTheDocument();
+  });
+
+  it("lists a plugin-contributed language with the browser's language name", 
async () => {
+    const expected = new Intl.DisplayNames(["eo"], { type: "language" 
}).of("eo");
+
+    await renderWithLanguages(["eo"], "eo");
+
+    expect(await screen.findByText(new RegExp(`${expected ?? "eo"} \\(eo\\)`, 
"u"))).toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.tsx
index d3d39c56696..dfdde81b7a6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/LanguageSelector.tsx
@@ -19,19 +19,39 @@
 import { useState, useCallback } from "react";
 
 import { Field, VStack, Box, Text } from "@chakra-ui/react";
+import { useQuery } from "@tanstack/react-query";
 import { Select, type SingleValue } from "chakra-react-select";
 import { useTranslation } from "react-i18next";
 
-import { supportedLanguages } from "src/i18n/config";
+import { resolveExtraLanguages, supportedLanguages } from "src/i18n/config";
+
+const builtinCodes = new Set<string>(supportedLanguages.map((lang) => 
lang.code));
+
+// The browser's own name for a plugin language code, falling back to the raw 
code.
+const getPluginLanguageName = (code: string): string => {
+  try {
+    return new Intl.DisplayNames([code], { type: "language" }).of(code) ?? 
code;
+  } catch {
+    return code;
+  }
+};
 
 const LanguageSelector = () => {
   const { i18n, t: translate } = useTranslation();
   const [selectedLang, setSelectedLang] = useState(i18n.resolvedLanguage ?? 
i18n.language);
 
-  const options = supportedLanguages.map((lang) => ({
-    label: lang.name,
-    value: lang.code,
-  }));
+  // Plugin-contributed languages, listed by the server, offered alongside the 
built-in ones.
+  const { data: pluginLanguages = [] } = useQuery({
+    queryFn: resolveExtraLanguages,
+    queryKey: ["uiPluginLanguages"],
+  });
+
+  const options = [
+    ...supportedLanguages.map((lang) => ({ label: lang.name, value: lang.code 
})),
+    ...pluginLanguages
+      .filter((code) => !builtinCodes.has(code))
+      .map((code) => ({ label: getPluginLanguageName(code), value: code })),
+  ];
 
   const handleLanguageChange = useCallback(
     (selectedOption: SingleValue<{ label: string; value: string }>) => {
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
index 0cabcf27393..85f09f3675d 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_app.py
@@ -18,6 +18,7 @@ from __future__ import annotations
 
 import contextlib
 import inspect
+import json
 import typing
 
 import pytest
@@ -29,13 +30,15 @@ from starlette.routing import Mount
 from starlette.testclient import TestClient
 
 from airflow.api_fastapi.app import create_app
-from airflow.api_fastapi.core_api.app import init_config
+from airflow.api_fastapi.core_api.app import init_config, 
init_ui_translation_views
 from airflow.api_fastapi.core_api.routes.public import authenticated_router
 from airflow.api_fastapi.core_api.routes.ui import ui_router
 from airflow.api_fastapi.core_api.security import get_user
+from airflow.plugins_manager import AirflowPlugin
 
 from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.db import clear_db_jobs
+from tests_common.test_utils.mock_plugins import mock_plugin_manager
 
 pytestmark = pytest.mark.db_test
 
@@ -199,3 +202,120 @@ class TestCorsMiddlewareConfig:
         blocked = client.get("/ping", headers={"Origin": "https://evil.com"})
         assert blocked.status_code == 200
         assert "access-control-allow-origin" not in blocked.headers
+
+
+class TestUiTranslationViews:
+    @staticmethod
+    def _client(plugins, tmp_path) -> TestClient:
+        app = FastAPI()
+        with mock_plugin_manager(plugins=plugins):
+            init_ui_translation_views(app, dev_mode=False, 
dist_directory=tmp_path)
+        return TestClient(app)
+
+    def test_languages_manifest_lists_plugin_languages_sorted(self, tmp_path):
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"eo": {"common": {"greeting": "Saluton"}}, 
"en": {"common": {"a": "A"}}}]
+
+        response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/languages.json")
+
+        assert response.status_code == 200
+        assert response.json() == {"languages": ["en", "eo"]}
+
+    def test_no_locale_route_without_plugin_translations(self, tmp_path):
+        client = self._client([], tmp_path)
+
+        assert client.get("/static/i18n/languages.json").json() == 
{"languages": []}
+        # With nothing to override, the per-locale route is not registered, so 
the bundled files
+        # keep being served straight from the static mount.
+        assert client.get("/static/i18n/locales/en/common.json").status_code 
== 404
+
+    def test_override_is_deep_merged_over_the_bundled_file(self, tmp_path):
+        (tmp_path / "i18n/locales/en").mkdir(parents=True)
+        (tmp_path / "i18n/locales/en/common.json").write_text(
+            json.dumps({"a": "base", "nested": {"x": "bx", "deep": {"p": "bp", 
"q": "bq"}}}),
+            encoding="utf-8",
+        )
+
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            # Override a key three levels deep; siblings at every level must 
survive.
+            ui_translations = [{"en": {"common": {"a": "override", "nested": 
{"deep": {"q": "oq"}}}}}]
+
+        response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/locales/en/common.json")
+
+        assert response.status_code == 200
+        assert response.json() == {"a": "override", "nested": {"x": "bx", 
"deep": {"p": "bp", "q": "oq"}}}
+
+    def test_only_the_overridden_namespace_is_intercepted(self, tmp_path):
+        # A bundled language override must not intercept its other namespaces; 
those keep being
+        # served from the static mount (here, the bare app has none, so they 
simply 404).
+        (tmp_path / "i18n/locales/en").mkdir(parents=True)
+        (tmp_path / "i18n/locales/en/common.json").write_text(json.dumps({"a": 
"base"}), encoding="utf-8")
+
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"en": {"common": {"a": "override"}}}]
+
+        client = self._client([TranslationPlugin()], tmp_path)
+
+        assert client.get("/static/i18n/locales/en/common.json").json() == 
{"a": "override"}
+        assert client.get("/static/i18n/locales/en/dags.json").status_code == 
404
+
+    def test_new_language_without_a_bundled_file_serves_its_translations(self, 
tmp_path):
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"eo": {"common": {"greeting": "Saluton"}}}]
+
+        client = self._client([TranslationPlugin()], tmp_path)
+
+        assert client.get("/static/i18n/locales/eo/common.json").json() == 
{"greeting": "Saluton"}
+        # A namespace the new language does not provide returns an empty 
object so i18next falls
+        # back to English rather than receiving a 404.
+        assert client.get("/static/i18n/locales/eo/dags.json").json() == {}
+
+    def test_invalid_namespace_segment_is_rejected(self, tmp_path):
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"eo": {"common": {"greeting": "Saluton"}}}]
+
+        # A namespace outside [A-Za-z0-9_-] cannot escape the locales 
directory.
+        response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/locales/eo/foo.bar.json")
+
+        assert response.status_code == 404
+
+    def 
test_unreadable_bundled_file_falls_back_to_the_plugin_translations(self, 
tmp_path):
+        (tmp_path / "i18n/locales/en").mkdir(parents=True)
+        (tmp_path / "i18n/locales/en/common.json").write_text("{ not valid 
json", encoding="utf-8")
+
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"en": {"common": {"a": "override"}}}]
+
+        response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/locales/en/common.json")
+
+        assert response.status_code == 200
+        assert response.json() == {"a": "override"}
+
+    def test_locale_response_supports_conditional_get(self, tmp_path):
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"eo": {"common": {"greeting": "Saluton"}}}]
+
+        client = self._client([TranslationPlugin()], tmp_path)
+
+        first = client.get("/static/i18n/locales/eo/common.json")
+        assert first.status_code == 200
+        etag = first.headers["etag"]
+
+        revalidated = client.get("/static/i18n/locales/eo/common.json", 
headers={"If-None-Match": etag})
+        assert revalidated.status_code == 304
+
+    def test_manifest_asks_the_browser_to_revalidate(self, tmp_path):
+        class TranslationPlugin(AirflowPlugin):
+            name = "translations"
+            ui_translations = [{"eo": {"common": {"greeting": "Saluton"}}}]
+
+        response = self._client([TranslationPlugin()], 
tmp_path).get("/static/i18n/languages.json")
+
+        assert response.headers["cache-control"] == "no-cache"
diff --git a/airflow-core/tests/unit/plugins/test_plugins_manager.py 
b/airflow-core/tests/unit/plugins/test_plugins_manager.py
index bef30bcd253..a7fc175afef 100644
--- a/airflow-core/tests/unit/plugins/test_plugins_manager.py
+++ b/airflow-core/tests/unit/plugins/test_plugins_manager.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import importlib
 import inspect
+import json
 import logging
 import os
 import sys
@@ -26,6 +27,7 @@ from unittest import mock
 
 import pytest
 
+import airflow.plugins_manager as plugins_manager
 from airflow._shared.module_loading import qualname
 from airflow.configuration import conf
 from airflow.listeners.listener import get_listener_manager
@@ -715,3 +717,166 @@ class TestGetFastapiPluginsTeamName:
         for plugin in (global_plugin, team_plugin):
             assert "team_name" not in plugin.fastapi_apps[0]
             assert "team_name" not in plugin.fastapi_root_middlewares[0]
+
+
+class TestMergeTranslations:
+    def test_override_wins_and_preserves_siblings(self):
+        base = {"a": "base", "group": {"x": "bx", "y": "by"}}
+        override = {"a": "override", "group": {"y": "oy", "z": "oz"}, "added": 
"new"}
+
+        result = plugins_manager.merge_translations(base, override)
+
+        assert result == {"a": "override", "group": {"x": "bx", "y": "oy", 
"z": "oz"}, "added": "new"}
+
+    def test_overrides_a_deeply_nested_key_without_dropping_siblings(self):
+        base = {"dagRun": {"durationStats": {"mean": "Mean", "mode": "Mode"}}}
+        override = {"dagRun": {"durationStats": {"mean": "Moyenne"}}}
+
+        result = plugins_manager.merge_translations(base, override)
+
+        assert result == {"dagRun": {"durationStats": {"mean": "Moyenne", 
"mode": "Mode"}}}
+
+    def test_inputs_are_not_mutated(self):
+        base = {"group": {"x": "bx"}}
+        override = {"group": {"y": "oy"}}
+
+        plugins_manager.merge_translations(base, override)
+
+        assert base == {"group": {"x": "bx"}}
+        assert override == {"group": {"y": "oy"}}
+
+
+class TestGetUiTranslations:
+    def test_returns_empty_without_translation_plugins(self):
+        with mock_plugin_manager(plugins=[]):
+            assert plugins_manager.get_ui_translations() == {}
+
+    def test_collects_inline_mapping_source(self):
+        class InlinePlugin(AirflowPlugin):
+            name = "inline"
+            ui_translations = [{"en": {"common": {"greeting": "Hi"}}}]
+
+        with mock_plugin_manager(plugins=[InlinePlugin()]):
+            assert plugins_manager.get_ui_translations() == {"en": {"common": 
{"greeting": "Hi"}}}
+
+    def test_collects_directory_source_including_new_language(self, tmp_path):
+        locales = tmp_path / "locales"
+        (locales / "eo").mkdir(parents=True)
+        (locales / "eo" / "common.json").write_text(json.dumps({"greeting": 
"Saluton"}), encoding="utf-8")
+
+        class DirectoryPlugin(AirflowPlugin):
+            name = "directory"
+            ui_translations = [locales]
+
+        with mock_plugin_manager(plugins=[DirectoryPlugin()]):
+            assert plugins_manager.get_ui_translations() == {"eo": {"common": 
{"greeting": "Saluton"}}}
+
+    def test_deep_merges_across_plugins(self):
+        class PluginA(AirflowPlugin):
+            name = "a"
+            ui_translations = [{"en": {"common": {"a": "1", "shared": {"x": 
"ax"}}}}]
+
+        class PluginB(AirflowPlugin):
+            name = "b"
+            ui_translations = [{"en": {"common": {"b": "2", "shared": {"y": 
"by"}}}}]
+
+        with mock_plugin_manager(plugins=[PluginA(), PluginB()]):
+            assert plugins_manager.get_ui_translations() == {
+                "en": {"common": {"a": "1", "b": "2", "shared": {"x": "ax", 
"y": "by"}}}
+            }
+
+    def test_skips_malformed_inline_source_but_keeps_valid_one(self, caplog):
+        class BadPlugin(AirflowPlugin):
+            name = "bad"
+            ui_translations = [{"en": {"common": "not-a-mapping"}}]
+
+        class GoodPlugin(AirflowPlugin):
+            name = "good"
+            ui_translations = [{"fr": {"common": {"greeting": "Bonjour"}}}]
+
+        with mock_plugin_manager(plugins=[BadPlugin(), GoodPlugin()]), 
caplog.at_level(logging.WARNING):
+            plugin_translations = plugins_manager.get_ui_translations()
+
+        assert plugin_translations == {"fr": {"common": {"greeting": 
"Bonjour"}}}
+        assert any("bad" in record.getMessage() for record in caplog.records)
+
+    def test_skips_source_that_is_neither_directory_nor_mapping(self, caplog):
+        class WeirdPlugin(AirflowPlugin):
+            name = "weird"
+            ui_translations = ["/nonexistent/locales/path", 123]
+
+        with mock_plugin_manager(plugins=[WeirdPlugin()]), 
caplog.at_level(logging.WARNING):
+            assert plugins_manager.get_ui_translations() == {}
+
+        assert any("weird" in record.getMessage() for record in caplog.records)
+
+    def test_skips_unreadable_file_but_keeps_the_rest_of_the_tree(self, 
tmp_path, caplog):
+        locales = tmp_path / "locales"
+        (locales / "eo").mkdir(parents=True)
+        (locales / "eo" / "common.json").write_text("{ not valid json", 
encoding="utf-8")
+        (locales / "eo" / "dags.json").write_text(json.dumps({"title": 
"Fluoj"}), encoding="utf-8")
+
+        class DirectoryPlugin(AirflowPlugin):
+            name = "directory"
+            ui_translations = [locales]
+
+        with mock_plugin_manager(plugins=[DirectoryPlugin()]), 
caplog.at_level(logging.WARNING):
+            plugin_translations = plugins_manager.get_ui_translations()
+
+        assert plugin_translations == {"eo": {"dags": {"title": "Fluoj"}}}
+        assert any("common.json" in record.getMessage() for record in 
caplog.records)
+
+    def 
test_broken_ui_translations_attribute_does_not_break_other_plugins(self, 
caplog):
+        class BrokenPlugin(AirflowPlugin):
+            name = "broken"
+            ui_translations = 123  # not even iterable
+
+        class GoodPlugin(AirflowPlugin):
+            name = "good"
+            ui_translations = [{"fr": {"common": {"greeting": "Bonjour"}}}]
+
+        with mock_plugin_manager(plugins=[BrokenPlugin(), GoodPlugin()]), 
caplog.at_level(logging.WARNING):
+            plugin_translations = plugins_manager.get_ui_translations()
+
+        assert plugin_translations == {"fr": {"common": {"greeting": 
"Bonjour"}}}
+        assert any("broken" in record.getMessage() for record in 
caplog.records)
+
+
+class TestWarnAboutUnknownTranslationKeys:
+    @staticmethod
+    def _english_reference(tmp_path):
+        reference_dir = tmp_path / "en"
+        reference_dir.mkdir()
+        (reference_dir / "common.json").write_text(
+            json.dumps({"greeting": "Hi", "group": {"known": "K"}}), 
encoding="utf-8"
+        )
+        return reference_dir
+
+    def test_warns_only_for_keys_absent_from_english(self, tmp_path, caplog):
+        reference_dir = self._english_reference(tmp_path)
+        plugin_translations = {
+            "eo": {
+                "common": {
+                    "greeting": "Saluton",
+                    "group": {"known": "Konata", "unknown": "Nekonata"},
+                    "stale": "Malaktuala",
+                }
+            }
+        }
+
+        with caplog.at_level(logging.WARNING):
+            
plugins_manager.warn_about_unknown_translation_keys(plugin_translations, 
reference_dir)
+
+        messages = [record.getMessage() for record in caplog.records]
+        assert not any("'greeting'" in message for message in messages)
+        assert not any("'group.known'" in message for message in messages)
+        assert any("'group.unknown'" in message for message in messages)
+        assert any("'stale'" in message for message in messages)
+
+    def test_missing_reference_file_warns_without_raising(self, tmp_path, 
caplog):
+        plugin_translations = {"eo": {"absent_namespace": {"a": "b"}}}
+
+        with caplog.at_level(logging.WARNING):
+            
plugins_manager.warn_about_unknown_translation_keys(plugin_translations, 
tmp_path / "en")
+
+        assert any("'a'" in record.getMessage() for record in caplog.records)
diff --git a/devel-common/src/tests_common/test_utils/mock_plugins.py 
b/devel-common/src/tests_common/test_utils/mock_plugins.py
index d4bc391418f..c21a33b5c9b 100644
--- a/devel-common/src/tests_common/test_utils/mock_plugins.py
+++ b/devel-common/src/tests_common/test_utils/mock_plugins.py
@@ -89,6 +89,7 @@ def mock_plugin_manager(plugins=None, **kwargs):
 
             plugins_manager._get_plugins.cache_clear()
             plugins_manager._get_ui_plugins.cache_clear()
+            plugins_manager.get_ui_translations.cache_clear()
             plugins_manager.get_flask_plugins.cache_clear()
             plugins_manager.get_fastapi_plugins.cache_clear()
             plugins_manager._get_extra_operators_links_plugins.cache_clear()
diff --git 
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py 
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
index d02b32285fe..d4dd953073b 100644
--- 
a/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
+++ 
b/shared/plugins_manager/src/airflow_shared/plugins_manager/plugins_manager.py
@@ -105,6 +105,7 @@ class AirflowPlugin:
     fastapi_root_middlewares: list[Any] = []
     external_views: list[Any] = []
     react_apps: list[Any] = []
+    ui_translations: list[Any] = []
     menu_links: list[Any] = []
     appbuilder_views: list[Any] = []
     appbuilder_menu_items: list[Any] = []

Reply via email to