Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude 8ba8681eb7 feat: wire i18n config to frontend runtime (fixes #126)
- Implement LocaleProvider component for React context-based i18n
- Add locale switcher to Header when multiple locales configured
- Wire translation hook to Footer and ChatInput components
- Support RTL locales with automatic dir="rtl" on document root
- Update feature registry to mark i18n as IMPLEMENTED (v0.5.0)
- Remove experimental comment from compiler i18n emission
- Add tests for i18n implementation and strict validation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-06-17 09:44:27 +00:00
8 changed files with 225 additions and 7 deletions

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/LocaleProvider'
function SkipLink({ enabled }: { enabled?: boolean }) {
if (!enabled) return null
@@ -372,5 +373,9 @@ export default function App() {
}
}
return renderByStyle()
return (
<LocaleProvider config={config.i18n}>
{renderByStyle()}
</LocaleProvider>
)
}
+3 -1
View File
@@ -1,7 +1,9 @@
// Footer component
import type { UIConfig } from './types'
import { useLocale } from './i18n/LocaleProvider'
export function Footer({ config }: { config: UIConfig }) {
const { t } = useLocale()
// Resolve footer component via template slot ref (e.g., footer_main)
const footerSlot = config.templates?.docs?.slots?.footer
const footerRef = footerSlot?.ref
@@ -20,7 +22,7 @@ export function Footer({ config }: { config: UIConfig }) {
<div className="w-5 h-5 rounded bg-gradient-to-br from-primary/60 to-primary flex items-center justify-center">
<span className="text-primary-foreground text-[8px] font-bold">AI</span>
</div>
<span>{footer?.text || `© ${new Date().getFullYear()} PraisonAIUI`}</span>
<span>{footer?.text || t('footer.copyright', `© ${new Date().getFullYear()} PraisonAIUI`)}</span>
</div>
<nav className="flex items-center gap-6">
{footer?.links?.map((link) => (
+17
View File
@@ -1,7 +1,9 @@
// Header component
import type { UIConfig } from './types'
import { useLocale } from './i18n/LocaleProvider'
export function Header({ config }: { config: UIConfig }) {
const { locale, locales, setLocale, t } = useLocale()
// Resolve header component via template slot ref (e.g., header_main)
const headerSlot = config.templates?.docs?.slots?.header
const headerRef = headerSlot?.ref
@@ -56,6 +58,21 @@ export function Header({ config }: { config: UIConfig }) {
</nav>
<div className="flex items-center gap-2">
{locales.length > 1 && (
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="h-9 px-3 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
aria-label={t('locale.switcher')}
data-testid="locale-select"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{loc.toUpperCase()}
</option>
))}
</select>
)}
{header?.cta && (
<a
href={header.cta.href}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useRef, type KeyboardEvent, type ChangeEvent } from 'react'
import { useLocale } from '../i18n/LocaleProvider'
interface ChatInputProps {
onSend: (message: string, files?: File[]) => void
@@ -12,9 +13,11 @@ export function ChatInput({
onSend,
onCancel,
isStreaming = false,
placeholder = 'Type a message...',
placeholder,
enableFileUpload = false,
}: ChatInputProps) {
const { t } = useLocale()
const finalPlaceholder = placeholder || t('chat.placeholder', 'Type a message...')
const [input, setInput] = useState('')
const [files, setFiles] = useState<File[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -119,7 +122,7 @@ export function ChatInput({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={placeholder}
placeholder={finalPlaceholder}
disabled={isStreaming}
rows={1}
className="flex-1 resize-none bg-transparent px-3 py-2.5 text-sm focus:outline-none disabled:opacity-50 placeholder:text-muted-foreground/60"
+160
View File
@@ -0,0 +1,160 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import type { I18nConfig } from '../types'
interface TranslationMap {
[key: string]: string
}
interface LocaleContextType {
locale: string
locales: string[]
rtlLocales: string[]
setLocale: (locale: string) => void
t: (key: string, fallback?: string) => string
isRTL: boolean
}
const LocaleContext = createContext<LocaleContextType | null>(null)
const DEFAULT_TRANSLATIONS: Record<string, TranslationMap> = {
en: {
'nav.home': 'Home',
'nav.docs': 'Documentation',
'nav.api': 'API',
'nav.settings': 'Settings',
'chat.placeholder': 'Type your message...',
'chat.send': 'Send',
'footer.copyright': 'All rights reserved',
'footer.privacy': 'Privacy',
'footer.terms': 'Terms',
'locale.switcher': 'Language',
},
es: {
'nav.home': 'Inicio',
'nav.docs': 'Documentación',
'nav.api': 'API',
'nav.settings': 'Configuración',
'chat.placeholder': 'Escribe tu mensaje...',
'chat.send': 'Enviar',
'footer.copyright': 'Todos los derechos reservados',
'footer.privacy': 'Privacidad',
'footer.terms': 'Términos',
'locale.switcher': 'Idioma',
},
ar: {
'nav.home': 'الصفحة الرئيسية',
'nav.docs': 'الوثائق',
'nav.api': 'واجهة البرمجة',
'nav.settings': 'الإعدادات',
'chat.placeholder': 'اكتب رسالتك...',
'chat.send': 'إرسال',
'footer.copyright': 'جميع الحقوق محفوظة',
'footer.privacy': 'الخصوصية',
'footer.terms': 'الشروط',
'locale.switcher': 'اللغة',
},
}
export function LocaleProvider({
children,
config
}: {
children: ReactNode
config?: I18nConfig
}) {
const defaultLocale = config?.defaultLocale || 'en'
const availableLocales = config?.locales || ['en']
const rtlLocales = config?.rtlLocales || []
const fallbackLocale = config?.fallbackLocale || 'en'
const [locale, setLocaleState] = useState(() => {
const saved = localStorage.getItem('locale')
return saved && availableLocales.includes(saved) ? saved : defaultLocale
})
const [translations, setTranslations] = useState<Record<string, TranslationMap>>({})
useEffect(() => {
const loadTranslations = async () => {
const translationCache: Record<string, TranslationMap> = {}
for (const loc of availableLocales) {
try {
const response = await fetch(`/api/i18n/strings/${loc}`)
if (response.ok) {
const data = await response.json()
translationCache[loc] = data.strings || data || DEFAULT_TRANSLATIONS[loc] || {}
} else {
translationCache[loc] = DEFAULT_TRANSLATIONS[loc] || {}
}
} catch (error) {
console.warn(`Failed to load translations for ${loc}`, error)
translationCache[loc] = DEFAULT_TRANSLATIONS[loc] || {}
}
}
setTranslations(translationCache)
}
if (config?.locales && config.locales.length > 0) {
loadTranslations()
} else {
setTranslations(DEFAULT_TRANSLATIONS)
}
}, [config, availableLocales])
useEffect(() => {
const isRTL = rtlLocales.includes(locale)
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
document.documentElement.lang = locale
localStorage.setItem('locale', locale)
}, [locale, rtlLocales])
const setLocale = (newLocale: string) => {
if (availableLocales.includes(newLocale)) {
setLocaleState(newLocale)
}
}
const t = (key: string, fallback?: string): string => {
const localeTranslations = translations[locale] || {}
const fallbackTranslations = translations[fallbackLocale] || {}
return localeTranslations[key] ||
fallbackTranslations[key] ||
fallback ||
key
}
const isRTL = rtlLocales.includes(locale)
return (
<LocaleContext.Provider
value={{
locale,
locales: availableLocales,
rtlLocales,
setLocale,
t,
isRTL
}}
>
{children}
</LocaleContext.Provider>
)
}
export function useLocale() {
const context = useContext(LocaleContext)
if (!context) {
return {
locale: 'en',
locales: ['en'],
rtlLocales: [],
setLocale: () => {},
t: (key: string, fallback?: string) => fallback || key,
isRTL: false
}
}
return context
}
+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,
+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 with locale switching and RTL support"),
}
def get_feature(self, name: str) -> FeatureInfo | None:
+31
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,32 @@ class TestConfigWithI18nA11y:
assert config.i18n.locales == ["en", "ja"]
assert config.a11y.reduce_motion is True
class TestI18nImplementation:
"""Test i18n feature implementation status."""
def test_i18n_is_implemented(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_validate_i18n_no_error(self):
"""Test that strict validation passes with valid i18n config."""
config = Config(
site=SiteConfig(title="I18n Test"),
i18n=I18nConfig(
defaultLocale="en",
locales=["en", "es", "ar"],
rtlLocales=["ar"],
),
)
result = validate_config(config, strict=True)
assert result.valid is True
# Verify no 4001 errors for i18n
experimental_errors = [e for e in result.errors if e.code == 4001]
i18n_errors = [e for e in experimental_errors if "i18n" in e.message]
assert len(i18n_errors) == 0