diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f95143..e60dcb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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). +## [2.0.0] - 2026-07-29 + +### Changed + +- **BREAKING**: Reduced the CLI to `generate` and `help` for a focused webpage + translation workflow. +- `generate` now accepts flat dotted-key JSON and nested JSON with the same + command. +- Generated translators use the simplified `g18n.new_translator(translations)` + API and no longer generate locale values or language rules. +- Generated modules now expose `available_languages` and `all_translators` for + application-controlled language selection. +- **BREAKING**: Removed PO conversion, locale reports, validation, key + management, parameter analysis, synchronization, statistics, linting, diffs, + and source scanning. +- Removed the runtime `g18n`, Glance, trie, and process-exit FFI dependencies + from the generator itself. + ## [1.3.0] - 2024-09-13 ### Added diff --git a/README.md b/README.md index 9b787fb..ea58ab2 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,93 @@ # g18n-dev +A small development-time generator for g18n webpage translations. + [![Package Version](https://img.shields.io/hexpm/v/g18n_dev)](https://hex.pm/packages/g18n_dev) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/g18n_dev/) -Development tools and CLI for the [g18n](https://github.com/renatillas/g18n) internationalization library for Gleam. - ## Installation +Add the runtime package and the development generator: + ```sh -gleam add g18n_dev@1 +gleam add g18n +gleam add --dev g18n_dev ``` -## Usage +## Translation files -The CLI provides commands to generate Gleam translation modules from various file formats: +Place one JSON file per language in your application's translation directory: -### Generate from Flat JSON Files - -```sh -gleam run -m g18n/dev generate +```text +src//translations/ +├── en.json +├── es.json +└── fr.json ``` -Place flat JSON files in `src//translations/` directory: +The filename identifies the language but has no runtime semantics. g18n does +not apply locale or language rules. + +Each file may use flat dotted keys: ```json { - "ui.button.save": "Save", - "ui.button.cancel": "Cancel", - "user.name": "Name", - "user.email": "Email" + "page.title": "Welcome", + "page.greeting": "Hello {name}!" } ``` -### Generate from Nested JSON Files - -```sh -gleam run -m g18n/dev generate --nested -``` - -Place nested JSON files in `src//translations/` directory: +Or nested objects: ```json { - "ui": { - "button": { - "save": "Save", - "cancel": "Cancel" - } - }, - "user": { - "name": "Name", - "email": "Email" + "page": { + "title": "Welcome", + "greeting": "Hello {name}!" } } ``` -This format is compatible with popular i18n libraries like react-i18next, Vue i18n, and Angular i18n. +Both formats may be used with the same command. -### Generate from PO Files (gettext) +## Generate the module ```sh -gleam run -m g18n/dev generate --po -``` - -Place PO files in `src//translations/` directory: - -```po -msgid "ui.button.save" -msgstr "Save" - -msgid "user.name" -msgstr "Name" -``` - -### Translation Coverage Report - -```sh -gleam run -m g18n/dev report -``` - -Generate a comprehensive translation coverage report that automatically detects your file format (flat JSON, nested JSON, or PO files) and shows: - -- Coverage percentages for each locale -- Missing translation keys -- Validation errors and warnings -- Primary locale comparison - -```sh -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 -gleam run -m g18n/dev help +gleam run -m g18n/dev generate ``` -## File Naming Convention - -Translation files should be named using locale codes: +The generator writes `src//translations.gleam`. For `en.json`, it +creates: -- `en.json` / `en.po` (English) -- `es.json` / `es.po` (Spanish) -- `pt.json` / `pt.po` (Portuguese) -- `en-US.json` / `en-US.po` (English - US) -- `pt-BR.json` / `pt-BR.po` (Portuguese - Brazil) +- `en_translations() -> g18n.Translations` +- `en_translator() -> g18n.Translator` -Locale codes must be 2 or 5 characters long. +It also creates: -## Generated Output +- `available_languages() -> List(String)` +- `all_translators() -> List(#(String, g18n.Translator))` -The tool generates a `translations.gleam` module with functions for each locale: +Use the generated module directly in a webpage: ```gleam -import my_project/translations - -pub fn main() { - let translator = translations.en_translator() - let message = g18n.translate(translator, "ui.button.save") - // Returns: "Save" -} -``` - -## Supported Formats - -- ✅ Flat JSON (g18n optimized) -- ✅ Nested JSON (react-i18next, Vue i18n, Angular i18n compatible) -- ✅ PO files (gettext standard) - -## Automation & CI/CD Integration +import g18n +import my_app/translations -### 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 +let translator = translations.en_translator() +g18n.translate(translator, "page.title") ``` -### Build Scripts - -```bash -# In your build script -echo "Validating translations..." -gleam run -m g18n/dev check --primary en || exit 1 -echo "✅ Translations valid!" -``` +Your application chooses the generated translator. The generator does not +parse locales, negotiate languages, apply plural rules, or add browser runtime +code. -## Development +## Commands -```sh -gleam run -m g18n/dev help # Show help -gleam test # Run the tests -gleam format # Format the code +```text +generate Generate the translation module from every JSON file +help Show command help ``` -## Documentation +## License -Further documentation can be found at . +MIT diff --git a/examples/README.md b/examples/README.md index a7293a9..f39e965 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,24 +1,20 @@ -# test_project +# g18n-dev example -[![Package Version](https://img.shields.io/hexpm/v/test_project)](https://hex.pm/packages/test_project) -[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/test_project/) +This example keeps one JSON file per language in +`src/test_project/translations/`. -```sh -gleam add test_project@1 -``` -```gleam -import test_project +Generate the Gleam module: -pub fn main() -> Nil { - // TODO: An example of the project in use -} +```sh +gleam run -m g18n/dev generate ``` -Further documentation can be found at . +The command accepts both flat dotted-key JSON and nested JSON. It writes +`src/test_project/translations.gleam` with translation and translator functions +for each file. -## Development +Run the example tests: ```sh -gleam run # Run the project -gleam test # Run the tests +gleam test ``` diff --git a/examples/gleam.toml b/examples/gleam.toml index 352fa20..f3df8d0 100644 --- a/examples/gleam.toml +++ b/examples/gleam.toml @@ -15,7 +15,7 @@ version = "1.0.0" [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" g18n_dev = { path = "../" } -g18n = ">= 3.1.0" +g18n = ">= 4.0.0 and < 5.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/examples/src/test_project/translations.gleam b/examples/src/test_project/translations.gleam index 34c10ac..1be61c4 100644 --- a/examples/src/test_project/translations.gleam +++ b/examples/src/test_project/translations.gleam @@ -1,5 +1,4 @@ import g18n -import g18n/locale pub fn en_translations() -> g18n.Translations { g18n.new_translations() @@ -19,13 +18,26 @@ pub fn en_translations() -> g18n.Translations { |> g18n.add_translation("user.profile.name", "Name") } -pub fn en_locale() -> locale.Locale { - let assert Ok(locale) = locale.new("en") - locale +pub fn en_translator() -> g18n.Translator { + g18n.new_translator(en_translations()) } -pub fn en_translator() -> g18n.Translator { - g18n.new_translator(en_locale(), en_translations()) +pub fn es_translations() -> g18n.Translations { + g18n.new_translations() + |> g18n.add_translation("admin.settings.description", "") + |> g18n.add_translation("admin.settings.title", "Configuración") + |> g18n.add_translation("admin.users.count", "Total de Usuarios") + |> g18n.add_translation("messages.welcome", "Bienvenido a nuestra aplicación") + |> g18n.add_translation("ui.button.cancel", "Cancelar") + |> g18n.add_translation("ui.button.delete", "Eliminar") + |> g18n.add_translation("ui.button.save", "Guardar") + |> g18n.add_translation("user.profile.avatar", "") + |> g18n.add_translation("user.profile.email", "Correo electrónico") + |> g18n.add_translation("user.profile.name", "Nombre") +} + +pub fn es_translator() -> g18n.Translator { + g18n.new_translator(es_translations()) } pub fn fr_translations() -> g18n.Translations { @@ -45,38 +57,14 @@ pub fn fr_translations() -> g18n.Translations { |> g18n.add_translation("user.profile.name", "Nom") } -pub fn fr_locale() -> locale.Locale { - let assert Ok(locale) = locale.new("fr") - locale -} - pub fn fr_translator() -> g18n.Translator { - g18n.new_translator(fr_locale(), fr_translations()) -} - -pub fn es_translations() -> g18n.Translations { - g18n.new_translations() - |> g18n.add_translation("admin.settings.description", "") - |> g18n.add_translation("admin.settings.title", "Configuración") - |> g18n.add_translation("admin.users.count", "Total de Usuarios") - |> g18n.add_translation("messages.welcome", "Bienvenido a nuestra aplicación") - |> g18n.add_translation("ui.button.cancel", "Cancelar") - |> g18n.add_translation("ui.button.delete", "Eliminar") - |> g18n.add_translation("ui.button.save", "Guardar") - |> g18n.add_translation("user.profile.avatar", "") - |> g18n.add_translation("user.profile.email", "Correo electrónico") - |> g18n.add_translation("user.profile.name", "Nombre") -} - -pub fn es_locale() -> locale.Locale { - let assert Ok(locale) = locale.new("es") - locale + g18n.new_translator(fr_translations()) } -pub fn es_translator() -> g18n.Translator { - g18n.new_translator(es_locale(), es_translations()) +pub fn available_languages() -> List(String) { + ["en", "es", "fr"] } -pub fn available_locales() -> List(String) { - ["en", "fr", "es"] +pub fn all_translators() -> List(#(String, g18n.Translator)) { + [#("en", en_translator()), #("es", es_translator()), #("fr", fr_translator())] } diff --git a/examples/src/test_project/translations/fr.json b/examples/src/test_project/translations/fr.json index b58f0ad..7dcb3da 100644 --- a/examples/src/test_project/translations/fr.json +++ b/examples/src/test_project/translations/fr.json @@ -1,12 +1,28 @@ { - "ui.button.save": "Sauvegarder", - "ui.button.cancel": "Annuler", - "ui.button.delete": "Supprimer", - "user.profile.name": "Nom", - "user.profile.email": "E-mail", - "user.profile.avatar": "Photo de profil", - "admin.settings.title": "Paramètres", - "admin.users.count": "Nombre total d'utilisateurs", - "messages.welcome": "Bienvenue dans notre application", - "messages.goodbye": "Merci d'utiliser notre service" -} \ No newline at end of file + "ui": { + "button": { + "save": "Sauvegarder", + "cancel": "Annuler", + "delete": "Supprimer" + } + }, + "user": { + "profile": { + "name": "Nom", + "email": "E-mail", + "avatar": "Photo de profil" + } + }, + "admin": { + "settings": { + "title": "Paramètres" + }, + "users": { + "count": "Nombre total d'utilisateurs" + } + }, + "messages": { + "welcome": "Bienvenue dans notre application", + "goodbye": "Merci d'utiliser notre service" + } +} diff --git a/examples/test/test_project_test.gleam b/examples/test/test_project_test.gleam index fba3c88..056b7be 100644 --- a/examples/test/test_project_test.gleam +++ b/examples/test/test_project_test.gleam @@ -1,13 +1,25 @@ +import g18n import gleeunit +import test_project/translations pub fn main() -> Nil { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" +pub fn generated_translator_test() { + assert g18n.translate(translations.es_translator(), "ui.button.save") + == "Guardar" +} + +pub fn generated_translator_with_fallback_test() { + let translator = + translations.es_translator() + |> g18n.with_fallback(translations.en_translations()) + + assert g18n.translate(translator, "messages.goodbye") + == "Thank you for using our service" +} - assert greeting == "Hello, Joe!" +pub fn generated_language_list_test() { + assert translations.available_languages() == ["en", "es", "fr"] } diff --git a/gleam.toml b/gleam.toml index 4f2b874..6a8b33c 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,14 +1,13 @@ name = "g18n_dev" -version = "1.3.0" +version = "2.0.0" -description = "Development tools and CLI for the g18n internationalization library" +description = "JSON translation code generator for g18n webpage projects" licences = ["MIT"] repository = { type = "github", user = "renatillas", repo = "g18n-dev" } links = [{ title = "Website", href = "https://github.com/renatillas/g18n-dev" }] [dependencies] gleam_stdlib = ">= 0.44.0 and < 2.0.0" -g18n = ">= 3.1.0 and < 4.0.0" argv = ">= 1.0.0 and < 2.0.0" filepath = ">= 1.0.0 and < 2.0.0" simplifile = ">= 2.0.0 and < 3.0.0" @@ -16,8 +15,6 @@ tom = ">= 1.0.0 and < 3.0.0" shellout = ">= 1.7.0 and < 2.0.0" snag = ">= 1.1.0 and < 2.0.0" gleam_json = ">= 3.0.0 and < 4.0.0" -trie_again = ">= 1.1.4 and < 2.0.0" -glance = ">= 6.0.0 and < 7.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 018538d..ba6ea6a 100644 --- a/manifest.toml +++ b/manifest.toml @@ -1,32 +1,27 @@ -# This file was generated by Gleam -# You typically do not need to edit this file +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. packages = [ { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, - { name = "g18n", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_community_maths", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "splitter", "trie_again"], otp_app = "g18n", source = "hex", outer_checksum = "B02594CD480BFD6C540252638BBA66B0E07AF165483DC56F1D5FFAED3FECC635" }, - { name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" }, - { name = "gleam_community_maths", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "gleam_community_maths", source = "hex", outer_checksum = "C9EFF6BCF3C9D113EF9837655B0128CA8CAC295131E9F3468F93C1D78EC3E013" }, { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, - { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, { name = "gleam_stdlib", version = "0.63.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "E1D5EC07638F606E48F0EA1556044DD805F2ACE9092A6F6AFBE4A0CC4DA21C2F" }, { name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, - { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, - { name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" }, { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" }, { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, { name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" }, - { name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" }, { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" }, - { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, ] [requirements] argv = { version = ">= 1.0.0 and < 2.0.0" } filepath = { version = ">= 1.0.0 and < 2.0.0" } -g18n = { version = ">= 3.1.0 and < 4.0.0" } -glance = { version = ">= 6.0.0 and < 7.0.0" } gleam_json = { version = ">= 3.0.0 and < 4.0.0" } gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } @@ -34,4 +29,3 @@ shellout = { version = ">= 1.7.0 and < 2.0.0" } simplifile = { version = ">= 2.0.0 and < 3.0.0" } snag = { version = ">= 1.1.0 and < 2.0.0" } tom = { version = ">= 1.0.0 and < 3.0.0" } -trie_again = { version = ">= 1.1.4 and < 2.0.0" } diff --git a/src/g18n/dev.gleam b/src/g18n/dev.gleam index 8f53e6f..376258e 100644 --- a/src/g18n/dev.gleam +++ b/src/g18n/dev.gleam @@ -1,1783 +1,369 @@ import argv import filepath -import g18n -import g18n/locale -import glance -import gleam/bool -import gleam/dict -import gleam/float -import gleam/int +import gleam/dict.{type Dict} +import gleam/dynamic.{type Dynamic} +import gleam/dynamic/decode import gleam/io +import gleam/json import gleam/list -import gleam/option.{None, Some} +import gleam/order import gleam/result import gleam/string import shellout import simplifile import snag.{type Result as SnagResult} import tom -import trie -/// CLI entry point for g18n-dev tools -/// -/// ## Commands -/// ``` -/// gleam run generate # Generate from flat JSON files -/// gleam run generate --nested # Generate from nested JSON files -/// gleam run generate -- po # Generate from PO files -/// gleam run report # Show translation report -/// gleam run report --primary # Set primary locale for comparison -/// gleam run help # Show help -/// gleam run # Show help (default) -/// ``` -pub fn main() { +/// Run the g18n development generator. +/// +/// `generate` reads every JSON file in `src//translations/`. Both flat +/// dotted-key JSON and nested JSON are accepted by the same command. +pub fn main() -> Nil { case argv.load().arguments { ["generate"] -> generate_command() - ["generate", "--nested"] -> generate_nested_command() - ["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) - ["sync", "--from", from_locale, "--to", to_locales] -> - sync_command(from_locale, to_locales) - ["stats"] -> stats_command("") - ["stats", "--locale", locale] -> stats_command(locale) - ["lint"] -> lint_command([]) - ["lint", "--rules", rules] -> lint_command(string.split(rules, ",")) - ["diff", from_locale, to_locale] -> diff_command(from_locale, to_locale) - ["scan"] -> scan_command("") - ["scan", "--dir", directory] -> scan_command(directory) - ["help"] -> help_command() - [] -> help_command() + ["help"] | [] -> help_command() _ -> { - io.println("Unknown command. Use 'help' for available commands.") + io.println_error("Unknown command. Use 'help' for available commands.") + help_command() } } } -fn generate_command() { +fn generate_command() -> Nil { case generate_translations() { - Ok(path) -> { - io.println("🌏Generated translation modules from flat JSON") - io.println(" " <> path) - } - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -fn generate_nested_command() { - case generate_nested_translations() { - Ok(path) -> { - io.println("🌏Generated translation modules from nested JSON") - io.println(" " <> path) - } - Error(msg) -> io.println(snag.pretty_print(msg)) - } -} - -fn generate_po_command() { - case generate_po_translations() { - Ok(path) -> { - io.println("🌏Generated translation modules from PO files") - io.println(" " <> path) - } - Error(msg) -> io.println(snag.pretty_print(msg)) - } -} - -fn report_command(primary_locale: String) { - case generate_translation_report(primary_locale) { - Ok(_) -> Nil - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -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 - exit(0) - } - Error(msg) -> { - io.println_error(snag.pretty_print(msg)) - // Exit with 1 to indicate failure - only for check command - exit(1) - } - } -} - -fn sync_command(from_locale: String, to_locales: String) { - case execute_sync_operation(from_locale, to_locales) { - Ok(_) -> Nil - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -fn stats_command(locale: String) { - case execute_stats_operation(locale) { - Ok(_) -> Nil - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -fn lint_command(rules: List(String)) { - case execute_lint_operation(rules) { - Ok(_) -> Nil - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -fn diff_command(from_locale: String, to_locale: String) { - case execute_diff_operation(from_locale, to_locale) { - Ok(_) -> Nil - Error(msg) -> io.println_error(snag.pretty_print(msg)) - } -} - -fn scan_command(directory: String) { - case execute_scan_operation(directory) { - Ok(_) -> { - // Exit with 0 to indicate success - only for scan command - exit(0) - } - Error(msg) -> { - io.println_error(snag.pretty_print(msg)) - // Exit with 1 to indicate failure - only for scan command - exit(1) - } + Ok(path) -> io.println("Generated translation module: " <> path) + Error(message) -> io.println_error(snag.pretty_print(message)) } } -fn help_command() { - io.println("g18n CLI - Internationalization for Gleam") +fn help_command() -> Nil { + io.println("g18n-dev - Generate Gleam translations from JSON") io.println("") io.println("Commands:") - io.println(" generate Generate Gleam module from flat JSON files") - io.println( - " generate --nested Generate Gleam module from nested JSON files (industry standard)", - ) - io.println( - " generate --po Generate Gleam module from PO files (gettext)", - ) - 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( - " sync --from --to Sync missing keys between locales", - ) - io.println(" stats Show project translation statistics") - io.println(" stats --locale Show statistics for specific locale") - io.println(" lint Check translations for common issues") - io.println( - " lint --rules Use specific lint rules (comma-separated)", - ) - io.println(" diff Compare two locales side by side") - io.println( - " scan Detect untranslated keys used in source code", - ) - io.println( - " scan --dir Scan specific directory for untranslated keys", - ) - io.println( - " (exits with error code 1 if untranslated keys found)", - ) - io.println(" help Show this help message") - io.println("") - io.println("Flat JSON usage:") - io.println(" Place flat JSON files in src//translations/") - io.println( - " Example: {\"ui.button.save\": \"Save\", \"user.name\": \"Name\"}", - ) - io.println(" Run 'gleam run generate' to create the translations module") - io.println("") - io.println("Nested JSON usage:") - io.println(" Place nested JSON files in src//translations/") - io.println( - " Example: {\"ui\": {\"button\": {\"save\": \"Save\"}}, \"user\": {\"name\": \"Name\"}}", - ) - io.println( - " Run 'gleam run generate_nested' to create the translations module", - ) - io.println("") - io.println("PO files usage:") - io.println(" Place PO files in src//translations/") - io.println(" Example: msgid \"ui.button.save\" / msgstr \"Save\"") - io.println(" Run 'gleam run generate_po' to create the translations module") + io.println(" generate Generate src//translations.gleam") + io.println(" help Show this help") io.println("") - io.println("Supported formats:") - io.println(" ✅ Flat JSON (g18n optimized)") - io.println( - " ✅ Nested JSON (react-i18next, Vue i18n, Angular i18n compatible)", - ) - io.println(" ✅ PO files (gettext standard) - msgid/msgstr pairs") + io.println("Place one JSON file per language in:") + io.println(" src//translations/") io.println("") + io.println("Flat dotted keys and nested JSON objects are both accepted.") } fn generate_translations() -> SnagResult(String) { use project_name <- result.try(get_project_name()) - use locale_files <- result.try(find_locale_files(project_name)) - use output_path <- result.try(write_module(project_name, locale_files)) - use _ <- result.try(format()) - Ok(output_path) -} - -fn generate_nested_translations() -> SnagResult(String) { - use project_name <- result.try(get_project_name()) - use locale_files <- result.try(find_locale_files(project_name)) - use output_path <- result.try(write_module_from_nested( - project_name, - locale_files, - )) - use _ <- result.try(format()) + use sources <- result.try(find_translation_sources(project_name)) + use loaded <- result.try(load_translation_sources(sources)) + use output_path <- result.try(write_module(project_name, loaded)) + use _ <- result.try(format_generated_file(output_path)) Ok(output_path) } -fn generate_po_translations() -> SnagResult(String) { - use project_name <- result.try(get_project_name()) - use locale_files <- result.try(find_po_files(project_name)) - use output_path <- result.try(write_module_from_po(project_name, locale_files)) - use _ <- result.try(format()) - Ok(output_path) -} - -fn generate_translation_report(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)) -> { - io.println("📊 Translation Validation Report") - io.println("Primary locale: " <> primary) - io.println("") - - // Generate reports for each locale compared to primary - list.each(locale_data, fn(pair) { - let #(locale_code, translations) = pair - - // Create locale for validation - case locale.new(string.replace(locale_code, each: "_", with: "-")) { - Ok(target_locale) -> { - let report = - g18n.validate_translations( - primary_translations, - translations, - target_locale, - ) - io.println("🌍 " <> locale_code <> ":") - io.println(g18n.export_validation_report(report)) - io.println("") - } - Error(_) -> { - io.println("❌ Invalid locale code: " <> locale_code) - io.println("") - } - } - }) - - 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 try_load_translations( - project_name: String, -) -> SnagResult(List(#(String, g18n.Translations))) { - // Try to find JSON files first - case find_locale_files(project_name) { - Ok(files) -> { - // Try flat JSON first - case load_all_locales(files) { - Ok(data) -> { - io.println("📁 Detected format: Flat JSON") - Ok(data) - } - Error(_flat_error) -> { - // Try nested JSON - let nested_result = load_all_locales_from_nested(files) - case nested_result { - Ok(data) -> { - io.println("📁 Detected format: Nested JSON") - Ok(data) - } - Error(_nested_error) -> { - // Both JSON formats failed, try PO files - case find_po_files(project_name) { - Ok(po_files) -> { - case load_all_locales_from_po(po_files) { - Ok(data) -> { - io.println("📁 Detected format: PO files") - Ok(data) - } - Error(po_error) -> Error(po_error) - } - } - Error(_) -> { - // No PO files found, return the nested JSON error as it's more likely to be helpful - nested_result - } - } - } - } - } - } - } - Error(_) -> { - // No JSON files found, try PO files directly - case find_po_files(project_name) { - Ok(po_files) -> { - case load_all_locales_from_po(po_files) { - Ok(data) -> { - io.println("📁 Detected format: PO files") - Ok(data) - } - Error(e) -> Error(e) - } - } - Error(_) -> - snag.error( - "No translation files found in src/" - <> project_name - <> "/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 = - trie.fold( - translations |> g18n.extract_trie, - [], - fn(acc, key_parts, _value) { - let key = string.join(key_parts, ".") - [key, ..acc] - }, - ) - |> list.reverse - 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 execute_sync_operation( - from_locale: String, - to_locales: String, -) -> SnagResult(Nil) { - use project_name <- result.try(get_project_name()) - use locale_data <- result.try(try_load_translations(project_name)) - - // Parse target locales - let target_locales = string.split(to_locales, ",") |> list.map(string.trim) - - io.println("🔄 Syncing missing keys from " <> from_locale) - io.println("Target locales: " <> string.join(target_locales, ", ")) - io.println("") - - // Check if source locale exists - case list.find(locale_data, fn(pair) { pair.0 == from_locale }) { - Ok(_) -> { - list.each(target_locales, fn(target_locale) { - case list.find(locale_data, fn(pair) { pair.0 == target_locale }) { - Ok(#(_, target_translations)) -> { - let #(_, source_translations) = case - list.find(locale_data, fn(pair) { pair.0 == from_locale }) - { - Ok(found) -> found - Error(_) -> #("", g18n.new_translations()) - } - let missing_keys = - g18n.get_missing_keys(source_translations, target_translations) - - case missing_keys { - [] -> { - io.println("✅ " <> target_locale <> ": No missing keys") - } - _ -> { - io.println( - "📝 " - <> target_locale - <> ": Adding " - <> int.to_string(list.length(missing_keys)) - <> " missing keys", - ) - list.each(missing_keys, fn(key) { io.println(" + " <> key) }) - - // In a real implementation, we would write the updated files here - io.println( - " (Note: Actual file writing not implemented in this demo)", - ) - } - } - } - Error(_) -> { - io.println("❌ Target locale '" <> target_locale <> "' not found") - } - } - }) - - Ok(Nil) - } - Error(_) -> { - snag.error("Source locale '" <> from_locale <> "' not found") - } - } -} - -fn execute_stats_operation(locale: String) -> SnagResult(Nil) { - use project_name <- result.try(get_project_name()) - use locale_data <- result.try(try_load_translations(project_name)) - - case locale { - "" -> { - // Overall project statistics using g18n core function - case locale_data { - [] -> { - io.println("No translations found.") - Ok(Nil) - } - [first, ..] -> { - let #(primary_locale, _) = first - - case g18n.get_translation_stats(locale_data, primary_locale) { - Ok(stats) -> { - io.println("📊 Project Translation Statistics") - io.println("=" |> string.repeat(50)) - io.println("") - io.println( - "🌍 Total locales: " <> int.to_string(stats.total_locales), - ) - io.println("🔑 Total keys: " <> int.to_string(stats.total_keys)) - // Calculate average coverage - let avg_coverage = case list.length(stats.locale_stats) { - 0 -> 0.0 - count -> - list.fold(stats.locale_stats, 0.0, fn(acc, ls) { - acc +. ls.coverage - }) - /. int.to_float(count) - } - io.println( - "📊 Average coverage: " <> float.to_string(avg_coverage) <> "%", - ) - io.println("") - - io.println("Locale Coverage:") - list.each(stats.locale_stats, fn(locale_stat) { - let status_icon = case - locale_stat.coverage >=. 100.0, - locale_stat.coverage >=. 80.0 - { - True, _ -> "✅" - False, True -> "🟡" - False, False -> "❌" - } - io.println( - " " - <> status_icon - <> " " - <> locale_stat.locale - <> ": " - <> int.to_string(locale_stat.translated_keys) - <> "/" - <> int.to_string(stats.total_keys) - <> " (" - <> float.to_string(locale_stat.coverage) - <> "%)", - ) - }) - - Ok(Nil) - } - Error(msg) -> snag.error(msg) - } - } - } - } - specific_locale -> { - // Statistics for specific locale using g18n core function - case list.find(locale_data, fn(pair) { pair.0 == specific_locale }) { - Ok(_) -> { - case g18n.get_translation_stats(locale_data, specific_locale) { - Ok(stats) -> { - io.println("📊 Statistics for locale: " <> specific_locale) - io.println("=" |> string.repeat(50)) - io.println("") - io.println("🔑 Total keys: " <> int.to_string(stats.total_keys)) - - case - list.find(stats.locale_stats, fn(s) { - s.locale == specific_locale - }) - { - Ok(locale_stat) -> { - io.println("📂 Keys by namespace:") - locale_stat.namespace_counts - |> list.sort(fn(a, b) { string.compare(a.0, b.0) }) - |> list.each(fn(pair) { - io.println( - " " <> pair.0 <> ": " <> int.to_string(pair.1) <> " keys", - ) - }) - } - Error(_) -> Nil - } - - Ok(Nil) - } - Error(msg) -> snag.error(msg) - } - } - Error(_) -> { - snag.error("Locale '" <> specific_locale <> "' not found") - } - } - } - } -} - -fn execute_lint_operation(rules: List(String)) -> SnagResult(Nil) { - use project_name <- result.try(get_project_name()) - use locale_data <- result.try(try_load_translations(project_name)) - - let active_rules = case rules { - [] -> ["empty", "long"] - _ -> rules - } - - io.println("🔍 Linting translations") - io.println("Active rules: " <> string.join(active_rules, ", ")) - io.println("=" |> string.repeat(50)) - io.println("") - - let lint_results = g18n.lint_translations(locale_data, active_rules) - - case lint_results.locale_issues { - [] -> { - io.println("✅ No linting issues found!") - Ok(Nil) - } - locale_issues -> { - list.each(locale_issues, fn(locale_issue) { - io.println( - "❌ " - <> locale_issue.locale - <> " (" - <> int.to_string(list.length(locale_issue.issues)) - <> " issues):", - ) - list.each(locale_issue.issues, fn(issue) { - case issue { - g18n.EmptyTranslationLint(key) -> - io.println(" Empty translation: " <> key) - g18n.LongTranslation(key, length) -> - io.println( - " Long translation (" - <> int.to_string(length) - <> " chars): " - <> key, - ) - g18n.DuplicateTranslation(key, duplicate_of) -> - io.println( - " Duplicate translation: " - <> key - <> " (duplicate of " - <> duplicate_of - <> ")", - ) - } - }) - io.println("") - }) - Ok(Nil) - } - } -} - -fn execute_diff_operation( - from_locale: String, - to_locale: String, -) -> SnagResult(Nil) { - use project_name <- result.try(get_project_name()) - use locale_data <- result.try(try_load_translations(project_name)) - - io.println("🔍 Comparing " <> from_locale <> " → " <> to_locale) - io.println("=" |> string.repeat(50)) - io.println("") - - case - list.find(locale_data, fn(pair) { pair.0 == from_locale }), - list.find(locale_data, fn(pair) { pair.0 == to_locale }) - { - Ok(#(_, from_translations)), Ok(#(_, to_translations)) -> { - let diff = - g18n.diff_translations( - from_translations, - to_translations, - from_locale, - to_locale, - ) - - case diff.missing_in_target { - [] -> io.println("✅ No missing keys in " <> to_locale) - missing -> { - io.println( - "❌ Missing in " - <> to_locale - <> " (" - <> int.to_string(list.length(missing)) - <> " keys):", - ) - list.each(missing, fn(key) { io.println(" - " <> key) }) - io.println("") - } - } - - case diff.extra_in_target { - [] -> io.println("✅ No extra keys in " <> to_locale) - extra -> { - io.println( - "ℹ️ Extra in " - <> to_locale - <> " (" - <> int.to_string(list.length(extra)) - <> " keys):", - ) - list.each(extra, fn(key) { io.println(" + " <> key) }) - io.println("") - } - } - - io.println( - "✅ Common keys: " <> int.to_string(list.length(diff.common_keys)), - ) - - Ok(Nil) - } - Error(_), _ -> snag.error("Source locale '" <> from_locale <> "' not found") - _, Error(_) -> snag.error("Target locale '" <> to_locale <> "' not found") - } -} - -fn execute_scan_operation(directory: String) -> SnagResult(Nil) { - use project_name <- result.try(get_project_name()) - use locale_data <- result.try(try_load_translations(project_name)) - - let scan_dir = case directory { - "" -> filepath.join(find_root("."), "src") - _ -> directory - } - - io.println("🔍 Scanning for untranslated keys in source code") - io.println("Directory: " <> scan_dir) - io.println("=" |> string.repeat(50)) - io.println("") - - // Get all existing translation keys from all locales - case locale_data { - [] -> { - io.println("❌ No translation files found.") - Ok(Nil) - } - _ -> { - let all_translation_keys = get_all_translation_keys(locale_data) - io.println( - "📚 Found " - <> int.to_string(list.length(all_translation_keys)) - <> " translation keys", - ) - - // Scan source files for translation key usage - use source_files <- result.try(find_gleam_files(scan_dir)) - use used_keys_with_files <- result.try( - extract_translation_keys_from_files(source_files), - ) - - let unique_keys = - list.map(used_keys_with_files, fn(pair) { pair.0 }) |> list.unique - - io.println( - "📁 Scanned " - <> int.to_string(list.length(source_files)) - <> " source files", - ) - io.println( - "🔑 Found " - <> int.to_string(list.length(unique_keys)) - <> " translation keys in source code", - ) - io.println("") - - // Find keys used in source code that don't exist in translations - let missing_keys_with_files = - list.filter(used_keys_with_files, fn(pair) { - let key = pair.0 - !list.contains(all_translation_keys, key) - }) - - case missing_keys_with_files { - [] -> { - io.println( - "✅ All translation keys found in source code exist in translation files!", - ) - Ok(Nil) - } - _ -> { - io.println( - "❌ Found " - <> int.to_string(list.length(missing_keys_with_files)) - <> " untranslated keys:", - ) - io.println("") - list.each(missing_keys_with_files, fn(pair) { - let #(key, file_path) = pair - // Make file path relative for cleaner output - let clean_path = case string.starts_with(file_path, scan_dir) { - True -> string.drop_start(file_path, string.length(scan_dir)) - False -> file_path - } - let clean_path = case string.starts_with(clean_path, "/") { - True -> string.drop_start(clean_path, 1) - False -> clean_path - } - io.println(" 🚫 " <> key <> " (" <> clean_path <> ")") - }) - io.println("") - io.println( - "These keys are used in source code but missing from translation files.", - ) - snag.error("Untranslated keys found - fix the issues above") - } - } - } - } -} - -fn format() { - shellout.command("gleam", ["format"], in: find_root("."), opt: []) - |> snag.map_error(fn(_) { "Could not format generated file" }) -} - fn get_project_name() -> SnagResult(String) { let root = find_root(".") let toml_path = filepath.join(root, "gleam.toml") use content <- result.try( simplifile.read(toml_path) - |> snag.map_error(fn(_) { "Could not read gleam.toml" }), + |> snag.map_error(fn(_) { "Could not read " <> toml_path }), ) - - use toml <- result.try( + use document <- result.try( tom.parse(content) - |> snag.map_error(fn(_) { "Could not parse gleam.toml" }), - ) - - use name <- result.try( - tom.get_string(toml, ["name"]) - |> snag.map_error(fn(_) { "Could not find project name in gleam.toml" }), + |> snag.map_error(fn(_) { "Could not parse " <> toml_path }), ) - - Ok(name) -} - -fn escape_string(str: String) -> String { - str - |> string.replace("\\", "\\\\") - |> string.replace("\"", "\\\"") - |> string.replace("\n", "\\n") - |> string.replace("\r", "\\r") - |> string.replace("\t", "\\t") + tom.get_string(document, ["name"]) + |> snag.map_error(fn(_) { "Could not find project name in " <> toml_path }) } fn find_root(path: String) -> String { - let toml = filepath.join(path, "gleam.toml") - - case simplifile.is_file(toml) { - Ok(False) | Error(_) -> find_root(filepath.join(path, "..")) + let toml_path = filepath.join(path, "gleam.toml") + case simplifile.is_file(toml_path) { Ok(True) -> path + Ok(False) | Error(_) -> find_root(filepath.join(path, "..")) } } -fn find_locale_files( +type TranslationSource { + TranslationSource(name: String, function_prefix: String, path: String) +} + +type LoadedTranslation { + LoadedTranslation( + name: String, + function_prefix: String, + translations: Dict(String, String), + ) +} + +fn find_translation_sources( project_name: String, -) -> SnagResult(List(#(String, String))) { +) -> SnagResult(List(TranslationSource)) { let root = find_root(".") - let translations_dir = + let translations_directory = filepath.join(root, "src") |> filepath.join(project_name) |> filepath.join("translations") - case simplifile.read_directory(translations_dir) { - Ok(files) -> { - let locale_files = - files - |> list.filter(fn(file) { - string.ends_with(file, ".json") && file != "translations.json" - }) - |> list.try_map(fn(file) { - let locale_code = string.drop_end(file, 5) - let file_path = filepath.join(translations_dir, file) - let locale_code = string.replace(locale_code, each: "-", with: "_") - use <- bool.guard( - string.length(locale_code) != 2 && string.length(locale_code) != 5, - snag.error( - "Locale code must be 2 or 5 characters (e.g., 'en' or 'en-US'): " - <> locale_code, - ), - ) - Ok(#(locale_code, file_path)) - }) - |> snag.context("Error processing locale files") - - case locale_files { - Ok([]) -> + use files <- result.try( + simplifile.read_directory(translations_directory) + |> snag.map_error(fn(_) { + "Could not read translations directory: " <> translations_directory + }), + ) + use sources <- result.try( + files + |> list.filter(fn(file) { string.ends_with(file, ".json") }) + |> list.try_map(fn(file) { + let name = string.drop_end(file, 5) + let function_prefix = language_function_prefix(name) + case is_valid_function_prefix(function_prefix) { + True -> + Ok(TranslationSource( + name: name, + function_prefix: function_prefix, + path: filepath.join(translations_directory, file), + )) + False -> snag.error( - "No locale JSON files found in " - <> translations_dir - <> "\nLooking for files like en.json, es.json, pt.json, etc.", + "Translation filename must produce a valid Gleam function name: " + <> file, ) - Ok(files) -> Ok(files) - Error(msg) -> Error(msg) } - } - Error(_) -> - snag.error("Could not read translations directory: " <> translations_dir) + }) + |> snag.context("Could not process translation files"), + ) + + case sources { + [] -> + snag.error( + "No JSON files found in " + <> translations_directory + <> ". Add files such as en.json or fr.json.", + ) + _ -> Ok(list.sort(sources, compare_translation_sources)) } } -fn find_po_files(project_name: String) -> SnagResult(List(#(String, String))) { - let root = find_root(".") - let translations_dir = - filepath.join(root, "src") - |> filepath.join(project_name) - |> filepath.join("translations") +fn language_function_prefix(name: String) -> String { + name + |> string.lowercase + |> string.replace("-", "_") + |> string.replace(".", "_") +} - case simplifile.read_directory(translations_dir) { - Ok(files) -> { - let locale_files = - files - |> list.filter(fn(file) { string.ends_with(file, ".po") }) - |> list.try_map(fn(file) { - let locale_code = string.drop_end(file, 3) - let file_path = filepath.join(translations_dir, file) - let locale_code = string.replace(locale_code, each: "-", with: "_") - use <- bool.guard( - string.length(locale_code) != 2 && string.length(locale_code) != 5, - snag.error( - "Locale code must be 2 or 5 characters (e.g., 'en' or 'en-US'): " - <> locale_code, - ), - ) - Ok(#(locale_code, file_path)) - }) - |> snag.context("Error processing PO files") +fn is_valid_function_prefix(prefix: String) -> Bool { + case string.to_graphemes(prefix) { + [first, ..remaining] -> + is_ascii_lowercase_letter(first) && is_valid_function_tail(remaining) + [] -> False + } +} - case locale_files { - Ok([]) -> - snag.error( - "No PO files found in " - <> translations_dir - <> "\nLooking for files like en.po, es.po, pt.po, etc.", - ) - Ok(files) -> Ok(files) - Error(msg) -> Error(msg) +fn is_valid_function_tail(graphemes: List(String)) -> Bool { + case graphemes { + [] -> True + [first, ..remaining] -> + case + is_ascii_lowercase_letter(first) + || string.contains("0123456789_", first) + { + True -> is_valid_function_tail(remaining) + False -> False } - } - Error(_) -> - snag.error("Could not read translations directory: " <> translations_dir) } } -fn write_module( - project_name: String, - locale_files: List(#(String, String)), -) -> SnagResult(String) { - use locale_data <- result.try(load_all_locales(locale_files)) - let root = find_root(".") - let output_path = - filepath.join(root, "src") - |> filepath.join(project_name) - |> filepath.join("translations.gleam") - - let module_content = generate_module_content(locale_data) - - simplifile.write(output_path, module_content) - |> snag.map_error(fn(_) { - "Could not write translations module at: " <> output_path - }) - |> result.map(fn(_) { output_path }) +fn is_ascii_lowercase_letter(grapheme: String) -> Bool { + string.contains("abcdefghijklmnopqrstuvwxyz", grapheme) } -fn write_module_from_nested( - project_name: String, - locale_files: List(#(String, String)), -) -> SnagResult(String) { - use locale_data <- result.try(load_all_locales_from_nested(locale_files)) - let root = find_root(".") - let output_path = - filepath.join(root, "src") - |> filepath.join(project_name) - |> filepath.join("translations.gleam") - - let module_content = generate_module_content(locale_data) - - simplifile.write(output_path, module_content) - |> snag.map_error(fn(_) { - "Could not write translations module from nested JSON at: " <> output_path - }) - |> result.map(fn(_) { output_path }) +fn compare_translation_sources( + first: TranslationSource, + second: TranslationSource, +) -> order.Order { + string.compare(first.name, second.name) } -fn load_all_locales( - locale_files: List(#(String, String)), -) -> SnagResult(List(#(String, g18n.Translations))) { - list.try_fold(locale_files, [], fn(acc, locale_file) { - let #(locale_code, file_path) = locale_file +fn load_translation_sources( + sources: List(TranslationSource), +) -> SnagResult(List(LoadedTranslation)) { + list.try_map(sources, fn(source) { use content <- result.try( - simplifile.read(file_path) - |> snag.map_error(fn(_) { "Could not read " <> file_path }), - ) - use translations <- result.try( - g18n.translations_from_json(content) - |> snag.map_error(fn(_) { "Could not parse JSON in " <> file_path }), + simplifile.read(source.path) + |> snag.map_error(fn(_) { "Could not read " <> source.path }), ) - Ok([#(locale_code, translations), ..acc]) + use translations <- result.try(decode_translations(content, source.path)) + Ok(LoadedTranslation( + name: source.name, + function_prefix: source.function_prefix, + translations: translations, + )) }) - |> result.map(list.reverse) } -fn load_all_locales_from_nested( - locale_files: List(#(String, String)), -) -> SnagResult(List(#(String, g18n.Translations))) { - list.try_fold(locale_files, [], fn(acc, locale_file) { - let #(locale_code, file_path) = locale_file - use content <- result.try( - simplifile.read(file_path) - |> snag.map_error(fn(_) { "Could not read " <> file_path }), - ) - use translations <- result.try( - g18n.translations_from_nested_json(content) - |> snag.map_error(fn(e) { - "Could not parse nested JSON in " <> file_path <> ": " <> e - }), - ) - Ok([#(locale_code, translations), ..acc]) - }) - |> result.map(list.reverse) - |> snag.context("Error loading nested JSON locale files") -} - -fn load_all_locales_from_po( - locale_files: List(#(String, String)), -) -> SnagResult(List(#(String, g18n.Translations))) { - list.try_fold(locale_files, [], fn(acc, locale_file) { - let #(locale_code, file_path) = locale_file - use content <- result.try( - simplifile.read(file_path) - |> snag.map_error(fn(_) { "Could not read " <> file_path }), - ) - use translations <- result.try( - g18n.translations_from_po(content) - |> snag.map_error(fn(e) { - "Could not parse PO file in " <> file_path <> ": " <> e - }), - ) - Ok([#(locale_code, translations), ..acc]) +fn decode_translations( + content: String, + path: String, +) -> SnagResult(Dict(String, String)) { + use dynamic <- result.try( + json.parse(content, decode.dynamic) + |> snag.map_error(fn(_) { "Could not parse JSON in " <> path }), + ) + use object <- result.try( + decode.run(dynamic, decode.dict(decode.string, decode.dynamic)) + |> snag.map_error(fn(_) { + "Translation JSON root must be an object: " <> path + }), + ) + flatten_json_object(object, "", path) +} + +fn flatten_json_object( + object: Dict(String, Dynamic), + prefix: String, + path: String, +) -> SnagResult(Dict(String, String)) { + object + |> dict.to_list + |> list.try_fold(dict.new(), fn(translations, entry) { + let #(key, value) = entry + let full_key = case prefix { + "" -> key + _ -> prefix <> "." <> key + } + + case decode.run(value, decode.string) { + Ok(translation) -> Ok(dict.insert(translations, full_key, translation)) + Error(_) -> + case decode.run(value, decode.dict(decode.string, decode.dynamic)) { + Ok(nested) -> + nested + |> flatten_json_object(full_key, path) + |> result.map(fn(flattened) { + dict.fold( + flattened, + translations, + fn(acc, nested_key, translation) { + dict.insert(acc, nested_key, translation) + }, + ) + }) + Error(_) -> + snag.error( + "Translation value for " + <> full_key + <> " must be a string or object in " + <> path, + ) + } + } }) - |> result.map(list.reverse) - |> snag.context("Error loading PO locale files") } -fn write_module_from_po( +fn write_module( project_name: String, - locale_files: List(#(String, String)), + translations: List(LoadedTranslation), ) -> SnagResult(String) { - use locale_data <- result.try(load_all_locales_from_po(locale_files)) + let root = find_root(".") let output_path = - filepath.join("src", project_name) + filepath.join(root, "src") + |> filepath.join(project_name) |> filepath.join("translations.gleam") - let module_content = generate_module_content(locale_data) - - simplifile.write(output_path, module_content) + simplifile.write(output_path, generate_module_content(translations)) |> snag.map_error(fn(_) { - "Could not write translations module from PO files at: " <> output_path + "Could not write translations module at: " <> output_path }) |> result.map(fn(_) { output_path }) } -fn generate_module_content( - locale_data: List(#(String, g18n.Translations)), -) -> String { - let imports = "import g18n\nimport g18n/locale\n\n" - - let locale_functions = - locale_data - |> list.map(fn(locale_pair) { - let #(locale_code, translations) = locale_pair - generate_single_locale_functions(locale_code, translations) - }) +fn generate_module_content(translations: List(LoadedTranslation)) -> String { + let language_functions = + translations + |> list.map(generate_language_functions) |> string.join("\n\n") + let available_languages = generate_available_languages(translations) + let all_translators = generate_all_translators(translations) - let all_locales_function = generate_all_locales_function(locale_data) - - imports <> locale_functions <> "\n\n" <> all_locales_function + "import g18n\n\n" + <> language_functions + <> "\n\n" + <> available_languages + <> "\n\n" + <> all_translators + <> "\n" } -fn generate_single_locale_functions( - locale_code: String, - translations: g18n.Translations, -) -> String { - // Convert trie to dict for generation - let dict_translations = - trie.fold( - translations |> g18n.extract_trie, - dict.new(), - fn(dict_acc, key_parts, value) { - let key = string.join(key_parts, ".") - dict.insert(dict_acc, key, value) - }, - ) - - let translations_list = - dict_translations +fn generate_language_functions(translation: LoadedTranslation) -> String { + let translation_pipeline = + translation.translations |> dict.to_list - |> list.map(fn(pair) { + |> list.sort(fn(first, second) { string.compare(first.0, second.0) }) + |> list.map(fn(entry) { " |> g18n.add_translation(\"" - <> pair.0 + <> escape_string(entry.0) <> "\", \"" - <> escape_string(pair.1) + <> escape_string(entry.1) <> "\")" }) |> string.join("\n") - let translations_func = + let translations_function = "pub fn " - <> string.lowercase(locale_code) + <> translation.function_prefix <> "_translations() -> g18n.Translations {\n g18n.new_translations()\n" - <> translations_list - <> "\n}" - - let locale_func = - "pub fn " - <> string.lowercase(locale_code) - <> "_locale() -> locale.Locale {\n let assert Ok(locale) = locale.new(\"" - <> string.replace(locale_code, each: "_", with: "-") - <> "\")" - <> "\nlocale" + <> translation_pipeline <> "\n}" - - let translator_func = + let translator_function = "pub fn " - <> string.lowercase(locale_code) - <> "_translator() -> g18n.Translator {\n " - <> "g18n.new_translator(" - <> string.lowercase(locale_code) - <> "_locale(), " - <> string.lowercase(locale_code) - <> "_translations())\n\n}" + <> translation.function_prefix + <> "_translator() -> g18n.Translator {\n g18n.new_translator(" + <> translation.function_prefix + <> "_translations())\n}" - translations_func <> "\n\n" <> locale_func <> "\n\n" <> translator_func + translations_function <> "\n\n" <> translator_function } -fn generate_all_locales_function( - locale_data: List(#(String, g18n.Translations)), +fn generate_available_languages( + translations: List(LoadedTranslation), ) -> String { - let locale_list = - locale_data - |> list.map(fn(pair) { "\"" <> pair.0 <> "\"" }) + let names = + translations + |> list.map(fn(translation) { + "\"" <> escape_string(translation.name) <> "\"" + }) |> string.join(", ") - "pub fn available_locales() -> List(String) {\n [" <> locale_list <> "]\n}" -} - -// Helper functions for scanning source code - -fn get_all_translation_keys( - locale_data: List(#(String, g18n.Translations)), -) -> List(String) { - // Collect all unique keys from all locales - list.fold(locale_data, [], fn(acc, locale_pair) { - let #(_, translations) = locale_pair - let keys_from_this_locale = - trie.fold( - translations |> g18n.extract_trie, - [], - fn(key_acc, key_parts, _value) { - let key = string.join(key_parts, ".") - [key, ..key_acc] - }, - ) - list.append(acc, keys_from_this_locale) - }) - |> list.unique -} - -fn find_gleam_files(directory: String) -> SnagResult(List(String)) { - use files <- result.try(collect_gleam_files_recursive(directory, [])) - Ok(files) -} - -fn collect_gleam_files_recursive( - directory: String, - acc: List(String), -) -> SnagResult(List(String)) { - case simplifile.read_directory(directory) { - Ok(entries) -> { - list.try_fold(entries, acc, fn(file_acc, entry) { - let full_path = filepath.join(directory, entry) - case simplifile.is_directory(full_path) { - Ok(True) -> { - // Skip build and other generated directories - case entry { - "build" | "target" | ".git" | "node_modules" -> Ok(file_acc) - _ -> collect_gleam_files_recursive(full_path, file_acc) - } - } - Ok(False) -> { - case string.ends_with(entry, ".gleam") { - True -> Ok([full_path, ..file_acc]) - False -> Ok(file_acc) - } - } - Error(_) -> Ok(file_acc) - } - }) - } - Error(_) -> snag.error("Could not read directory: " <> directory) - } -} - -fn extract_translation_keys_from_files( - files: List(String), -) -> SnagResult(List(#(String, String))) { - list.try_fold(files, [], fn(acc, file_path) { - use keys <- result.try(extract_translation_keys_from_file(file_path)) - let keys_with_file = list.map(keys, fn(key) { #(key, file_path) }) - Ok(list.append(acc, keys_with_file)) - }) -} - -fn extract_translation_keys_from_file( - file_path: String, -) -> SnagResult(List(String)) { - use content <- result.try( - simplifile.read(file_path) - |> snag.map_error(fn(_) { "Could not read file: " <> file_path }), - ) - - use keys <- result.try(extract_keys_from_gleam_content(content)) - Ok(keys) + "pub fn available_languages() -> List(String) {\n [" <> names <> "]\n}" } -fn extract_keys_from_gleam_content(content: String) -> SnagResult(List(String)) { - case glance.module(content) { - Ok(module) -> { - let keys = extract_keys_from_functions(module.functions) - Ok(keys) - } - Error(_) -> { - // If we can't parse the module, fall back to an empty list - // This might happen with malformed or incomplete Gleam files - Ok([]) - } - } -} - -fn extract_keys_from_functions( - functions: List(glance.Definition(glance.Function)), -) -> List(String) { - list.fold(functions, [], fn(acc, definition) { - case definition { - glance.Definition(_, glance.Function(_, _, _, _, _, body)) -> { - list.append(acc, extract_keys_from_statements(body)) - } - } - }) -} - -fn extract_keys_from_statements( - statements: List(glance.Statement), -) -> List(String) { - list.fold(statements, [], fn(acc, statement) { - case statement { - glance.Use(_, _, function) -> { - list.append(acc, extract_keys_from_expression(function)) - } - glance.Assignment(_, _, _, _, value) -> { - list.append(acc, extract_keys_from_expression(value)) - } - glance.Assert(_, expression, message) -> { - let keys_from_expr = extract_keys_from_expression(expression) - let keys_from_message = case message { - Some(msg) -> extract_keys_from_expression(msg) - None -> [] - } - list.append(acc, list.append(keys_from_expr, keys_from_message)) - } - glance.Expression(expression) -> { - list.append(acc, extract_keys_from_expression(expression)) - } - } - }) -} - -fn extract_keys_from_expression(expression: glance.Expression) -> List(String) { - case expression { - // Function calls - look for g18n.translate* functions - glance.Call(_, function, arguments) -> { - let keys_from_call = extract_keys_from_function_call(function, arguments) - let keys_from_args = - list.fold(arguments, [], fn(acc, arg) { - case arg { - glance.LabelledField(_, _, expr) -> - list.append(acc, extract_keys_from_expression(expr)) - glance.UnlabelledField(expr) -> - list.append(acc, extract_keys_from_expression(expr)) - glance.ShorthandField(_, _) -> acc - } - }) - list.append(keys_from_call, keys_from_args) - } - - // Block expressions - glance.Block(_, statements) -> { - extract_keys_from_statements(statements) - } - - // Case expressions - glance.Case(_, subjects, clauses) -> { - let keys_from_subjects = - list.fold(subjects, [], fn(acc, subject) { - list.append(acc, extract_keys_from_expression(subject)) - }) - let keys_from_clauses = - list.fold(clauses, [], fn(acc, clause) { - let keys_from_guard = case clause.guard { - Some(guard) -> extract_keys_from_expression(guard) - None -> [] - } - list.append( - acc, - list.append( - keys_from_guard, - extract_keys_from_expression(clause.body), - ), - ) - }) - list.append(keys_from_subjects, keys_from_clauses) - } - - // List expressions - glance.List(_, elements, rest) -> { - let keys_from_elements = - list.fold(elements, [], fn(acc, element) { - list.append(acc, extract_keys_from_expression(element)) - }) - let keys_from_rest = case rest { - Some(rest_expr) -> extract_keys_from_expression(rest_expr) - None -> [] - } - list.append(keys_from_elements, keys_from_rest) - } - - // Tuple expressions - glance.Tuple(_, elements) -> { - list.fold(elements, [], fn(acc, element) { - list.append(acc, extract_keys_from_expression(element)) - }) - } - - // Binary operator expressions - glance.BinaryOperator(_, _, left, right) -> { - let keys_from_left = extract_keys_from_expression(left) - let keys_from_right = extract_keys_from_expression(right) - list.append(keys_from_left, keys_from_right) - } - - // Negation expressions - glance.NegateInt(_, operand) -> extract_keys_from_expression(operand) - glance.NegateBool(_, operand) -> extract_keys_from_expression(operand) - - // Field access - glance.FieldAccess(_, container, _) -> { - extract_keys_from_expression(container) - } - - // Tuple access - glance.TupleIndex(_, tuple, _) -> { - extract_keys_from_expression(tuple) - } - - // Function definitions - glance.Fn(_, _, _, body) -> { - extract_keys_from_statements(body) - } - - // Record update - glance.RecordUpdate(_, _, _, record, fields) -> { - let keys_from_record = extract_keys_from_expression(record) - let keys_from_fields = - list.fold(fields, [], fn(acc, field) { - case field.item { - Some(expr) -> list.append(acc, extract_keys_from_expression(expr)) - None -> acc - } - }) - list.append(keys_from_record, keys_from_fields) - } - - // Literals and variables - no keys to extract - glance.String(_, _) - | glance.Int(_, _) - | glance.Float(_, _) - | glance.Variable(_, _) - | glance.Todo(_, _) - | glance.Panic(_, _) - | glance.FnCapture(_, _, _, _, _) - | glance.BitString(_, _) - | glance.Echo(_, _, _) -> [] - } -} - -fn extract_keys_from_function_call( - function: glance.Expression, - arguments: List(glance.Field(glance.Expression)), -) -> List(String) { - case function { - // Direct function calls like translate(...) - glance.Variable(_, name) -> { - case is_translation_function(name) { - True -> extract_key_from_arguments(arguments) - False -> [] - } - } - - // Module function calls like g18n.translate(...) - glance.FieldAccess(_, glance.Variable(_, module_name), function_name) -> { - case module_name == "g18n" && is_translation_function(function_name) { - True -> extract_key_from_arguments(arguments) - False -> [] - } - } +fn generate_all_translators(translations: List(LoadedTranslation)) -> String { + let translators = + translations + |> list.map(fn(translation) { + "#(\"" + <> escape_string(translation.name) + <> "\", " + <> translation.function_prefix + <> "_translator())" + }) + |> string.join(", ") - _ -> [] - } + "pub fn all_translators() -> List(#(String, g18n.Translator)) {\n [" + <> translators + <> "]\n}" } -fn is_translation_function(name: String) -> Bool { - case name { - "translate" - | "translate_with_params" - | "translate_with_context" - | "translate_with_context_and_params" - | "translate_plural" - | "translate_plural_with_params" - | "translate_cardinal" - | "translate_ordinal" - | "translate_range" - | "translate_ordinal_with_params" - | "translate_range_with_params" -> True - _ -> False - } +fn escape_string(value: String) -> String { + value + |> string.replace("\\", "\\\\") + |> string.replace("\"", "\\\"") + |> string.replace("\n", "\\n") + |> string.replace("\r", "\\r") + |> string.replace("\t", "\\t") } -fn extract_key_from_arguments( - arguments: List(glance.Field(glance.Expression)), -) -> List(String) { - // Look for the translation key in the arguments - // The key is typically the second argument (after translator) - case arguments { - [_, glance.LabelledField(_, _, glance.String(_, key)), ..] -> [key] - [_, glance.UnlabelledField(glance.String(_, key)), ..] -> [key] - _ -> [] - } +fn format_generated_file(path: String) -> SnagResult(Nil) { + shellout.command("gleam", ["format", path], in: find_root("."), opt: []) + |> snag.map_error(fn(_) { "Could not format generated file: " <> path }) + |> result.map(fn(_) { Nil }) } - -@external(erlang, "g18n_dev_ffi", "exit") -@external(javascript, "g18n_dev_ffi.mjs", "exit") -fn exit(code: Int) -> Nil diff --git a/src/g18n_def_ffi.mjs b/src/g18n_def_ffi.mjs deleted file mode 100644 index 6ac6ee2..0000000 --- a/src/g18n_def_ffi.mjs +++ /dev/null @@ -1,14 +0,0 @@ -/// 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 deleted file mode 100644 index 4fe5da0..0000000 --- a/src/g18n_dev/translations/en.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "app.title": "My Application", - "nav.home": "Home", - "nav.about": "About", - "user.login": "Login", - "messages.welcome": "Welcome to our app!", - "buttons.save": "Save" -} \ No newline at end of file diff --git a/src/g18n_dev/translations/es.json b/src/g18n_dev/translations/es.json deleted file mode 100644 index d18f01d..0000000 --- a/src/g18n_dev/translations/es.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "app.title": "Mi Aplicación", - "nav.home": "Inicio", - "user.login": "Iniciar sesión", - "buttons.save": "Guardar" -} \ No newline at end of file diff --git a/src/g18n_dev_ffi.erl b/src/g18n_dev_ffi.erl deleted file mode 100644 index b86174e..0000000 --- a/src/g18n_dev_ffi.erl +++ /dev/null @@ -1,10 +0,0 @@ --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 diff --git a/test/g18n_dev_test.gleam b/test/g18n_dev_test.gleam index fba3c88..902c4da 100644 --- a/test/g18n_dev_test.gleam +++ b/test/g18n_dev_test.gleam @@ -3,11 +3,3 @@ import gleeunit pub fn main() -> Nil { gleeunit.main() } - -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - let name = "Joe" - let greeting = "Hello, " <> name <> "!" - - assert greeting == "Hello, Joe!" -}