diff --git a/CHANGELOG.md b/CHANGELOG.md index a391d97..755671c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2024-12-01 + +### Added + +- **New `keys` command**: Comprehensive key management utilities + - `gleam run -m g18n/dev keys --list` - List all translation keys + - `gleam run -m g18n/dev keys --prefix ` - Find keys with specific prefix + - `gleam run -m g18n/dev keys --unused ` - Find unused translation keys +- **New `convert` command**: Convert between translation formats + - `gleam run -m g18n/dev convert --to flat` - Convert to flat JSON format + - `gleam run -m g18n/dev convert --to nested` - Convert to nested JSON format + - `gleam run -m g18n/dev convert --to po` - Convert to PO file format +- **New `params` command**: Parameter analysis and validation + - `gleam run -m g18n/dev params --check` - Check for parameter issues across locales + - `gleam run -m g18n/dev params --extract ` - Extract parameters from specific key +- **New `check` command**: CI/CD-ready validation with proper exit codes + - `gleam run -m g18n/dev check` - Validate translations and exit with status code + - `gleam run -m g18n/dev check --primary ` - Validate with specific primary locale + - Exits with code 0 for success, 1 for validation errors +- **Cross-platform FFI exit handling**: Proper process exit codes for automation + - JavaScript support (Node.js, Deno) + - Erlang support using `halt/1` + - Perfect for CI/CD pipelines, pre-commit hooks, and build automation + +### Enhanced + +- **All commands now support automatic format detection**: Works seamlessly with flat JSON, nested JSON, and PO files +- **Comprehensive validation**: Detects missing keys, parameter issues, empty translations, and plural form problems +- **Built-in g18n integration**: Uses core g18n functions like `get_keys_with_prefix()`, `find_unused_translations()`, `extract_placeholders()` + ## [1.1.0] - 2024-12-01 ### Added diff --git a/README.md b/README.md index b5973f0..9b787fb 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,51 @@ gleam run -m g18n/dev report --primary es Use a specific locale as the primary reference for comparison. +### Key Management + +```sh +# List all translation keys +gleam run -m g18n/dev keys --list + +# Find keys with specific prefix +gleam run -m g18n/dev keys --prefix ui.button + +# Find unused keys (requires file with used keys, one per line) +gleam run -m g18n/dev keys --unused used-keys.txt +``` + +### Format Conversion + +```sh +# Convert translations between formats +gleam run -m g18n/dev convert --to flat # Convert to flat JSON +gleam run -m g18n/dev convert --to nested # Convert to nested JSON +gleam run -m g18n/dev convert --to po # Convert to PO files +``` + +### Parameter Analysis + +```sh +# Check for parameter issues across all locales +gleam run -m g18n/dev params --check + +# Extract parameters from a specific translation key +gleam run -m g18n/dev params --extract user.greeting +``` + +### CI/CD Validation + +```sh +# Validate translations with proper exit codes (perfect for CI/CD) +gleam run -m g18n/dev check # Exit 0 = success, 1 = errors +gleam run -m g18n/dev check --primary en # Use specific primary locale +``` + +The `check` command is designed for automation and will: +- Exit with code **0** if all translations are valid +- Exit with code **1** if validation errors are found +- Perfect for pre-commit hooks, GitHub Actions, and build pipelines + ### Help ```sh @@ -130,6 +175,48 @@ pub fn main() { - ✅ Nested JSON (react-i18next, Vue i18n, Angular i18n compatible) - ✅ PO files (gettext standard) +## Automation & CI/CD Integration + +### GitHub Actions + +```yaml +name: Check Translations +on: [push, pull_request] + +jobs: + translations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26.0" + gleam-version: "1.0.0" + - run: gleam deps download + - run: gleam run -m g18n/dev check --primary en +``` + +### Pre-commit Hook + +```bash +#!/bin/sh +# .git/hooks/pre-commit +gleam run -m g18n/dev check --primary en +if [ $? -ne 0 ]; then + echo "❌ Translation validation failed. Fix issues before committing." + exit 1 +fi +``` + +### Build Scripts + +```bash +# In your build script +echo "Validating translations..." +gleam run -m g18n/dev check --primary en || exit 1 +echo "✅ Translations valid!" +``` + ## Development ```sh diff --git a/gleam.toml b/gleam.toml index e379713..602f672 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "g18n_dev" -version = "1.1.1" +version = "1.2.0" description = "Development tools and CLI for the g18n internationalization library" licences = ["MIT"] diff --git a/src/g18n/dev.gleam b/src/g18n/dev.gleam index f9dce23..0955edc 100644 --- a/src/g18n/dev.gleam +++ b/src/g18n/dev.gleam @@ -1,6 +1,7 @@ import argv import filepath import g18n +import g18n/dev/process import g18n/locale import gleam/bool import gleam/dict @@ -33,6 +34,14 @@ pub fn main() { ["generate", "--po"] -> generate_po_command() ["report"] -> report_command("") ["report", "--primary", locale] -> report_command(locale) + ["keys", "--list"] -> keys_command("list", "") + ["keys", "--prefix", prefix] -> keys_command("prefix", prefix) + ["keys", "--unused", usage_file] -> keys_command("unused", usage_file) + ["convert", "--to", format] -> convert_command(format) + ["params", "--check"] -> params_command("check", "") + ["params", "--extract", key] -> params_command("extract", key) + ["check"] -> check_command("") + ["check", "--primary", locale] -> check_command(locale) ["help"] -> help_command() [] -> help_command() _ -> { @@ -78,6 +87,41 @@ fn report_command(primary_locale: String) { } } +fn keys_command(operation: String, arg: String) { + case execute_keys_operation(operation, arg) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn convert_command(format: String) { + case execute_convert_operation(format) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn params_command(operation: String, arg: String) { + case execute_params_operation(operation, arg) { + Ok(_) -> Nil + Error(msg) -> io.println_error(snag.pretty_print(msg)) + } +} + +fn check_command(primary_locale: String) { + case execute_check_operation(primary_locale) { + Ok(_) -> { + // Exit with 0 to indicate success - only for check command + process.exit(0) + } + Error(msg) -> { + io.println_error(snag.pretty_print(msg)) + // Exit with 1 to indicate failure - only for check command + process.exit(1) + } + } +} + fn help_command() { io.println("g18n CLI - Internationalization for Gleam") io.println("") @@ -91,6 +135,14 @@ fn help_command() { ) io.println(" report Show translation coverage report") io.println(" report --primary Set primary locale for comparison") + io.println(" keys --list List all translation keys") + io.println(" keys --prefix Find keys with specific prefix") + io.println(" keys --unused Find unused keys (provide file with used keys)") + io.println(" convert --to Convert translations (flat, nested, po)") + io.println(" params --check Check for parameter issues across locales") + io.println(" params --extract Extract parameters from specific key") + io.println(" check Validate translations and exit with error if issues found") + io.println(" check --primary Validate with specific primary locale") io.println(" help Show this help message") io.println("") io.println("Flat JSON usage:") @@ -274,6 +326,294 @@ fn try_load_translations( } } +fn execute_keys_operation(operation: String, arg: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + case operation { + "list" -> { + io.println("🔑 Translation Keys") + io.println("=" |> string.repeat(50)) + io.println("") + + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [#(_locale, translations), ..] -> { + let keys = get_all_translation_keys(translations) + list.each(keys, io.println) + io.println("") + io.println("Total keys: " <> string.inspect(list.length(keys))) + Ok(Nil) + } + } + } + "prefix" -> { + io.println("🔍 Keys with prefix: " <> arg) + io.println("=" |> string.repeat(50)) + io.println("") + + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [#(_locale, translations), ..] -> { + let keys = g18n.get_keys_with_prefix(translations, arg) + list.each(keys, io.println) + io.println("") + io.println("Found " <> string.inspect(list.length(keys)) <> " keys") + Ok(Nil) + } + } + } + "unused" -> { + case simplifile.read(arg) { + Ok(content) -> { + // Simple implementation: split by lines and filter non-empty + let used_keys = + content + |> string.split("\n") + |> list.map(string.trim) + |> list.filter(fn(line) { line != "" }) + + io.println("🚫 Unused Translation Keys") + io.println("=" |> string.repeat(50)) + io.println("") + + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [#(_locale, translations), ..] -> { + let unused_keys = g18n.find_unused_translations(translations, used_keys) + case unused_keys { + [] -> { + io.println("✅ All translation keys are being used!") + Ok(Nil) + } + _ -> { + list.each(unused_keys, io.println) + io.println("") + io.println("Found " <> string.inspect(list.length(unused_keys)) <> " unused keys") + Ok(Nil) + } + } + } + } + } + Error(_) -> snag.error("Could not read usage file: " <> arg) + } + } + _ -> snag.error("Unknown keys operation: " <> operation) + } +} + +fn execute_convert_operation(format: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + io.println("🔄 Converting translations to: " <> format) + io.println("") + + case format { + "flat" | "nested" | "po" -> { + list.each(locale_data, fn(pair) { + let #(locale_code, translations) = pair + let file_extension = case format { + "po" -> ".po" + _ -> ".json" + } + let output_file = locale_code <> "_converted" <> file_extension + + let content = case format { + "flat" -> g18n.translations_to_json(translations) + "nested" -> g18n.translations_to_nested_json(translations) + "po" -> g18n.translations_to_po(translations) + _ -> "" + } + + case simplifile.write(output_file, content) { + Ok(_) -> io.println("✅ " <> locale_code <> " → " <> output_file) + Error(_) -> io.println("❌ Failed to write " <> output_file) + } + }) + Ok(Nil) + } + _ -> snag.error("Unknown format: " <> format <> ". Use: flat, nested, or po") + } +} + +fn execute_params_operation(operation: String, arg: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + case operation { + "check" -> { + io.println("🔧 Parameter Validation") + io.println("=" |> string.repeat(50)) + io.println("") + + // Use first locale as primary for parameter checking + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [primary_pair, ..rest] -> { + let #(_primary_locale, primary_translations) = primary_pair + + list.each(rest, fn(pair) { + let #(target_locale_code, target_translations) = pair + case locale.new(string.replace(target_locale_code, each: "_", with: "-")) { + Ok(target_locale) -> { + let report = g18n.validate_translations(primary_translations, target_translations, target_locale) + let param_errors = list.filter(report.errors, fn(error) { + case error { + g18n.MissingParameter(_, _, _) -> True + g18n.UnusedParameter(_, _, _) -> True + _ -> False + } + }) + + case param_errors { + [] -> io.println("✅ " <> target_locale_code <> ": No parameter issues") + _ -> { + io.println("❌ " <> target_locale_code <> ": " <> string.inspect(list.length(param_errors)) <> " parameter issues") + } + } + } + Error(_) -> io.println("❌ Invalid locale code: " <> target_locale_code) + } + }) + Ok(Nil) + } + } + } + "extract" -> { + io.println("📝 Parameters in key: " <> arg) + io.println("=" |> string.repeat(50)) + io.println("") + + case locale_data { + [] -> { + io.println("No translations found.") + Ok(Nil) + } + [#(_locale, translations), ..] -> { + let key_parts = string.split(arg, ".") + case trie.get(g18n.extract_trie(translations), key_parts) { + Ok(template) -> { + let params = g18n.extract_placeholders(template) + case params { + [] -> io.println("No parameters found in this template") + _ -> { + io.println("Template: " <> template) + io.println("Parameters:") + list.each(params, fn(param) { + io.println(" - {" <> param <> "}") + }) + } + } + Ok(Nil) + } + Error(_) -> { + io.println("Key '" <> arg <> "' not found in translations") + Ok(Nil) + } + } + } + } + } + _ -> snag.error("Unknown params operation: " <> operation) + } +} + +fn execute_check_operation(primary_locale: String) -> SnagResult(Nil) { + use project_name <- result.try(get_project_name()) + use locale_data <- result.try(try_load_translations(project_name)) + + // Determine primary locale + let primary = case primary_locale { + "" -> { + case locale_data { + [first, ..] -> first.0 + [] -> "en" + } + } + locale -> locale + } + + // Find primary locale data + case list.find(locale_data, fn(pair) { pair.0 == primary }) { + Ok(#(_, primary_translations)) -> { + let has_errors = list.fold(locale_data, False, fn(has_error_acc, pair) { + let #(locale_code, translations) = pair + + case locale.new(string.replace(locale_code, each: "_", with: "-")) { + Ok(target_locale) -> { + let report = g18n.validate_translations(primary_translations, translations, target_locale) + case report.errors { + [] -> has_error_acc + errors -> { + io.println("❌ " <> locale_code <> " has " <> string.inspect(list.length(errors)) <> " issues:") + list.each(errors, fn(error) { + case error { + g18n.MissingTranslation(key, _) -> + io.println(" Missing key: " <> key) + g18n.MissingParameter(key, param, _) -> + io.println(" Missing parameter '" <> param <> "' in key: " <> key) + g18n.UnusedParameter(key, param, _) -> + io.println(" Unused parameter '" <> param <> "' in key: " <> key) + g18n.EmptyTranslation(key, _) -> + io.println(" Empty translation for key: " <> key) + g18n.InvalidPluralForm(key, missing_forms, _) -> + io.println(" Invalid plural form for key '" <> key <> "', missing: " <> string.join(missing_forms, ", ")) + } + }) + io.println("") + True + } + } + } + Error(_) -> { + io.println("❌ Invalid locale code: " <> locale_code) + True + } + } + }) + + case has_errors { + True -> snag.error("Translation validation failed - fix the issues above") + False -> { + io.println("✅ All translations are valid!") + Ok(Nil) + } + } + } + Error(_) -> { + io.println("❌ Primary locale '" <> primary <> "' not found!") + io.println("Available locales: " <> string.join(list.map(locale_data, fn(pair) { pair.0 }), ", ")) + snag.error("Primary locale not found") + } + } +} + +fn get_all_translation_keys(translations: g18n.Translations) -> List(String) { + trie.fold( + translations |> g18n.extract_trie, + [], + fn(acc, key_parts, _value) { + let key = string.join(key_parts, ".") + [key, ..acc] + }, + ) + |> list.reverse +} + fn format() { shellout.command("gleam", ["format"], in: find_root("."), opt: []) |> snag.map_error(fn(_) { "Could not format generated file" }) diff --git a/src/g18n/dev/process.gleam b/src/g18n/dev/process.gleam new file mode 100644 index 0000000..61a5989 --- /dev/null +++ b/src/g18n/dev/process.gleam @@ -0,0 +1,11 @@ +/// Process control utilities for handling exits +/// +/// This module provides cross-platform process control functionality +/// using FFI to ensure proper exit codes for CI/CD environments. + +/// Exit the process with the given status code +/// - 0 indicates success +/// - Non-zero indicates failure +@external(erlang, "g18n_dev_ffi", "exit") +@external(javascript, "./process_ffi.mjs", "exit") +pub fn exit(code: Int) -> Nil \ No newline at end of file diff --git a/src/g18n/dev/process_ffi.mjs b/src/g18n/dev/process_ffi.mjs new file mode 100644 index 0000000..6ac6ee2 --- /dev/null +++ b/src/g18n/dev/process_ffi.mjs @@ -0,0 +1,14 @@ +/// JavaScript FFI implementation for process control + +export function exit(code) { + if (typeof process !== 'undefined' && process.exit) { + // Node.js environment + process.exit(code); + } else if (typeof Deno !== 'undefined' && Deno.exit) { + // Deno environment + Deno.exit(code); + } else { + // Browser or other environment - throw error as fallback + throw new Error(`Process exit with code: ${code}`); + } +} \ No newline at end of file diff --git a/src/g18n_dev/translations/en.json b/src/g18n_dev/translations/en.json index 354eabe..3197a96 100644 --- a/src/g18n_dev/translations/en.json +++ b/src/g18n_dev/translations/en.json @@ -8,8 +8,9 @@ "user.logout": "Logout", "user.profile": "Profile", "user.settings": "Settings", - "messages.welcome": "Welcome to our app!", + "messages.welcome": "Welcome to our app, {name}!", "messages.goodbye": "Thanks for using our app!", + "messages.greeting": "Hello {name}, you have {count} new {type}", "errors.not_found": "Page not found", "errors.server_error": "Internal server error", "buttons.save": "Save", diff --git a/src/g18n_dev_ffi.erl b/src/g18n_dev_ffi.erl new file mode 100644 index 0000000..b86174e --- /dev/null +++ b/src/g18n_dev_ffi.erl @@ -0,0 +1,10 @@ +-module(g18n_dev_ffi). +-export([exit/1]). + +%% Erlang FFI implementation for process control +%% +%% Uses halt/1 which properly exits the process with the given status code +%% This ensures exit codes work correctly in CI/CD environments + +exit(Code) -> + halt(Code). \ No newline at end of file