466 lines
18 KiB
Text
466 lines
18 KiB
Text
|
|
#!/usr/bin/env nu
|
||
|
|
|
||
|
|
# Translation Synchronization Script
|
||
|
|
# Nushell version of sync-translations.sh
|
||
|
|
# Synchronizes translation keys and manages localization files
|
||
|
|
|
||
|
|
def main [command?: string, ...args] {
|
||
|
|
let config = {
|
||
|
|
content_dir: ($env.SITE_CONTENT_PATH? | default "site/content"),
|
||
|
|
languages: ["en", "es"],
|
||
|
|
locales_dir: ($env.SITE_I18N_PATH? | default "site/i18n")
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"(ansi blue)🌍 Translation Synchronization Tool(ansi reset)"
|
||
|
|
|
||
|
|
if ($command | is-empty) or $command in ["-h", "--help", "help"] {
|
||
|
|
show_sync_usage
|
||
|
|
exit 0
|
||
|
|
}
|
||
|
|
|
||
|
|
match $command {
|
||
|
|
"extract-keys" | "extract" => { cmd_extract_keys $config $args }
|
||
|
|
"sync-keys" | "sync" => { cmd_sync_keys $config $args }
|
||
|
|
"validate-translations" | "validate" => { cmd_validate_translations $config $args }
|
||
|
|
"generate-missing" | "missing" => { cmd_generate_missing_translations $config $args }
|
||
|
|
"show-stats" | "stats" => { cmd_show_translation_stats $config }
|
||
|
|
"create-template" | "template" => { cmd_create_translation_template $config $args }
|
||
|
|
_ => {
|
||
|
|
print $"(ansi red)❌ Unknown command: ($command)(ansi reset)"
|
||
|
|
show_sync_usage
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Show usage information
|
||
|
|
def show_sync_usage [] {
|
||
|
|
print "Usage: nu sync-translations.nu [COMMAND] [OPTIONS]"
|
||
|
|
print ""
|
||
|
|
print "Commands:"
|
||
|
|
print " extract-keys Extract translation keys from content files"
|
||
|
|
print " sync-keys Synchronize translation keys across languages"
|
||
|
|
print " validate-translations Validate translation completeness"
|
||
|
|
print " generate-missing Generate missing translation entries"
|
||
|
|
print " show-stats Show translation statistics"
|
||
|
|
print " create-template Create translation template for new language"
|
||
|
|
print " help Show this help message"
|
||
|
|
print ""
|
||
|
|
print "Options:"
|
||
|
|
print " --lang LANGUAGE Target specific language [default: all]"
|
||
|
|
print " --source LANG Source language for template [default: en]"
|
||
|
|
print " --output DIR Output directory [default: content/locales]"
|
||
|
|
print ""
|
||
|
|
print "Examples:"
|
||
|
|
print " nu sync-translations.nu extract-keys"
|
||
|
|
print " nu sync-translations.nu sync-keys --lang es"
|
||
|
|
print " nu sync-translations.nu validate-translations"
|
||
|
|
print " nu sync-translations.nu create-template --lang fr --source en"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Extract translation keys from content files
|
||
|
|
def cmd_extract_keys [config, args] {
|
||
|
|
print $"(ansi blue)🔧 Extracting translation keys from content...(ansi reset)"
|
||
|
|
|
||
|
|
let content_types = get_content_types $config.content_dir
|
||
|
|
mut all_keys = {}
|
||
|
|
|
||
|
|
for content_type in $content_types {
|
||
|
|
print $"(ansi yellow) Processing ($content_type) content...(ansi reset)"
|
||
|
|
|
||
|
|
for language in $config.languages {
|
||
|
|
let content_dir = $"($config.content_dir)/($content_type)/($language)"
|
||
|
|
|
||
|
|
if not ($content_dir | path exists) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
let md_files = (ls $"($content_dir)/**/*.md" | where type == file)
|
||
|
|
|
||
|
|
for file_info in $md_files {
|
||
|
|
let md_file = $file_info.name
|
||
|
|
let file_keys = extract_translation_keys_from_file $md_file
|
||
|
|
|
||
|
|
for key in $file_keys {
|
||
|
|
if $key not-in ($all_keys | columns) {
|
||
|
|
$all_keys = ($all_keys | upsert $key {
|
||
|
|
source: $"($content_type)/($language)/($md_file | path basename)",
|
||
|
|
languages: [$language]
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
let current = ($all_keys | get $key)
|
||
|
|
let updated_languages = ($current.languages | append $language | uniq)
|
||
|
|
$all_keys = ($all_keys | upsert $key ($current | upsert languages $updated_languages))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let keys_count = ($all_keys | columns | length)
|
||
|
|
print $"(ansi green)✅ Extracted ($keys_count) unique translation keys(ansi reset)"
|
||
|
|
|
||
|
|
# Save extracted keys
|
||
|
|
mkdir $config.locales_dir
|
||
|
|
let keys_file = $"($config.locales_dir)/extracted-keys.json"
|
||
|
|
$all_keys | to json | save $keys_file
|
||
|
|
|
||
|
|
print $"(ansi green)💾 Keys saved to: ($keys_file)(ansi reset)"
|
||
|
|
|
||
|
|
# Show key statistics
|
||
|
|
print ""
|
||
|
|
print $"(ansi blue)📊 Key Statistics:(ansi reset)"
|
||
|
|
for key in ($all_keys | columns | first 10) {
|
||
|
|
let key_info = ($all_keys | get $key)
|
||
|
|
print $" ($key): found in ($key_info.languages | length) language(s)"
|
||
|
|
}
|
||
|
|
|
||
|
|
if $keys_count > 10 {
|
||
|
|
print $" ... and ($keys_count - 10) more keys"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Extract translation keys from a single file
|
||
|
|
def extract_translation_keys_from_file [file_path: string] {
|
||
|
|
try {
|
||
|
|
let content = (open $file_path)
|
||
|
|
|
||
|
|
# Look for common translation key patterns
|
||
|
|
# This is a simplified version - can be enhanced with more sophisticated patterns
|
||
|
|
mut keys = []
|
||
|
|
|
||
|
|
# Pattern 1: {{t "key"}} or {{t 'key'}}
|
||
|
|
let t_pattern_matches = ($content | str find-replace --all --regex '\{\{t\s+["\']([^"\']+)["\'][^}]*\}\}' '$1' | split row '\n' | where {|line| $line != $content})
|
||
|
|
$keys = ($keys | append $t_pattern_matches)
|
||
|
|
|
||
|
|
# Pattern 2: i18n("key") or i18n('key')
|
||
|
|
let i18n_pattern_matches = ($content | str find-replace --all --regex 'i18n\(["\']([^"\']+)["\']\)' '$1' | split row '\n' | where {|line| $line != $content})
|
||
|
|
$keys = ($keys | append $i18n_pattern_matches)
|
||
|
|
|
||
|
|
# Pattern 3: translate("key") or translate('key')
|
||
|
|
let translate_pattern_matches = ($content | str find-replace --all --regex 'translate\(["\']([^"\']+)["\']\)' '$1' | split row '\n' | where {|line| $line != $content})
|
||
|
|
$keys = ($keys | append $translate_pattern_matches)
|
||
|
|
|
||
|
|
$keys | uniq
|
||
|
|
} catch {
|
||
|
|
[]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Synchronize translation keys across languages
|
||
|
|
def cmd_sync_keys [config, args] {
|
||
|
|
print $"(ansi blue)🔧 Synchronizing translation keys across languages...(ansi reset)"
|
||
|
|
|
||
|
|
# Parse target language from args
|
||
|
|
let target_lang = (parse_lang_arg $args | default "all")
|
||
|
|
|
||
|
|
let languages = if $target_lang == "all" { $config.languages } else { [$target_lang] }
|
||
|
|
|
||
|
|
# Load existing translation files
|
||
|
|
mut translation_files = {}
|
||
|
|
|
||
|
|
for language in $config.languages {
|
||
|
|
let fluent_file = $"($config.content_dir)/($language).ftl"
|
||
|
|
let locale_file = $"($config.locales_dir)/($language).json"
|
||
|
|
|
||
|
|
# Try to load existing translations
|
||
|
|
mut translations = {}
|
||
|
|
|
||
|
|
if ($fluent_file | path exists) {
|
||
|
|
$translations = (load_fluent_translations $fluent_file)
|
||
|
|
} else if ($locale_file | path exists) {
|
||
|
|
$translations = (open $locale_file | from json)
|
||
|
|
}
|
||
|
|
|
||
|
|
$translation_files = ($translation_files | upsert $language $translations)
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load extracted keys
|
||
|
|
let keys_file = $"($config.locales_dir)/extracted-keys.json"
|
||
|
|
if not ($keys_file | path exists) {
|
||
|
|
print $"(ansi red)❌ No extracted keys found. Run 'extract-keys' first.(ansi reset)"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
let extracted_keys = (open $keys_file | from json)
|
||
|
|
|
||
|
|
# Sync keys across languages
|
||
|
|
for language in $languages {
|
||
|
|
print $"(ansi yellow) Syncing keys for ($language)...(ansi reset)"
|
||
|
|
|
||
|
|
let current_translations = ($translation_files | get $language)
|
||
|
|
mut updated_translations = $current_translations
|
||
|
|
|
||
|
|
# Add missing keys
|
||
|
|
mut added_keys = 0
|
||
|
|
for key in ($extracted_keys | columns) {
|
||
|
|
if $key not-in ($current_translations | columns) {
|
||
|
|
# Add placeholder translation
|
||
|
|
$updated_translations = ($updated_translations | upsert $key $"[TODO: Translate '($key)' to ($language)]")
|
||
|
|
$added_keys = $added_keys + 1
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if $added_keys > 0 {
|
||
|
|
# Save updated translations
|
||
|
|
let output_file = $"($config.locales_dir)/($language).json"
|
||
|
|
$updated_translations | to json | save $output_file
|
||
|
|
print $"(ansi green) ✅ Added ($added_keys) new keys to ($language)(ansi reset)"
|
||
|
|
} else {
|
||
|
|
print $"(ansi green) ✅ ($language) translations are up to date(ansi reset)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"(ansi green)🎉 Key synchronization completed!(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate translation completeness
|
||
|
|
def cmd_validate_translations [config, args] {
|
||
|
|
print $"(ansi blue)🔧 Validating translation completeness...(ansi reset)"
|
||
|
|
|
||
|
|
mut total_issues = 0
|
||
|
|
mut translation_stats = {}
|
||
|
|
|
||
|
|
for language in $config.languages {
|
||
|
|
let fluent_file = $"($config.content_dir)/($language).ftl"
|
||
|
|
let locale_file = $"($config.locales_dir)/($language).json"
|
||
|
|
|
||
|
|
mut translations = {}
|
||
|
|
mut file_type = ""
|
||
|
|
|
||
|
|
if ($fluent_file | path exists) {
|
||
|
|
$translations = (load_fluent_translations $fluent_file)
|
||
|
|
$file_type = "ftl"
|
||
|
|
} else if ($locale_file | path exists) {
|
||
|
|
$translations = (open $locale_file | from json)
|
||
|
|
$file_type = "json"
|
||
|
|
} else {
|
||
|
|
print $"(ansi red)❌ No translation file found for ($language)(ansi reset)"
|
||
|
|
$total_issues = $total_issues + 1
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
# Count translations and find issues
|
||
|
|
let total_keys = ($translations | columns | length)
|
||
|
|
let todo_translations = ($translations | values | where {|val| ($val | str contains "TODO") or ($val | str contains "TRANSLATE")})
|
||
|
|
let empty_translations = ($translations | values | where {|val| ($val | str trim | is-empty)})
|
||
|
|
|
||
|
|
let incomplete_count = ($todo_translations | length) + ($empty_translations | length)
|
||
|
|
let complete_count = $total_keys - $incomplete_count
|
||
|
|
|
||
|
|
$translation_stats = ($translation_stats | upsert $language {
|
||
|
|
total: $total_keys,
|
||
|
|
complete: $complete_count,
|
||
|
|
incomplete: $incomplete_count,
|
||
|
|
file_type: $file_type,
|
||
|
|
completion_rate: (if $total_keys > 0 { ($complete_count * 100 / $total_keys) } else { 0 })
|
||
|
|
})
|
||
|
|
|
||
|
|
if $incomplete_count > 0 {
|
||
|
|
print $"(ansi yellow)⚠️ ($language): ($incomplete_count) incomplete translations out of ($total_keys)(ansi reset)"
|
||
|
|
$total_issues = $total_issues + $incomplete_count
|
||
|
|
} else {
|
||
|
|
print $"(ansi green)✅ ($language): All ($total_keys) translations complete(ansi reset)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Show summary
|
||
|
|
print ""
|
||
|
|
print $"(ansi blue)📊 Translation Completeness Summary(ansi reset)"
|
||
|
|
|
||
|
|
for language in $config.languages {
|
||
|
|
if $language in ($translation_stats | columns) {
|
||
|
|
let stats = ($translation_stats | get $language)
|
||
|
|
let rate = ($stats.completion_rate | into string)
|
||
|
|
print $" ($language): ($stats.complete)/($stats.total) complete (($rate)%) - ($stats.file_type) format"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if $total_issues == 0 {
|
||
|
|
print $"(ansi green)🎉 All translations are complete!(ansi reset)"
|
||
|
|
} else {
|
||
|
|
print $"(ansi yellow)⚠️ ($total_issues) translation issues found(ansi reset)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Generate missing translation entries
|
||
|
|
def cmd_generate_missing_translations [config, args] {
|
||
|
|
print $"(ansi blue)🔧 Generating missing translation entries...(ansi reset)"
|
||
|
|
|
||
|
|
# This would integrate with translation APIs or create templates
|
||
|
|
# For now, create structured templates for manual translation
|
||
|
|
|
||
|
|
let target_lang = (parse_lang_arg $args | default "all")
|
||
|
|
let languages = if $target_lang == "all" { $config.languages } else { [$target_lang] }
|
||
|
|
|
||
|
|
for language in $languages {
|
||
|
|
let locale_file = $"($config.locales_dir)/($language).json"
|
||
|
|
|
||
|
|
if ($locale_file | path exists) {
|
||
|
|
let translations = (open $locale_file | from json)
|
||
|
|
let incomplete_keys = ($translations | transpose key value | where {|row| ($row.value | str contains "TODO") or ($row.value | str trim | is-empty)})
|
||
|
|
|
||
|
|
if ($incomplete_keys | length) > 0 {
|
||
|
|
let template_file = $"($config.locales_dir)/($language)-template.md"
|
||
|
|
create_translation_template $incomplete_keys $language $template_file
|
||
|
|
print $"(ansi green)✅ Created translation template: ($template_file)(ansi reset)"
|
||
|
|
} else {
|
||
|
|
print $"(ansi green)✅ ($language): No missing translations(ansi reset)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Show translation statistics
|
||
|
|
def cmd_show_translation_stats [config] {
|
||
|
|
print $"(ansi blue)📊 Translation Statistics(ansi reset)"
|
||
|
|
print $"(ansi blue)Languages: ($config.languages | str join ', ')(ansi reset)"
|
||
|
|
print $"(ansi blue)Locales directory: ($config.locales_dir)(ansi reset)"
|
||
|
|
print ""
|
||
|
|
|
||
|
|
for language in $config.languages {
|
||
|
|
let fluent_file = $"($config.content_dir)/($language).ftl"
|
||
|
|
let locale_file = $"($config.locales_dir)/($language).json"
|
||
|
|
|
||
|
|
print $"(ansi cyan)📝 ($language | str upcase):(ansi reset)"
|
||
|
|
|
||
|
|
if ($fluent_file | path exists) {
|
||
|
|
let file_size = (du $fluent_file | get apparent | first)
|
||
|
|
let line_count = (open $fluent_file | lines | length)
|
||
|
|
print $" Fluent file: ($fluent_file) (($line_count) lines, ($file_size))"
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($locale_file | path exists) {
|
||
|
|
let translations = (open $locale_file | from json)
|
||
|
|
let total_keys = ($translations | columns | length)
|
||
|
|
let incomplete = ($translations | values | where {|val| ($val | str contains "TODO") or ($val | str trim | is-empty)} | length)
|
||
|
|
let complete = $total_keys - $incomplete
|
||
|
|
|
||
|
|
print $" JSON file: ($locale_file) (($total_keys) keys)"
|
||
|
|
print $" Complete: ($complete), Incomplete: ($incomplete)"
|
||
|
|
}
|
||
|
|
|
||
|
|
if not ($fluent_file | path exists) and not ($locale_file | path exists) {
|
||
|
|
print $" ❌ No translation files found"
|
||
|
|
}
|
||
|
|
|
||
|
|
print ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create translation template for new language
|
||
|
|
def cmd_create_translation_template [config, args] {
|
||
|
|
let target_lang = (parse_lang_arg $args | default "")
|
||
|
|
let source_lang = (parse_source_arg $args | default "en")
|
||
|
|
|
||
|
|
if ($target_lang | is-empty) {
|
||
|
|
print $"(ansi red)❌ Target language required. Use --lang LANGUAGE(ansi reset)"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"(ansi blue)🔧 Creating translation template for ($target_lang) based on ($source_lang)...(ansi reset)"
|
||
|
|
|
||
|
|
let source_file = $"($config.locales_dir)/($source_lang).json"
|
||
|
|
if not ($source_file | path exists) {
|
||
|
|
print $"(ansi red)❌ Source translation file not found: ($source_file)(ansi reset)"
|
||
|
|
exit 1
|
||
|
|
}
|
||
|
|
|
||
|
|
let source_translations = (open $source_file | from json)
|
||
|
|
let template_translations = ($source_translations | transpose key value | each {|row|
|
||
|
|
{key: $row.key, value: $"[TODO: Translate '($row.value)' to ($target_lang)]"}
|
||
|
|
} | reduce --fold {} {|row, acc| $acc | upsert $row.key $row.value})
|
||
|
|
|
||
|
|
let target_file = $"($config.locales_dir)/($target_lang).json"
|
||
|
|
$template_translations | to json | save $target_file
|
||
|
|
|
||
|
|
print $"(ansi green)✅ Created translation template: ($target_file)(ansi reset)"
|
||
|
|
print $"(ansi green)📝 ($source_translations | columns | length) keys ready for translation(ansi reset)"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get content types from directory structure
|
||
|
|
def get_content_types [content_dir: string] {
|
||
|
|
if not ($content_dir | path exists) {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
|
||
|
|
ls $content_dir
|
||
|
|
| where type == dir
|
||
|
|
| where name !~ "(locales|themes|menus|footer|tmp|routes)"
|
||
|
|
| get name
|
||
|
|
| path basename
|
||
|
|
}
|
||
|
|
|
||
|
|
# Load translations from Fluent (.ftl) file
|
||
|
|
def load_fluent_translations [file_path: string] {
|
||
|
|
try {
|
||
|
|
let content = (open $file_path | lines)
|
||
|
|
mut translations = {}
|
||
|
|
|
||
|
|
for line in $content {
|
||
|
|
if ($line | str trim | str starts-with "#") or ($line | str trim | is-empty) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
if $line =~ '^([^=]+)=(.*)$' {
|
||
|
|
let matches = ($line | parse --regex '^([^=]+)=(.*)$')
|
||
|
|
if ($matches | length) > 0 {
|
||
|
|
let key = ($matches | first | get capture0 | str trim)
|
||
|
|
let value = ($matches | first | get capture1 | str trim)
|
||
|
|
$translations = ($translations | upsert $key $value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$translations
|
||
|
|
} catch {
|
||
|
|
{}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create translation template markdown file
|
||
|
|
def create_translation_template [incomplete_keys, language: string, template_file: string] {
|
||
|
|
mut content = $"# Translation Template for ($language | str upcase)\n\n"
|
||
|
|
$content = $content + $"This file contains keys that need translation to ($language).\n"
|
||
|
|
$content = $content + $"Please translate the values and update the JSON file.\n\n"
|
||
|
|
|
||
|
|
for row in $incomplete_keys {
|
||
|
|
$content = $content + $"## ($row.key)\n"
|
||
|
|
$content = $content + $"Current: ($row.value)\n"
|
||
|
|
$content = $content + $"Translation: _[Please provide ($language) translation here]_\n\n"
|
||
|
|
}
|
||
|
|
|
||
|
|
$content | save $template_file
|
||
|
|
}
|
||
|
|
|
||
|
|
# Parse language argument from args
|
||
|
|
def parse_lang_arg [args] {
|
||
|
|
mut i = 0
|
||
|
|
while $i < ($args | length) {
|
||
|
|
let arg = ($args | get $i)
|
||
|
|
if $arg == "--lang" {
|
||
|
|
$i = $i + 1
|
||
|
|
if $i < ($args | length) {
|
||
|
|
return ($args | get $i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$i = $i + 1
|
||
|
|
}
|
||
|
|
null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Parse source language argument from args
|
||
|
|
def parse_source_arg [args] {
|
||
|
|
mut i = 0
|
||
|
|
while $i < ($args | length) {
|
||
|
|
let arg = ($args | get $i)
|
||
|
|
if $arg == "--source" {
|
||
|
|
$i = $i + 1
|
||
|
|
if $i < ($args | length) {
|
||
|
|
return ($args | get $i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$i = $i + 1
|
||
|
|
}
|
||
|
|
null
|
||
|
|
}
|