444 lines
No EOL
15 KiB
Text
444 lines
No EOL
15 KiB
Text
#!/usr/bin/env nu
|
|
# AuroraFrame i18n Framework - Hierarchical Locale System
|
|
#
|
|
# Supports global + page-specific locales with intelligent merging,
|
|
# build-time validation, and AI-powered translation assistance.
|
|
|
|
# Global i18n cache for performance
|
|
mut $i18n_cache = {}
|
|
mut $locale_bundles = {}
|
|
|
|
# Locale resolution priority: page-specific > global > fallback
|
|
export def resolve_locale_chain [
|
|
requested_locale: string
|
|
page_name?: string
|
|
site_config: record
|
|
] -> list<record> {
|
|
let available_locales = $site_config.i18n.available_locales
|
|
let fallback_chain = $site_config.i18n.fallback_chain
|
|
|
|
mut locale_chain = []
|
|
|
|
# 1. Try requested locale
|
|
if ($requested_locale in ($available_locales | each { |l| $l.code })) {
|
|
$locale_chain = ($locale_chain | append {
|
|
code: $requested_locale
|
|
source: "requested"
|
|
priority: 1
|
|
})
|
|
}
|
|
|
|
# 2. Find fallback for requested locale
|
|
let requested_config = ($available_locales | where code == $requested_locale | first)
|
|
if ($requested_config | is-not-empty) and ($requested_config.fallback? | is-not-empty) {
|
|
let fallback = $requested_config.fallback
|
|
if $fallback != $requested_locale { # Prevent infinite loops
|
|
$locale_chain = ($locale_chain | append {
|
|
code: $fallback
|
|
source: "locale_fallback"
|
|
priority: 2
|
|
})
|
|
}
|
|
}
|
|
|
|
# 3. Add global fallback chain
|
|
for fallback in $fallback_chain {
|
|
if not ($fallback in ($locale_chain | each { |l| $l.code })) {
|
|
$locale_chain = ($locale_chain | append {
|
|
code: $fallback
|
|
source: "global_fallback"
|
|
priority: 3
|
|
})
|
|
}
|
|
}
|
|
|
|
$locale_chain
|
|
}
|
|
|
|
# Load and merge locales for a specific page and locale
|
|
export def load_merged_locales [
|
|
locale_code: string
|
|
page_name?: string
|
|
site_config: record
|
|
page_config?: record
|
|
] -> record {
|
|
let cache_key = $"($locale_code):($page_name | default 'global')"
|
|
|
|
# Return cached if available
|
|
if $cache_key in $locale_bundles {
|
|
return ($locale_bundles | get $cache_key)
|
|
}
|
|
|
|
mut merged_bundle = {}
|
|
|
|
# 1. Load global locales
|
|
let global_dir = $site_config.i18n.global_locales_dir
|
|
let global_files = try {
|
|
ls $"($global_dir)/($locale_code)/*.ftl" | get name
|
|
} catch {
|
|
[]
|
|
}
|
|
|
|
for file in $global_files {
|
|
let namespace = ($file | path basename | str replace ".ftl" "")
|
|
let messages = (fluent parse $file)
|
|
$merged_bundle = ($merged_bundle | insert $namespace $messages)
|
|
}
|
|
|
|
# 2. Load page-specific locales if page is specified
|
|
if ($page_name | is-not-empty) and ($page_config | is-not-empty) {
|
|
let page_i18n = ($page_config.i18n? | default {})
|
|
|
|
if ($page_i18n.enabled? | default false) and ($page_i18n.extends_global? | default true) {
|
|
let pages_dir = $site_config.i18n.page_locales_dir
|
|
let page_files = try {
|
|
ls $"($pages_dir)/($page_name)/($locale_code)/*.ftl" | get name
|
|
} catch {
|
|
[]
|
|
}
|
|
|
|
for file in $page_files {
|
|
let namespace = ($file | path basename | str replace ".ftl" "")
|
|
let page_messages = (fluent parse $file)
|
|
|
|
# Merge or override based on configuration
|
|
if ($page_i18n.override_global? | default false) {
|
|
# Page messages can override global ones
|
|
if $namespace in $merged_bundle {
|
|
let existing = ($merged_bundle | get $namespace)
|
|
let combined = ($existing | merge $page_messages)
|
|
$merged_bundle = ($merged_bundle | upsert $namespace $combined)
|
|
} else {
|
|
$merged_bundle = ($merged_bundle | insert $namespace $page_messages)
|
|
}
|
|
} else {
|
|
# Global messages take precedence
|
|
if not ($namespace in $merged_bundle) {
|
|
$merged_bundle = ($merged_bundle | insert $namespace $page_messages)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Cache the merged bundle
|
|
$locale_bundles = ($locale_bundles | insert $cache_key $merged_bundle)
|
|
|
|
$merged_bundle
|
|
}
|
|
|
|
# Smart locale detection with fallback chain
|
|
export def detect_locale [
|
|
site_config: record
|
|
detection_methods?: list<string>
|
|
] -> string {
|
|
let methods = $detection_methods | default $site_config.i18n.locale_detection
|
|
let available_codes = ($site_config.i18n.available_locales | each { |l| $l.code })
|
|
|
|
for method in $methods {
|
|
let detected = match $method {
|
|
"url" => (detect_locale_from_url)
|
|
"cookie" => (detect_locale_from_cookie)
|
|
"header" => (detect_locale_from_header)
|
|
"default" => $site_config.i18n.default_locale
|
|
_ => null
|
|
}
|
|
|
|
if ($detected | is-not-empty) and ($detected in $available_codes) {
|
|
return $detected
|
|
}
|
|
}
|
|
|
|
# Ultimate fallback
|
|
$site_config.i18n.default_locale
|
|
}
|
|
|
|
# Get localized message with fallback chain
|
|
export def get_message [
|
|
message_id: string
|
|
locale_code: string
|
|
args?: record
|
|
page_name?: string
|
|
site_config: record
|
|
page_config?: record
|
|
] -> string {
|
|
let locale_chain = (resolve_locale_chain $locale_code $page_name $site_config)
|
|
|
|
for locale_info in $locale_chain {
|
|
let bundle = (load_merged_locales $locale_info.code $page_name $site_config $page_config)
|
|
|
|
# Try to find message in all namespaces
|
|
for namespace in ($bundle | columns) {
|
|
let namespace_messages = ($bundle | get $namespace)
|
|
if ($message_id in ($namespace_messages.messages | columns)) {
|
|
let message = ($namespace_messages.messages | get $message_id)
|
|
if ($args | is-not-empty) {
|
|
return (fluent localize $message_id --bundle $namespace_messages --args $args)
|
|
} else {
|
|
return (fluent localize $message_id --bundle $namespace_messages)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Fallback to message ID if not found
|
|
$"[[($message_id)]]"
|
|
}
|
|
|
|
# Build-time locale validation
|
|
export def validate_i18n_build [
|
|
site_config: record
|
|
pages: list<record>
|
|
] -> record {
|
|
mut validation_results = {
|
|
valid: true
|
|
warnings: []
|
|
errors: []
|
|
stats: {}
|
|
}
|
|
|
|
let available_locales = ($site_config.i18n.available_locales | each { |l| $l.code })
|
|
|
|
print "🌐 Validating i18n configuration..."
|
|
|
|
# 1. Validate site configuration
|
|
let site_validation = (validate_site_i18n_config $site_config)
|
|
if not $site_validation.valid {
|
|
$validation_results.valid = false
|
|
$validation_results.errors = ($validation_results.errors | append $site_validation.errors)
|
|
}
|
|
|
|
# 2. Validate each locale
|
|
mut locale_stats = {}
|
|
|
|
for locale_code in $available_locales {
|
|
print $" Checking locale: ($locale_code)"
|
|
|
|
# Check global messages
|
|
let global_bundle = (load_merged_locales $locale_code)
|
|
let global_message_count = ($global_bundle | values | each { |ns| $ns.messages | columns | length } | reduce { |acc, x| $acc + $x })
|
|
|
|
# Check page-specific messages
|
|
mut page_message_counts = {}
|
|
for page in $pages {
|
|
if ($page.config.i18n?.enabled? | default false) {
|
|
let page_bundle = (load_merged_locales $locale_code $page.name $site_config $page.config)
|
|
let page_message_count = ($page_bundle | values | each { |ns| $ns.messages | columns | length } | reduce { |acc, x| $acc + $x })
|
|
$page_message_counts = ($page_message_counts | insert $page.name $page_message_count)
|
|
}
|
|
}
|
|
|
|
$locale_stats = ($locale_stats | insert $locale_code {
|
|
global_messages: $global_message_count
|
|
page_messages: $page_message_counts
|
|
total_messages: ($global_message_count + ($page_message_counts | values | reduce { |acc, x| $acc + $x }))
|
|
})
|
|
}
|
|
|
|
# 3. Find missing translations
|
|
let reference_locale = $site_config.i18n.default_locale
|
|
let reference_stats = ($locale_stats | get $reference_locale)
|
|
|
|
for locale_code in $available_locales {
|
|
if $locale_code != $reference_locale {
|
|
let current_stats = ($locale_stats | get $locale_code)
|
|
|
|
if $current_stats.global_messages < $reference_stats.global_messages {
|
|
let missing = ($reference_stats.global_messages - $current_stats.global_messages)
|
|
$validation_results.warnings = ($validation_results.warnings | append $"($locale_code): Missing ($missing) global messages")
|
|
}
|
|
}
|
|
}
|
|
|
|
$validation_results.stats = $locale_stats
|
|
|
|
if ($validation_results.errors | length) == 0 and ($validation_results.warnings | length) == 0 {
|
|
print "✅ All i18n validations passed"
|
|
} else {
|
|
print $"⚠️ Found ($validation_results.warnings | length) warnings and ($validation_results.errors | length) errors"
|
|
}
|
|
|
|
$validation_results
|
|
}
|
|
|
|
# Generate build-optimized locale bundles
|
|
export def build_locale_bundles [
|
|
site_config: record
|
|
pages: list<record>
|
|
output_dir: string
|
|
] {
|
|
print "🔨 Building optimized locale bundles..."
|
|
|
|
let available_locales = ($site_config.i18n.available_locales | each { |l| $l.code })
|
|
let bundle_strategy = $site_config.i18n.bundle_splitting
|
|
|
|
mkdir $"($output_dir)/locales"
|
|
|
|
match $bundle_strategy {
|
|
"global" => {
|
|
# Single bundle per locale with all messages
|
|
for locale_code in $available_locales {
|
|
print $" Building global bundle: ($locale_code)"
|
|
|
|
mut combined_bundle = {}
|
|
|
|
# Add global messages
|
|
let global_bundle = (load_merged_locales $locale_code)
|
|
$combined_bundle = ($combined_bundle | merge $global_bundle)
|
|
|
|
# Add all page messages
|
|
for page in $pages {
|
|
if ($page.config.i18n?.enabled? | default false) {
|
|
let page_bundle = (load_merged_locales $locale_code $page.name $site_config $page.config)
|
|
$combined_bundle = ($combined_bundle | merge $page_bundle)
|
|
}
|
|
}
|
|
|
|
$combined_bundle | to json | save $"($output_dir)/locales/($locale_code).json"
|
|
}
|
|
}
|
|
|
|
"per-page" => {
|
|
# Separate bundle for each page + global
|
|
for locale_code in $available_locales {
|
|
print $" Building per-page bundles: ($locale_code)"
|
|
|
|
# Global bundle
|
|
let global_bundle = (load_merged_locales $locale_code)
|
|
$global_bundle | to json | save $"($output_dir)/locales/($locale_code)-global.json"
|
|
|
|
# Page bundles
|
|
for page in $pages {
|
|
if ($page.config.i18n?.enabled? | default false) {
|
|
let page_bundle = (load_merged_locales $locale_code $page.name $site_config $page.config)
|
|
$page_bundle | to json | save $"($output_dir)/locales/($locale_code)-($page.name).json"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
"hybrid" => {
|
|
# Global + page-specific overrides
|
|
for locale_code in $available_locales {
|
|
print $" Building hybrid bundles: ($locale_code)"
|
|
|
|
# Global bundle (shared across all pages)
|
|
let global_bundle = (load_merged_locales $locale_code)
|
|
$global_bundle | to json | save $"($output_dir)/locales/($locale_code)-global.json"
|
|
|
|
# Page-specific additions only
|
|
for page in $pages {
|
|
if ($page.config.i18n?.enabled? | default false) {
|
|
let page_locales_dir = $site_config.i18n.page_locales_dir
|
|
let page_files = try {
|
|
ls $"($page_locales_dir)/($page.name)/($locale_code)/*.ftl" | get name
|
|
} catch {
|
|
[]
|
|
}
|
|
|
|
if ($page_files | length) > 0 {
|
|
mut page_only_bundle = {}
|
|
for file in $page_files {
|
|
let namespace = ($file | path basename | str replace ".ftl" "")
|
|
let messages = (fluent parse $file)
|
|
$page_only_bundle = ($page_only_bundle | insert $namespace $messages)
|
|
}
|
|
$page_only_bundle | to json | save $"($output_dir)/locales/($locale_code)-($page.name).json"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
print "✅ Locale bundles built successfully"
|
|
}
|
|
|
|
# Helper functions
|
|
def detect_locale_from_url [] {
|
|
# Extract locale from URL path
|
|
let url_path = ($env.REQUEST_URI? | default "" | url parse | get path)
|
|
let path_parts = ($url_path | str trim --char "/" | split row "/")
|
|
|
|
if ($path_parts | length) > 0 {
|
|
let potential_locale = ($path_parts | first)
|
|
# Basic locale pattern matching
|
|
if ($potential_locale | str length) == 5 and ($potential_locale | str contains "-") {
|
|
return $potential_locale
|
|
}
|
|
}
|
|
|
|
null
|
|
}
|
|
|
|
def detect_locale_from_cookie [] {
|
|
$env.COOKIES?.locale? | default null
|
|
}
|
|
|
|
def detect_locale_from_header [] {
|
|
let accept_lang = ($env.ACCEPT_LANGUAGE? | default "")
|
|
|
|
if ($accept_lang | is-empty) {
|
|
return null
|
|
}
|
|
|
|
# Parse Accept-Language header (simplified)
|
|
let languages = ($accept_lang | split row "," | each { |lang|
|
|
$lang | str trim | split row ";" | first | str trim
|
|
})
|
|
|
|
# Return first supported language
|
|
for lang in $languages {
|
|
# Convert language codes to our format
|
|
let normalized = match $lang {
|
|
"en" => "en-US"
|
|
"es" => "es-ES"
|
|
"fr" => "fr-FR"
|
|
_ => $lang
|
|
}
|
|
return $normalized
|
|
}
|
|
|
|
null
|
|
}
|
|
|
|
def validate_site_i18n_config [site_config: record] -> record {
|
|
mut result = {
|
|
valid: true
|
|
errors: []
|
|
warnings: []
|
|
}
|
|
|
|
let i18n_config = $site_config.i18n
|
|
let available_codes = ($i18n_config.available_locales | each { |l| $l.code })
|
|
|
|
# Check default locale exists
|
|
if not ($i18n_config.default_locale in $available_codes) {
|
|
$result.valid = false
|
|
$result.errors = ($result.errors | append $"Default locale '($i18n_config.default_locale)' not in available locales")
|
|
}
|
|
|
|
# Check fallback chain
|
|
for fallback in $i18n_config.fallback_chain {
|
|
if not ($fallback in $available_codes) {
|
|
$result.valid = false
|
|
$result.errors = ($result.errors | append $"Fallback locale '($fallback)' not in available locales")
|
|
}
|
|
}
|
|
|
|
$result
|
|
}
|
|
|
|
# AI-powered commands (integrated with MCP)
|
|
export def "ai i18n generate" [
|
|
source_locale: string
|
|
target_locale: string
|
|
--page: string
|
|
--namespace: string
|
|
] {
|
|
print $"🤖 Generating ($target_locale) translations from ($source_locale)"
|
|
|
|
# Implementation will call MCP server for translation
|
|
# This is a placeholder for the AI integration
|
|
print " AI translation integration coming soon..."
|
|
} |