import argv import filepath 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/order import gleam/result import gleam/string import shellout import simplifile import snag.{type Result as SnagResult} import tom /// 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() ["help"] | [] -> help_command() _ -> { io.println_error("Unknown command. Use 'help' for available commands.") help_command() } } } fn generate_command() -> Nil { case generate_translations() { Ok(path) -> io.println("Generated translation module: " <> path) Error(message) -> io.println_error(snag.pretty_print(message)) } } fn help_command() -> Nil { io.println("g18n-dev - Generate Gleam translations from JSON") io.println("") io.println("Commands:") io.println(" generate Generate src//translations.gleam") io.println(" help Show this help") io.println("") 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 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 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 " <> toml_path }), ) use document <- result.try( tom.parse(content) |> snag.map_error(fn(_) { "Could not parse " <> toml_path }), ) 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_path = filepath.join(path, "gleam.toml") case simplifile.is_file(toml_path) { Ok(True) -> path Ok(False) | Error(_) -> find_root(filepath.join(path, "..")) } } 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(TranslationSource)) { let root = find_root(".") let translations_directory = filepath.join(root, "src") |> filepath.join(project_name) |> filepath.join("translations") 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( "Translation filename must produce a valid Gleam function name: " <> file, ) } }) |> 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 language_function_prefix(name: String) -> String { name |> string.lowercase |> string.replace("-", "_") |> string.replace(".", "_") } 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 } } 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 } } } fn is_ascii_lowercase_letter(grapheme: String) -> Bool { string.contains("abcdefghijklmnopqrstuvwxyz", grapheme) } fn compare_translation_sources( first: TranslationSource, second: TranslationSource, ) -> order.Order { string.compare(first.name, second.name) } fn load_translation_sources( sources: List(TranslationSource), ) -> SnagResult(List(LoadedTranslation)) { list.try_map(sources, fn(source) { use content <- result.try( simplifile.read(source.path) |> snag.map_error(fn(_) { "Could not read " <> source.path }), ) use translations <- result.try(decode_translations(content, source.path)) Ok(LoadedTranslation( name: source.name, function_prefix: source.function_prefix, translations: translations, )) }) } 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, ) } } }) } fn write_module( project_name: String, translations: List(LoadedTranslation), ) -> SnagResult(String) { let root = find_root(".") let output_path = filepath.join(root, "src") |> filepath.join(project_name) |> filepath.join("translations.gleam") simplifile.write(output_path, generate_module_content(translations)) |> snag.map_error(fn(_) { "Could not write translations module at: " <> output_path }) |> result.map(fn(_) { output_path }) } 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) "import g18n\n\n" <> language_functions <> "\n\n" <> available_languages <> "\n\n" <> all_translators <> "\n" } fn generate_language_functions(translation: LoadedTranslation) -> String { let translation_pipeline = translation.translations |> dict.to_list |> list.sort(fn(first, second) { string.compare(first.0, second.0) }) |> list.map(fn(entry) { " |> g18n.add_translation(\"" <> escape_string(entry.0) <> "\", \"" <> escape_string(entry.1) <> "\")" }) |> string.join("\n") let translations_function = "pub fn " <> translation.function_prefix <> "_translations() -> g18n.Translations {\n g18n.new_translations()\n" <> translation_pipeline <> "\n}" let translator_function = "pub fn " <> translation.function_prefix <> "_translator() -> g18n.Translator {\n g18n.new_translator(" <> translation.function_prefix <> "_translations())\n}" translations_function <> "\n\n" <> translator_function } fn generate_available_languages( translations: List(LoadedTranslation), ) -> String { let names = translations |> list.map(fn(translation) { "\"" <> escape_string(translation.name) <> "\"" }) |> string.join(", ") "pub fn available_languages() -> List(String) {\n [" <> names <> "]\n}" } 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 escape_string(value: String) -> String { value |> string.replace("\\", "\\\\") |> string.replace("\"", "\\\"") |> string.replace("\n", "\\n") |> string.replace("\r", "\\r") |> string.replace("\t", "\\t") } 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 }) }