From 024e8335f1ac1fcf7c06996bd7c7a1cc0ce87d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20da=20Silva?= Date: Sat, 1 Jun 2024 22:27:39 +0200 Subject: [PATCH] Add first working version of the library :) --- .github/workflows/test.yml | 23 +++++++++ .gitignore | 4 ++ README.md | 73 ++++++++++++++++++++++++++++ gleam.toml | 14 ++++++ manifest.toml | 17 +++++++ src/error.gleam | 11 +++++ src/header.gleam | 43 +++++++++++++++++ src/json.gleam | 30 ++++++++++++ src/translator.gleam | 96 +++++++++++++++++++++++++++++++++++++ test/header_test.gleam | 47 ++++++++++++++++++ test/locale/de-CH/lang.json | 14 ++++++ test/locale/en-US/lang.json | 14 ++++++ test/translator_test.gleam | 63 ++++++++++++++++++++++++ 13 files changed, 449 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gleam.toml create mode 100644 manifest.toml create mode 100644 src/error.gleam create mode 100644 src/header.gleam create mode 100644 src/json.gleam create mode 100644 src/translator.gleam create mode 100644 test/header_test.gleam create mode 100644 test/locale/de-CH/lang.json create mode 100644 test/locale/en-US/lang.json create mode 100644 test/translator_test.gleam diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ac2397 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: "26.0.2" + gleam-version: "1.2.1" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bc53b6 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Translator for gleam + +[![Package Version](https://img.shields.io/hexpm/v/translator)](https://hex.pm/packages/translator) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/translator/) +[![Tests](https://github.com/andre-dasilva/translator/actions/workflows/test.yml/badge.svg)](https://github.com/andre-dasilva/translator/actions/workflows/test.yml) + + +Further documentation can be found at . + +## Getting Started + +### Installation + +```sh +gleam add translator +``` + +### Usage + +Create a file for each locale in `src/` + +```md +src/ +└── locale/ + └── de-CH/ + └── lang.json + └── en-US/ + └── lang.json +``` + +de-CH/lang.json + +```json +{ + "hello": { + "value": "Willkommen" + } +} +``` + +en-US/lang.json + +```json +{ + "hello": { + "value": "Welcome" + } +} +``` + +And use the translator like this + +```gleam +import translator/translator + +let assert Ok(translator) = + translator.new_translator("de-CH") + |> translator.with_directory("src/locale") + |> translator.from_json() + +translator.get_key("hello") +// Willkommen +``` + +## Development + +Fork the project and clone it + +You can run the tests with: + +```sh +gleam test +``` diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..014bddf --- /dev/null +++ b/gleam.toml @@ -0,0 +1,14 @@ +name = "translator" +version = "0.0.1" + +description = "Translate gleam applications with locale files" +licences = ["MIT"] +repository = { type = "github", user = "andre-dasilva", repo = "translator" } + +[dependencies] +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +simplifile = ">= 2.0.0 and < 3.0.0" +gleam_json = ">= 1.0.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..cd27445 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,17 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, + { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, + { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, + { name = "simplifile", version = "2.0.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "95219227A43FCFE62C6E494F413A1D56FF953B68FE420698612E3D89A1EFE029" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, +] + +[requirements] +gleam_json = { version = ">= 1.0.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +simplifile = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/src/error.gleam b/src/error.gleam new file mode 100644 index 0000000..aa5f7d6 --- /dev/null +++ b/src/error.gleam @@ -0,0 +1,11 @@ +import gleam/json +import simplifile + +pub type TranslatorError { + DirectoryNotSet + DirectoryNotFound + KeyNotFound + LanguageFileNotFound + JsonError(json.DecodeError) + FileError(simplifile.FileError) +} diff --git a/src/header.gleam b/src/header.gleam new file mode 100644 index 0000000..f97d1bf --- /dev/null +++ b/src/header.gleam @@ -0,0 +1,43 @@ +import gleam/float +import gleam/list +import gleam/option.{type Option} +import gleam/result +import gleam/string + +/// Parses the accept langauge header according to: +/// https://www.rfc-editor.org/rfc/rfc9110#field.accept-language +/// +/// e.g. fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5 +pub fn parse_accept_language_header( + raw_header: String, +) -> List(#(String, Float)) { + let quality_factor = ";q=" + + raw_header + |> string.replace(" ", "") + |> string.split(",") + |> list.filter(fn(lang) { !string.is_empty(lang) }) + |> list.filter_map(fn(lang) { + case string.contains(lang, quality_factor) { + True -> string.split_once(lang, quality_factor) + False -> Ok(#(lang, "1.0")) + } + }) + |> list.filter_map(fn(lang_with_priority) { + use priority <- result.try(float.parse(lang_with_priority.1)) + Ok(#(lang_with_priority.0, priority)) + }) + |> list.sort(fn(a, b) { float.compare(a.1, b.1) }) + |> list.reverse() +} + +pub fn get_first_lang_from_accept_language_header( + raw_header: String, +) -> Option(String) { + let languages_with_priority = parse_accept_language_header(raw_header) + + languages_with_priority + |> list.first() + |> option.from_result() + |> option.map(fn(lang_with_priority) { lang_with_priority.0 }) +} diff --git a/src/json.gleam b/src/json.gleam new file mode 100644 index 0000000..938f9ee --- /dev/null +++ b/src/json.gleam @@ -0,0 +1,30 @@ +import error +import gleam/dict.{type Dict} +import gleam/dynamic +import gleam/json +import gleam/result +import gleam/string +import simplifile + +const language_file_name = "lang.json" + +pub fn read_file( + directory: String, +) -> Result(Dict(String, Dict(String, String)), error.TranslatorError) { + let file = + directory |> string.append("/") |> string.append(language_file_name) + + use json_content <- result.try( + simplifile.read(file) |> result.map_error(error.FileError), + ) + + let decoder = + dynamic.dict(dynamic.string, dynamic.dict(dynamic.string, dynamic.string)) + + use translations <- result.try(result.map_error( + json.decode(from: json_content, using: decoder), + error.JsonError, + )) + + Ok(translations) +} diff --git a/src/translator.gleam b/src/translator.gleam new file mode 100644 index 0000000..4c88803 --- /dev/null +++ b/src/translator.gleam @@ -0,0 +1,96 @@ +import error +import gleam/dict.{type Dict} +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import json +import simplifile + +pub type Translator { + Translator( + language: String, + directory: Option(String), + translations: Option(Dict(String, Dict(String, String))), + ) +} + +const default_translations_directory = "src/translations" + +pub fn new_translator(language: String) -> Translator { + Translator( + language: language, + directory: Some(default_translations_directory), + translations: None, + ) +} + +pub fn with_directory(translator: Translator, directory: String) -> Translator { + Translator(..translator, directory: Some(directory)) +} + +pub fn from_json( + translator: Translator, +) -> Result(Translator, error.TranslatorError) { + use directory <- result.try(option.to_result( + translator.directory, + error.DirectoryNotSet, + )) + + let directory_with_language = + directory |> string.append("/") |> string.append(translator.language) + + use is_directory <- result.try( + simplifile.is_directory(directory_with_language) + |> result.map_error(error.FileError), + ) + + use translations <- result.try(case is_directory { + True -> Ok(json.read_file(directory_with_language)) + False -> Error(error.DirectoryNotFound) + }) + + use translations <- result.try(translations) + + Ok(Translator(..translator, translations: Some(translations))) +} + +pub fn get_key( + translator: Translator, + key: String, +) -> Result(String, error.TranslatorError) { + let translations = translator.translations |> option.unwrap(dict.new()) + + use translation <- result.try( + dict.get(translations, key) |> result.replace_error(error.KeyNotFound), + ) + + dict.get(translation, "value") |> result.replace_error(error.KeyNotFound) +} + +fn rec_replace_args(value: String, args: List(#(String, String))) -> String { + case args { + [head, ..tail] -> { + let #(param, param_value) = head + + let updated_translation = + string.replace( + value, + each: "{" <> string.uppercase(param) <> "}", + with: param_value, + ) + + rec_replace_args(updated_translation, tail) + } + _ -> value + } +} + +pub fn get_key_with_args( + translator: Translator, + key: String, + args: List(#(String, String)), +) -> Result(String, error.TranslatorError) { + use translation <- result.try(get_key(translator, key)) + + Ok(rec_replace_args(translation, args)) +} diff --git a/test/header_test.gleam b/test/header_test.gleam new file mode 100644 index 0000000..7cf7a00 --- /dev/null +++ b/test/header_test.gleam @@ -0,0 +1,47 @@ +import gleam/list +import gleam/option.{None, Some} +import gleeunit/should +import header + +pub fn parse_accept_language_header_test() { + let tests = [ + #("fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", [ + #("fr-CH", 1.0), + #("fr", 0.9), + #("en", 0.8), + #("de", 0.7), + #("*", 0.5), + ]), + #("fr-CH, de-CH;q=0.2, en;q=0.5, it;q=0.3", [ + #("fr-CH", 1.0), + #("en", 0.5), + #("it", 0.3), + #("de-CH", 0.2), + ]), + #("fr-CH, de-CH;q=A, en;q=0.2", [#("fr-CH", 1.0), #("en", 0.2)]), + #("", []), + ] + + tests + |> list.map(fn(t) { + let #(input, expected) = t + + header.parse_accept_language_header(input) + |> should.equal(expected) + }) +} + +pub fn get_first_lang_from_accept_language_header_test() { + let tests = [ + #("fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", Some("fr-CH")), + #("", None), + ] + + tests + |> list.map(fn(t) { + let #(input, expected) = t + + header.get_first_lang_from_accept_language_header(input) + |> should.equal(expected) + }) +} diff --git a/test/locale/de-CH/lang.json b/test/locale/de-CH/lang.json new file mode 100644 index 0000000..70dfa78 --- /dev/null +++ b/test/locale/de-CH/lang.json @@ -0,0 +1,14 @@ +{ + "hello": { + "value": "Hallo" + }, + "welcome": { + "value": "Willkommen" + }, + "this_is_great": { + "value": "Das ist fantastisch" + }, + "amount_planets": { + "value": "Es gibt {AMOUNT} Planeten. Aber es waren mal {AMOUNT_BEFORE}." + } +} diff --git a/test/locale/en-US/lang.json b/test/locale/en-US/lang.json new file mode 100644 index 0000000..2919964 --- /dev/null +++ b/test/locale/en-US/lang.json @@ -0,0 +1,14 @@ +{ + "hello": { + "value": "Hello" + }, + "welcome": { + "value": "Welcome" + }, + "this_is_great": { + "value": "This is great" + }, + "amount_planets": { + "value": "There are {AMOUNT} planets" + } +} diff --git a/test/translator_test.gleam b/test/translator_test.gleam new file mode 100644 index 0000000..3c17399 --- /dev/null +++ b/test/translator_test.gleam @@ -0,0 +1,63 @@ +import gleam/list +import gleeunit +import gleeunit/should +import translator + +pub fn main() { + gleeunit.main() +} + +fn translator(language: String) -> translator.Translator { + let assert Ok(translator) = + translator.new_translator(language) + |> translator.with_directory("test/locale") + |> translator.from_json() + + translator +} + +pub fn read_translation_de_ch_test() { + let translator = translator("de-CH") + + let tests = [ + #("hello", [], "Hallo"), + #("welcome", [], "Willkommen"), + #("this_is_great", [], "Das ist fantastisch"), + #( + "amount_planets", + [#("AMOUNT", "7"), #("AMOUNT_BEFORE", "8")], + "Es gibt 7 Planeten. Aber es waren mal 8.", + ), + ] + + tests + |> list.map(fn(t) { + let #(input, args, expected) = t + + let assert Ok(key) = translator |> translator.get_key_with_args(input, args) + + key + |> should.equal(expected) + }) +} + +pub fn read_translation_en_us_test() { + let translator = translator("en-US") + + let tests = [ + #("hello", [], "Hello"), + #("welcome", [], "Welcome"), + #("this_is_great", [], "This is great"), + #("amount_planets", [#("AMOUNT", "7")], "There are 7 planets"), + ] + + tests + |> list.map(fn(t) { + let #(input, args, expected) = t + + let assert Ok(key) = translator |> translator.get_key_with_args(input, args) + + key + |> should.equal(expected) + }) +}