Merge pull request #131 from MervinPraison/claude/issue-126-20260617

feat: wire i18n config to frontend runtime
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-23 11:39:05 +01:00
commit 69461ebef3
10 files changed
+365 -9

No files matched your search

+6 -1
View File
@@ -9,6 +9,7 @@ import { ZoneWidgets } from './Widgets'
import { Toc } from './Toc'
import { Footer } from './Footer'
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
import { LocaleProvider } from './i18n'
function SkipLink({ enabled }: { enabled?: boolean }) {
if (!enabled) return null
@@ -394,5 +395,9 @@ export default function App() {
}
}
return renderByStyle()
return (
<LocaleProvider config={config.i18n}>
{renderByStyle()}
</LocaleProvider>
)
}
+2
View File
@@ -1,5 +1,6 @@
// Header component
import type { UIConfig } from './types'
import { LocaleSwitcher } from './i18n'
export function Header({ config }: { config: UIConfig }) {
// Resolve header component via template slot ref (e.g., header_main)
@@ -56,6 +57,7 @@ export function Header({ config }: { config: UIConfig }) {
</nav>
<div className="flex items-center gap-2">
<LocaleSwitcher />
{header?.cta && (
<a
href={header.cta.href}
+133
View File
@@ -0,0 +1,133 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { I18nConfig } from '../types'
interface LocaleContextValue {
locale: string
locales: string[]
rtlLocales: string[]
setLocale: (locale: string) => void
t: (key: string, variables?: Record<string, string>) => string
isRTL: boolean
strings: Record<string, string>
loading: boolean
}
const LocaleContext = createContext<LocaleContextValue | null>(null)
export function useLocale() {
const context = useContext(LocaleContext)
if (!context) {
throw new Error('useLocale must be used within LocaleProvider')
}
return context
}
interface LocaleProviderProps {
config?: I18nConfig
children: React.ReactNode
}
export function LocaleProvider({ config, children }: LocaleProviderProps) {
const [locale, setLocaleState] = useState(config?.defaultLocale || 'en')
const [strings, setStrings] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const locales = config?.locales || ['en']
const rtlLocales = config?.rtlLocales || []
const isRTL = rtlLocales.includes(locale)
// Apply RTL to document
useEffect(() => {
if (isRTL) {
document.documentElement.dir = 'rtl'
} else {
document.documentElement.dir = 'ltr'
}
}, [isRTL])
// Load strings from API when locale changes
useEffect(() => {
const abortController = new AbortController()
async function loadStrings() {
if (!config || locales.length <= 1) return
setLoading(true)
try {
const response = await fetch(`/api/i18n/strings/${locale}`, {
signal: abortController.signal
})
if (response.ok) {
const data = await response.json()
setStrings(data.strings || {})
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name === 'AbortError') return
console.error('Failed to load translations:', error)
} finally {
setLoading(false)
}
}
loadStrings()
// Cleanup: abort the request if component unmounts or locale changes
return () => abortController.abort()
}, [locale, config, locales.length])
const setLocale = useCallback(async (newLocale: string) => {
// Validate locale format (e.g., 'en', 'en-US', 'zh-CN')
if (!/^[a-z]{2}(-[A-Z]{2})?$/.test(newLocale)) {
console.error('Invalid locale format:', newLocale)
return
}
if (!locales.includes(newLocale)) return
setLocaleState(newLocale)
// Update server-side locale
try {
await fetch('/api/i18n/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: newLocale })
})
} catch (error) {
console.error('Failed to update server locale:', error)
}
}, [locales])
const t = useCallback((key: string, variables?: Record<string, string>) => {
let text = strings[key] || key
// Apply variable substitution with proper escaping
if (variables) {
Object.entries(variables).forEach(([k, v]) => {
// Escape special regex characters in the key to prevent injection
const escapedKey = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
text = text.replace(new RegExp(`{${escapedKey}}`, 'g'), v)
})
}
return text
}, [strings])
const value: LocaleContextValue = {
locale,
locales,
rtlLocales,
setLocale,
t,
isRTL,
strings,
loading
}
return (
<LocaleContext.Provider value={value}>
{children}
</LocaleContext.Provider>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { useLocale } from './LocaleProvider'
export function LocaleSwitcher() {
const { locale, locales, setLocale } = useLocale()
// Don't show switcher if only one locale
if (locales.length <= 1) {
return null
}
return (
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="px-3 py-1.5 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
aria-label="Language"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{loc.toUpperCase()}
</option>
))}
</select>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { LocaleProvider, useLocale } from './LocaleProvider'
export { LocaleSwitcher } from './LocaleSwitcher'
+1 -1
View File
@@ -333,7 +333,7 @@ class Compiler:
"ariaLabels": self.config.a11y.aria_labels,
}
# I18n configuration - emit for frontend consumption (experimental)
# I18n configuration - emit for frontend consumption
if self.config.i18n:
result["i18n"] = {
"defaultLocale": self.config.i18n.default_locale,
+32 -5
View File
@@ -8,6 +8,7 @@ Architecture:
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
@@ -19,6 +20,10 @@ from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
# Compile regex patterns once for performance
LOCALE_PATTERN = re.compile(r'^[a-z]{2}(-[A-Z]{2})?$')
KEY_PATTERN = re.compile(r'^[a-zA-Z0-9._-]+$')
# ── I18n Protocol ────────────────────────────────────────────────────
@@ -200,6 +205,11 @@ class I18nFeature(BaseFeatureProtocol):
async def _strings(self, request: Request) -> JSONResponse:
mgr = get_i18n_manager()
locale = request.path_params["locale"]
# Validate locale format to prevent injection
if not LOCALE_PATTERN.match(locale):
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
strings = mgr.get_strings(locale)
if not strings:
return JSONResponse({"error": f"Locale '{locale}' not found"}, status_code=404)
@@ -208,10 +218,21 @@ class I18nFeature(BaseFeatureProtocol):
async def _translate(self, request: Request) -> JSONResponse:
mgr = get_i18n_manager()
body = await request.json()
text = mgr.t(
body.get("key", ""), locale=body.get("locale", ""), **body.get("variables", {})
)
return JSONResponse({"key": body.get("key", ""), "text": text})
# Validate locale if provided
locale = body.get("locale", "")
if locale:
if not LOCALE_PATTERN.match(locale):
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
# Validate key format (alphanumeric with dots)
key = body.get("key", "")
if key:
if not KEY_PATTERN.match(key):
return JSONResponse({"error": f"Invalid key format: '{key}'"}, status_code=400)
text = mgr.t(key, locale=locale, **body.get("variables", {}))
return JSONResponse({"key": key, "text": text})
async def _get_locale(self, request: Request) -> JSONResponse:
mgr = get_i18n_manager()
@@ -220,7 +241,13 @@ class I18nFeature(BaseFeatureProtocol):
async def _set_locale(self, request: Request) -> JSONResponse:
mgr = get_i18n_manager()
body = await request.json()
mgr.set_locale(body.get("locale", "en"))
locale = body.get("locale", "en")
# Validate locale format to prevent injection
if not LOCALE_PATTERN.match(locale):
return JSONResponse({"error": f"Invalid locale format: '{locale}'"}, status_code=400)
mgr.set_locale(locale)
return JSONResponse({"locale": mgr.get_locale()})
+2 -2
View File
@@ -59,8 +59,8 @@ class ConfigFeatureRegistry:
"SEO configuration with title templates, OG tags, Twitter meta - basic implementation"),
"a11y": FeatureInfo("a11y", FeatureStatus.IMPLEMENTED, "0.5.0",
"Accessibility configuration - basic implementation for skip links and ARIA labels"),
"i18n": FeatureInfo("i18n", FeatureStatus.EXPERIMENTAL, None,
"Internationalization accepted in schema but content pipeline/routing not implemented"),
"i18n": FeatureInfo("i18n", FeatureStatus.IMPLEMENTED, "0.5.0",
"Internationalization configuration with multi-language support"),
}
def get_feature(self, name: str) -> FeatureInfo | None:
+27
View File
@@ -1,12 +1,14 @@
"""Tests for i18n and accessibility configuration."""
from praisonaiui.schema.features import get_feature_registry
from praisonaiui.schema.models import (
A11yConfig,
Config,
I18nConfig,
SiteConfig,
)
from praisonaiui.schema.validators import validate_config
class TestI18nConfig:
@@ -112,3 +114,28 @@ class TestConfigWithI18nA11y:
assert config.i18n.locales == ["en", "ja"]
assert config.a11y.reduce_motion is True
def test_i18n_implemented_status(self):
"""Test that i18n is marked as IMPLEMENTED in feature registry."""
registry = get_feature_registry()
assert registry.is_implemented("i18n") is True
assert registry.is_experimental("i18n") is False
def test_strict_validation_passes_with_i18n(self):
"""Test that strict validation passes with valid i18n config."""
config = Config(
site=SiteConfig(title="Test Site"),
i18n=I18nConfig(
defaultLocale="en",
locales=["en", "es", "fr"],
rtlLocales=["ar"],
fallbackLocale="en"
),
)
result = validate_config(config, strict=True)
# Should not have experimental error (code 4001) for i18n
assert not any(e.code == 4001 and 'i18n' in e.message for e in result.errors)
# Validation should pass with no errors related to i18n being experimental
assert result.valid or len([e for e in result.errors if 'i18n' in e.message and 'experimental' in e.message]) == 0
+135
View File
@@ -0,0 +1,135 @@
"""Tests for i18n feature implementation."""
import pytest
from praisonaiui.features.i18n import I18nFeature, JSONLocaleManager, get_i18n_manager
class TestI18nFeature:
"""Tests for I18nFeature class."""
def test_feature_metadata(self):
"""Test feature metadata properties."""
feature = I18nFeature()
assert feature.name == "i18n"
assert "Internationalization" in feature.description
def test_feature_routes(self):
"""Test that i18n feature exposes correct routes."""
feature = I18nFeature()
routes = feature.routes()
# Check expected routes exist
route_paths = [route.path for route in routes]
assert "/api/i18n/locales" in route_paths
assert "/api/i18n/strings/{locale}" in route_paths
assert "/api/i18n/translate" in route_paths
assert "/api/i18n/locale" in route_paths
@pytest.mark.asyncio
async def test_feature_health(self):
"""Test health endpoint."""
feature = I18nFeature()
health = await feature.health()
assert health["status"] == "ok"
assert health["feature"] == "i18n"
assert "provider" in health
class TestJSONLocaleManager:
"""Tests for JSONLocaleManager."""
def test_default_initialization(self):
"""Test default initialization."""
manager = JSONLocaleManager()
assert manager.get_locale() == "en"
assert len(manager.list_locales()) > 0
def test_translation_lookup(self):
"""Test translation key lookup."""
manager = JSONLocaleManager()
# Test English translation
text = manager.t("app.welcome", locale="en")
assert "Welcome" in text
# Test Spanish translation
text = manager.t("app.welcome", locale="es")
assert "Bienvenido" in text
# Test French translation
text = manager.t("app.welcome", locale="fr")
assert "Bienvenue" in text
def test_translation_with_variables(self):
"""Test translation with variable substitution."""
manager = JSONLocaleManager()
manager._strings["en"]["greeting"] = "Hello {name}!"
text = manager.t("greeting", locale="en", name="World")
assert text == "Hello World!"
def test_unknown_key_returns_key(self):
"""Test that unknown keys return the key itself."""
manager = JSONLocaleManager()
text = manager.t("unknown.key", locale="en")
assert text == "unknown.key"
def test_locale_switching(self):
"""Test switching default locale."""
manager = JSONLocaleManager()
assert manager.get_locale() == "en"
manager.set_locale("es")
assert manager.get_locale() == "es"
# Test that default locale is used when none specified
text = manager.t("app.welcome")
assert "Bienvenido" in text
def test_get_strings(self):
"""Test getting all strings for a locale."""
manager = JSONLocaleManager()
en_strings = manager.get_strings("en")
assert "app.title" in en_strings
assert "chat.placeholder" in en_strings
es_strings = manager.get_strings("es")
assert "app.title" in es_strings
assert es_strings["chat.placeholder"] == "Escribe tu mensaje..."
def test_list_locales(self):
"""Test listing available locales."""
manager = JSONLocaleManager()
locales = manager.list_locales()
# Check structure
assert isinstance(locales, list)
assert len(locales) >= 3 # At least en, es, fr
# Check format
en_locale = next((loc for loc in locales if loc["code"] == "en"), None)
assert en_locale is not None
assert en_locale["name"] == "English"
assert en_locale["native"] == "English"
class TestI18nIntegration:
"""Integration tests for i18n system."""
def test_singleton_manager(self):
"""Test that get_i18n_manager returns singleton."""
mgr1 = get_i18n_manager()
mgr2 = get_i18n_manager()
assert mgr1 is mgr2
def test_manager_health(self):
"""Test manager health check."""
manager = get_i18n_manager()
health = manager.health()
assert health["status"] == "ok"
assert "provider" in health
assert "default_locale" in health