├── .eslintrc.yml ├── .github ├── auto-comment.yml └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── example ├── Makefile ├── README.md ├── main.c └── translations │ ├── en-GB.yml │ └── ru-RU.yml ├── lib ├── app_error.js ├── cli.js ├── cmd_compile.js ├── cmd_extract.js ├── cmd_rename.js ├── compiler_template.js ├── parser.js ├── plurals.js ├── source_keys.js └── translation_keys.js ├── lv_i18n.js ├── package-lock.json ├── package.json ├── src ├── lv_i18n.template.c └── lv_i18n.template.h ├── support ├── template_data.yml └── template_update.js └── test ├── c ├── .gitignore ├── Makefile └── test.c └── js ├── .eslintrc.yml ├── fixtures ├── broken.yml ├── c_escapes.yml ├── cli_compile │ └── no_base.yml ├── cli_extract │ ├── empty_en-GB.yml │ ├── empty_ru-RU.yml │ ├── mixed_en-GB.yml │ ├── orphaned_en-GB.yml │ ├── orphaned_ru-RU.yml │ ├── partial_en-GB.yml │ ├── src_1.c │ └── src_2.c ├── cli_rename │ ├── en-GB.yml │ ├── en-US.yml │ └── ru-RU.yml ├── empty_src.c ├── file_load │ ├── file_load.c │ ├── file_load.h │ └── file_load.yml └── newlines │ ├── partial_ru-RU.yml │ ├── ru-RU.yml │ └── src.c ├── plurajs.js ├── test_cli.js ├── test_cli_compile.js ├── test_cli_extract.js ├── test_cli_rename.js ├── test_newlines.js ├── test_parser.js ├── test_source_keys.js └── test_translation_keys.js /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | es6: true 4 | 5 | parserOptions: 6 | ecmaVersion: '2020' 7 | 8 | rules: 9 | accessor-pairs: 2 10 | array-bracket-spacing: [ 2, "always", { "singleValue": true, "objectsInArrays": true, "arraysInArrays": true } ] 11 | block-scoped-var: 2 12 | block-spacing: 2 13 | brace-style: [ 2, '1tbs', { "allowSingleLine": true } ] 14 | # Postponed 15 | #callback-return: 2 16 | comma-dangle: 2 17 | comma-spacing: 2 18 | comma-style: 2 19 | computed-property-spacing: [ 2, never ] 20 | # Postponed 21 | #consistent-return: 2 22 | consistent-this: [ 2, self ] 23 | # ? change to multi 24 | curly: [ 2, 'multi-line' ] 25 | # Postponed 26 | # dot-notation: [ 2, { allowKeywords: true } ] 27 | dot-location: [ 2, 'property' ] 28 | eol-last: 2 29 | eqeqeq: 2 30 | func-style: [ 2, declaration ] 31 | # Postponed 32 | #global-require: 2 33 | guard-for-in: 2 34 | handle-callback-err: 2 35 | 36 | # Postponed 37 | indent: [ 2, 2, { VariableDeclarator: { var: 2, let: 2, const: 3 }, SwitchCase: 1 } ] 38 | 39 | # key-spacing: [ 2, { "align": "value" } ] 40 | keyword-spacing: 2 41 | linebreak-style: 2 42 | max-depth: [ 1, 3 ] 43 | max-nested-callbacks: [ 1, 5 ] 44 | # string can exceed 80 chars, but should not overflow github website :) 45 | max-len: [ 2, 120, 1000 ] 46 | new-cap: 2 47 | new-parens: 2 48 | # Postponed 49 | #newline-after-var: 2 50 | no-alert: 2 51 | no-array-constructor: 2 52 | no-bitwise: 2 53 | no-caller: 2 54 | #no-case-declarations: 2 55 | no-catch-shadow: 2 56 | no-cond-assign: 2 57 | no-console: 1 58 | no-constant-condition: 2 59 | no-control-regex: 2 60 | no-debugger: 1 61 | no-delete-var: 2 62 | no-div-regex: 2 63 | no-dupe-args: 2 64 | no-dupe-keys: 2 65 | no-duplicate-case: 2 66 | no-else-return: 2 67 | # Tend to drop 68 | # no-empty: 1 69 | no-empty-character-class: 2 70 | no-empty-pattern: 2 71 | no-eq-null: 2 72 | no-eval: 2 73 | no-ex-assign: 2 74 | no-extend-native: 2 75 | no-extra-bind: 2 76 | no-extra-boolean-cast: 2 77 | no-extra-semi: 2 78 | no-fallthrough: 2 79 | no-floating-decimal: 2 80 | no-func-assign: 2 81 | # Postponed 82 | #no-implicit-coercion: [2, { "boolean": true, "number": true, "string": true } ] 83 | no-implied-eval: 2 84 | no-inner-declarations: 2 85 | no-invalid-regexp: 2 86 | no-irregular-whitespace: 2 87 | no-iterator: 2 88 | no-label-var: 2 89 | no-labels: 2 90 | no-lone-blocks: 1 91 | no-lonely-if: 2 92 | no-loop-func: 2 93 | no-mixed-requires: [ 1, { "grouping": true } ] 94 | no-mixed-spaces-and-tabs: 2 95 | # Postponed 96 | #no-native-reassign: 2 97 | no-negated-in-lhs: 2 98 | # Postponed 99 | #no-nested-ternary: 2 100 | no-new: 2 101 | no-new-func: 2 102 | no-new-object: 2 103 | no-new-require: 2 104 | no-new-wrappers: 2 105 | no-obj-calls: 2 106 | no-octal: 2 107 | no-octal-escape: 2 108 | no-path-concat: 2 109 | no-proto: 2 110 | no-redeclare: 2 111 | # Postponed 112 | #no-regex-spaces: 2 113 | no-return-assign: 2 114 | no-self-compare: 2 115 | no-sequences: 2 116 | # Postponed 117 | #no-shadow: 2 118 | no-shadow-restricted-names: 2 119 | no-sparse-arrays: 2 120 | # Postponed 121 | #no-sync: 2 122 | no-trailing-spaces: 2 123 | no-undef: 2 124 | no-undef-init: 2 125 | no-undefined: 2 126 | no-unexpected-multiline: 2 127 | no-unreachable: 2 128 | no-unused-expressions: 2 129 | no-unused-vars: 2 130 | no-use-before-define: 2 131 | no-void: 2 132 | no-with: 2 133 | object-curly-spacing: [ 2, always, { "objectsInObjects": true, "arraysInObjects": true } ] 134 | operator-assignment: 1 135 | # Postponed 136 | #operator-linebreak: [ 2, after ] 137 | semi: 2 138 | semi-spacing: 2 139 | space-before-function-paren: [ 2, { "anonymous": "always", "named": "never" } ] 140 | space-in-parens: [ 2, never ] 141 | space-infix-ops: 2 142 | space-unary-ops: 2 143 | # Postponed 144 | #spaced-comment: [ 1, always, { exceptions: [ '/', '=' ] } ] 145 | strict: [ 2, global ] 146 | quotes: [ 2, single, avoid-escape ] 147 | quote-props: [ 1, 'as-needed' ] 148 | radix: 2 149 | use-isnan: 2 150 | valid-typeof: 2 151 | yoda: [ 2, never, { "exceptRange": true } ] 152 | 153 | # 154 | # es6 155 | # 156 | arrow-body-style: [ 1, "as-needed" ] 157 | arrow-parens: [ 1, "as-needed" ] 158 | arrow-spacing: 2 159 | constructor-super: 2 160 | generator-star-spacing: [ 2, {"before": false, "after": true } ] 161 | no-class-assign: 2 162 | no-confusing-arrow: [ 1, { allowParens: true } ] 163 | no-const-assign: 2 164 | #no-constant-condition: 2 165 | no-dupe-class-members: 2 166 | no-this-before-super: 2 167 | # Postponed 168 | #no-var: 2 169 | object-shorthand: 1 170 | # Postponed 171 | #prefer-arrow-callback: 1 172 | # Postponed 173 | #prefer-const: 1 174 | #prefer-reflect 175 | #prefer-spread 176 | # Postponed 177 | #prefer-template: 1 178 | require-yield: 1 179 | -------------------------------------------------------------------------------- /.github/auto-comment.yml: -------------------------------------------------------------------------------- 1 | # Comment to a new issue. 2 | pullRequestOpened: | 3 | Thank you for raising your pull request. 4 | 5 | To ensure that all licensing criteria is met all repositories of the LVGL project apply a process called DCO (Developer's Certificate of Origin). 6 | 7 | The text of DCO can be read here: https://developercertificate.org/ 8 | For a more detailed description see the [Documentation](https://docs.lvgl.io/latest/en/html/contributing/index.html#developer-certification-of-origin-dco) site. 9 | 10 | By contributing to any repositories of the LVGL project you state that your contribution corresponds with the DCO. 11 | 12 | No further action is required if your contribution fulfills the DCO. If you are not sure about it feel free to ask us in a comment. 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | 9 | tests: 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | node-version: [16, 18, 20, 22] 14 | # Use different OS to have different C compilers: 15 | os-version: [ubuntu-latest, ubuntu-22.04, ubuntu-20.04] 16 | # macos-14-large, macos-14, macos-13-large, macos-13 17 | # macos currently fails with this and similar 18 | # ../../src/lv_i18n.template.c:207:28: error: mixing declarations and code is incompatible with standards before C99 [-Werror,-Wdeclaration-after-statement] 19 | # const lv_i18n_lang_t * lang = current_lang; 20 | 21 | name: "Tests (OS: ${{ matrix.os-version }} Node: ${{ matrix.node-version }})" 22 | runs-on: ${{ matrix.os-version }} 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | 29 | - run: npm i lv_i18n -s 30 | - run: cd test/c ; make test-deps 31 | - run: make test 32 | - run: cd example && make example && ./main 33 | - run: cd example && make example-optimized && ./main 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | *.log 5 | *.swp 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - lts/* 5 | before_script: make test-deps 6 | script: make test 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.2.1 / 2020-10-29 2 | ------------------ 3 | 4 | - Fix extract of multiple phrases from single line, #31. 5 | 6 | 7 | 0.2.0 / 2020-10-29 8 | ------------------ 9 | 10 | - Bundle deps for faster `npx` execute. 11 | 12 | 13 | 0.1.2 / 2020-10-05 14 | ------------------ 15 | 16 | - Deps bump. 17 | - Fix Travis-CI badge. 18 | 19 | 20 | 0.1.1 / 2019-07-13 21 | ------------------ 22 | 23 | - Deps bump. 24 | - Pin unity version to fix C tests. 25 | 26 | 27 | 0.1.0 / 2019-05-09 28 | ------------------ 29 | 30 | - First release. 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 authors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | .PHONY: default test-coverage test test-deps clean 3 | 4 | test-deps: 5 | cd test/c && $(MAKE) test-deps 6 | 7 | test-js: 8 | npm test 9 | 10 | test-deps: 11 | cd test/c && $(MAKE) test-deps 12 | 13 | test-c: 14 | cd test/c && $(MAKE) test 15 | 16 | test: test-js test-c 17 | 18 | test-coverage: 19 | cd test/c && $(MAKE) test-coverage 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lv_i18n - Internationalization for LittlevGL 2 | ============================================ 3 | 4 | [![Build Status](https://github.com/lvgl/lv_i18n/actions/workflows/.github/workflows/ci.yml/badge.svg)](https://github.com/lvgl/lv_i18n/actions/workflows/ci.yml) 5 | [![NPM version](https://img.shields.io/npm/v/lv_i18n.svg?style=flat)](https://www.npmjs.org/package/lv_i18n) 6 | 7 | Lightweight [gettext](https://www.gnu.org/software/gettext/) replacement 8 | tools for C. Add multi-language support to your embedded projects with ease. 9 | 10 | 11 | ## Quick overview 12 | 13 | 1. Mark up the text in your C files as `_("some text")` (singular) and `_p("%d item", item_cnt)` (plural) 14 | 2. Create template `yml` files for the translations you are interested in 15 | 3. Run `extract` to fill the `yml` files with the texts in `_()` and `_p()` 16 | 4. Add the translations into the `yml` files 17 | 5. Run `compile` to convert the `yml` files to a C and H file. They will contain the translations and all the background functions you need. 18 | 6. Be sure your fonts contain the required characters. See [the docs here](https://docs.lvgl.io/latest/en/html/overview/font.html) for more details. 19 | 20 | ## Install/run the script 21 | 22 | [node.js](https://nodejs.org/en/download/) required. 23 | 24 | Global install of the last version, execute as "lv_i18n" 25 | 26 | ```sh 27 | npm i lv_i18n -g 28 | ``` 29 | 30 | **Alternatives:** 31 | 32 | Install from github's repo, master branch 33 | 34 | ```sh 35 | npm i littlevgl/lv_i18n -g 36 | ``` 37 | 38 | If you wish local install for your project, do in project root: 39 | 40 | ```sh 41 | npm init private 42 | npm i lv_i18n -s 43 | # now available at ./node_modules/.bin/lv_i18n 44 | ``` 45 | 46 | Then commit `package.json` & put `/node_modules` into `.gitignore`. Next time 47 | use just `npm i` to install. 48 | 49 | **run via [npx](https://www.npmjs.com/package/npx)** 50 | 51 | `node.js` has built-in [npx](https://www.npmjs.com/package/npx) utility to 52 | execute packages "without install": 53 | 54 | ```sh 55 | # run from github master 56 | npx github:littlevgl/lv_i18n -h 57 | # run from npm registry 58 | npx lv_i18n -h 59 | ``` 60 | 61 | 62 | ## Mark up the text in your code 63 | 64 | ```c 65 | #include "lv_i18n/lv_i18n.h" /*Assuming you have the translations here. (See below)*/ 66 | 67 | /* Load translations & default locale (usually, done once) */ 68 | lv_i18n_init(lv_i18n_language_pack); 69 | 70 | /* Set active locale (can be switched anytime) */ 71 | lv_i18n_set_locale("ru-RU"); 72 | 73 | /* The translation of "title1" will be returned according to the selected locale. 74 | * ("title1" is only a unique ID of the text.) Example: 75 | * en-GB: "Main menu" 76 | * ru_RU: "Главное меню" 77 | */ 78 | gui_set_text(label, _("title1")); 79 | 80 | /* According to `user_cnt` different text can be returned 81 | * en-GB `user_cnt == 1` : "One user is logged in" 82 | * `user_cnt == 6` : "%d users are logged in" 83 | */ 84 | char buf[64]; 85 | sprintf(buf, _p("user_logged_in", user_cnt)), user_cnt); /*E.g. buf == "7 users are logged in"*/ 86 | gui_set_text(label, buf); 87 | ``` 88 | 89 | `_` and `_p` are normal functions. They just have this short name to enable fast typing of texts. 90 | 91 | Rules of getting the translation: 92 | - If the translation is not available on the selected locale then the default language will be used instead 93 | - If the translation is not available on the default locale the text ID ("title1" in the example) will be returned 94 | 95 | 96 | ## Create template yml files for the translations 97 | 98 | For each translation, you need to create a `yml` file with "language code" name. For example: 99 | 100 | - en-GB.yml 101 | - ru-RU.yml 102 | 103 | Here is a [list](https://www.andiamo.co.uk/resources/iso-language-codes/) of the language and locale codes. 104 | 105 | Add the `locale-name: ~` line to the `yml` files. Replace "locale-name" with the actual language code. 106 | E.g.: `en-GB: ~` or simply `en: ~` 107 | 108 | Technically you can have one `yml` file where you list all language codes you need but its more modular to separate them. 109 | 110 | 111 | ## Run extract to fill the yml files 112 | 113 | Run `extract` like this (assuming your source files are in the `src` folder and the `yml` files in the translations folder): 114 | 115 | ```sh 116 | lv_i18n extract -s 'src/**/*.+(c|cpp|h|hpp)' -t 'translations/*.yml' 117 | ``` 118 | 119 | It will fill the `yml` files the texts marked with `_` and `_p`. 120 | For example: 121 | 122 | ```yml 123 | en-GB: 124 | title1: ~ 125 | user_logged_in: 126 | one: ~ 127 | other: ~ 128 | ``` 129 | 130 | 131 | ## Add the translations into the yml files 132 | 133 | The naming conventions in the `yml` files follow the rules of [CLDR](https://cldr.unicode.org/translation/displaynames/languagelocale-names) so most of the translation offices will know them. 134 | 135 | Example: 136 | 137 | ```yml 138 | 'en-GB': 139 | title1: Main menu 140 | user_logged_in: 141 | one: One user is logged in 142 | other: '%d users are logged in' 143 | ``` 144 | 145 | If translators want to know where a message comes from, then use `lv_i18n extract --dump-sourceref sr.json ...` to generate the file `sr.json` containing file names and line number of each message. 146 | 147 | ## Run compile to convert the yml files to a C and H file 148 | 149 | Once you have the translations in the `yml` files you only need to run the `compile` to generate a C and H files from the `yml` files. No other library will be required to get the translation with `_()` and `_p`. 150 | 151 | Running `compile`: 152 | 153 | ```sh 154 | lv_i18n compile -t 'translations/*.yml' -o 'src/lv_i18n' 155 | ``` 156 | 157 | The default locale is `en-GB` but you change it with `-l 'language-code'`. 158 | 159 | You can use `--optimize` to generate optimized C code. Without this, finding the corresponding translation by `lv_i18n_get_text()` is done by searching through all keys until the right one is found. This can eat up a lot of CPU espcially if the list is long (aka O(n)). Using `--optimize` changes this behaviour by using an integer index into the list of strings resulting in an immediate return of the right string (aka O(1)). As the index is computed at compile time, you need a compiler, which is able to evaluate `strcmp()` with constants at compile time. All modern compilers, like gcc and clang are able to do this. If you want to check, whether your compiler is able to handle this optimization, you can use the following code to check this: 160 | 161 | ```c 162 | int main() 163 | { 164 | return strcmp("a", "a"); 165 | } 166 | ``` 167 | 168 | If this compiles without needing `#include ` and `nm -u a.out` does not output `strcmp` as being undefined, then the compiler optimizes the code and is able to handle `--optimize`. 169 | 170 | ## Follow modifications in the source code 171 | To change a text id in the `yml` files use: 172 | ```sh 173 | lv_i18n rename -t src/i18n/*.yml --from 'Hillo wold' --to 'Hello world!' 174 | ``` 175 | 176 | ## Example application 177 | 178 | You can find a complete example application inside the `example/` 179 | directory. Please see [Example README](example/README.md) for more 180 | information. 181 | 182 | ## C API 183 | 184 | #### int lv_i18n_init(const lv_i18n_language_pack_t * langs) 185 | Attach generated translations to be used by `lv_i18n_get_text()`. 186 | 187 | - _return_ - 0 on success, -1 on fail. 188 | 189 | ___ 190 | 191 | #### int lv_i18n_set_locale(const char * l_name) 192 | Set locale to be used by `lv_i18n_get_text()`. 193 | 194 | - _l_name_ - locale name (`en-GB`, `ru-RU`). 195 | - _returns_ - 0 on success, -1 if locale not found. 196 | 197 | ___ 198 | 199 | #### const char * lv_i18n_get_text(const char * msg_id) 200 | Mapped to `_(...)` or `_t(...)` via `#define` 201 | 202 | Get translated text. If not translated, return fallback (try default locale 203 | first, then input param if default not exists) 204 | - _msg_id_ - The ID of a text to translate (e.g. `"title1"`) 205 | - _return_ - pointer to the traslation 206 | 207 | ___ 208 | 209 | #### char* lv_i18n_get_text_plural(char* msg_id, int32_t plural) 210 | Mapped to `_p(...)` or `_tp(...)` via `#define` 211 | 212 | Get the plural form of translated text. Use current locale to select plural 213 | algorithm. If not translated, fallback to default locale first, then to input 214 | param. 215 | 216 | - _msg_id_ - The ID of a text to translate (e.g. `"title1"`) 217 | - _plural_ - number of items to decide which plural for to use 218 | - _return_ - pointer to the traslation 219 | 220 | ## References: 221 | 222 | To understand i18n principles better, you may find useful links below: 223 | 224 | - [gettext](https://www.gnu.org/software/gettext/) 225 | - [Rails Internationalization (I18n) API](https://guides.rubyonrails.org/i18n.html) 226 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | example: clean compile main 2 | 3 | example-optimized: clean compile-optimized main 4 | 5 | extract: 6 | ../lv_i18n.js extract -s main.c -t "translations/*.yml" 7 | 8 | compile: 9 | ../lv_i18n.js compile -l en-GB -t "translations/*.yml" -o . 10 | 11 | compile-optimized: 12 | ../lv_i18n.js compile --optimize -l en-GB -t "translations/*.yml" -o . 13 | 14 | main.o: main.c 15 | $(CC) -c -o main.o main.c 16 | 17 | lv_i18n.o: lv_i18n.c 18 | $(CC) -c -o lv_i18n.o lv_i18n.c 19 | 20 | main: main.o lv_i18n.o 21 | $(CC) -o main main.o lv_i18n.o 22 | 23 | clean: 24 | rm -f main main.o lv_i18n.o 25 | 26 | distclean: clean 27 | rm -f lv_i18n.c lv_i18n.h *~ 28 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | Example for lv_i18n 2 | =================== 3 | 4 | This directory contains a small example to demonstrate the use of 5 | lv_i18n. The source code of the C program using translated text with 6 | lv_i18n is "main.c" and the translations can be found in 7 | "translations/". 8 | 9 | ## Create the lv_i18n library 10 | 11 | There are two versions: standard and optimized (using lv_i18n with 12 | --optimize). First create the lv_i18n library by using the available 13 | translations with:: 14 | 15 | ```sh 16 | make compile 17 | ``` 18 | 19 | This will create lv_i18n.h and lv_i18n.c in the current directory 20 | containing the library code and the translations. For the optimized 21 | case, use:: 22 | 23 | ```sh 24 | make compile-optimized 25 | ``` 26 | 27 | ## Compile the sample program 28 | 29 | Then use the following command to compile your application in main.c 30 | together with the created lv_i18n library and translations:: 31 | 32 | ```sh 33 | make main 34 | ``` 35 | 36 | ## Execute the application 37 | 38 | Execute your application with 39 | 40 | ```sh 41 | ./main 42 | ``` 43 | 44 | It will use lv_i18n to printf various strings and their associated 45 | translations. It will do this for the language pack "en-GB" (default) 46 | and "ru-RU". So you should see, which key gets translated and how the 47 | fallback to the default language works. 48 | 49 | ## Extending the application 50 | 51 | The text "This is a new text" in main.c is not yet part of the 52 | translation and is an example on how to extend your program with new 53 | texts. Without the follwing work, this text is not found in the 54 | translation table, and therefore used unchanged/untranslated. 55 | 56 | If you added new texts to your application, you muste execute 57 | 58 | ```sh 59 | make extract 60 | ``` 61 | 62 | to extract new texts from your main.c and add it to the translation 63 | files. Please the edit translations/*.yml to see, that the new key was 64 | added and implement a proper translation for "en-GB" and "ru-RU". 65 | 66 | Then follow above to "make compile" and "make main" to compile the 67 | library and application and see the new key being translated. 68 | -------------------------------------------------------------------------------- /example/main.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "lv_i18n.h" 3 | 4 | void use_i18n(void) 5 | { 6 | printf("%s\t%s\n", "s_en_only", _("s_en_only")); 7 | printf("%s\t%s\n", "s_translated", _("s_translated")); 8 | printf("%s\t%s\n", "s_untranslated", _("s_untranslated")); 9 | printf("%s\t%s\n", "This is a new text", _("This is a new text")); 10 | for(int i=0; i<10;i++) 11 | { 12 | printf("%s\t%d ", "p_i_have_dogs", i); 13 | printf(_p("p_i_have_dogs", i), i); 14 | printf("\n"); 15 | } 16 | } 17 | 18 | int main(void) 19 | { 20 | lv_i18n_init(lv_i18n_language_pack); 21 | 22 | lv_i18n_set_locale("en-GB"); 23 | puts("en-GB:"); 24 | use_i18n(); 25 | 26 | lv_i18n_set_locale("ru-RU"); 27 | puts("ru-RU:"); 28 | use_i18n(); 29 | 30 | return 0; 31 | } 32 | 33 | -------------------------------------------------------------------------------- /example/translations/en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | s_en_only: english only 3 | s_translated: s translated 4 | s_untranslated: ~ 5 | p_i_have_dogs: 6 | one: I have %d dog 7 | other: I have %d dogs 8 | -------------------------------------------------------------------------------- /example/translations/ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: 2 | s_en_only: ~ 3 | s_translated: s переведено 4 | s_untranslated: ~ 5 | p_i_have_dogs: 6 | one: У меня %d собакен 7 | few: У меня %d собакена 8 | many: У меня %d собакенов 9 | other: ~ 10 | -------------------------------------------------------------------------------- /lib/app_error.js: -------------------------------------------------------------------------------- 1 | // Custom Error type to simplify error messageing 2 | // 3 | 'use strict'; 4 | 5 | 6 | const ExtendableError = require('es6-error'); 7 | 8 | 9 | module.exports = class AppError extends ExtendableError {}; 10 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | // Parse input arguments and execute commands 2 | 'use strict'; 3 | 4 | 5 | const ArgumentParser = require('argparse').ArgumentParser; 6 | const debug = require('debug')('cli'); 7 | 8 | const commands = [ 9 | require('./cmd_extract'), 10 | require('./cmd_compile'), 11 | require('./cmd_rename') 12 | ]; 13 | 14 | 15 | module.exports.run = function (test_cli_params) { 16 | 17 | // Main parser instance 18 | let argparser = new ArgumentParser({ 19 | add_help: true, 20 | epilog: "See '%(prog)s -h' for more information on specific command." 21 | }); 22 | 23 | argparser.add_argument('-v', '--version', { 24 | action: 'version', 25 | version: require('../package.json').version 26 | }); 27 | 28 | // Sub-parser for commands 29 | let cmd_argparsers = argparser.add_subparsers({ 30 | title: 'Known commands', 31 | dest: 'command' 32 | }); 33 | 34 | // Configure commands & arguments 35 | commands.forEach(c => { 36 | let subparser = cmd_argparsers.add_parser( 37 | c.subparserInfo.command, 38 | c.subparserInfo.options 39 | ); 40 | 41 | c.subparserArgsList.forEach(item => { 42 | let args = [].concat(item.args).concat([ JSON.parse(JSON.stringify(item.options)) ]); 43 | subparser.add_argument(...args); 44 | }); 45 | }); 46 | 47 | // Finally, parse args 48 | let cli_args = test_cli_params || process.argv.slice(2); 49 | let args = argparser.parse_args(cli_args.length ? cli_args : [ '-h' ]); 50 | 51 | // Let's rock begin! 52 | debug(`Arguments: ${args}`); 53 | commands.find(c => c.subparserInfo.command === args.command).execute(args); 54 | }; 55 | -------------------------------------------------------------------------------- /lib/cmd_compile.js: -------------------------------------------------------------------------------- 1 | // "Compile" translations to C code. 2 | 'use strict'; 3 | 4 | 5 | const TranslationKeys = require('./translation_keys'); 6 | const AppError = require('./app_error'); 7 | const shell = require('shelljs'); 8 | 9 | const { join, dirname, basename, extname } = require('path'); 10 | const { getRAW, getIDX } = require('./compiler_template'); 11 | 12 | const { readFileSync, writeFileSync } = require('fs'); 13 | 14 | 15 | module.exports.subparserInfo = { 16 | command: 'compile', 17 | options: { 18 | description: 'Generate compiled translations' 19 | } 20 | }; 21 | 22 | 23 | module.exports.subparserArgsList = [ 24 | { 25 | args: [ '-t' ], 26 | options: { 27 | dest: 'translations', 28 | help: 'translation file(s) path (glob patterns allowed)', 29 | action: 'append', 30 | metavar: '', 31 | required: true 32 | } 33 | }, 34 | { 35 | args: [ '-o' ], 36 | options: { 37 | dest: 'output', 38 | help: 'output folder path', 39 | metavar: '' 40 | } 41 | }, 42 | { 43 | args: [ '--raw' ], 44 | options: { 45 | dest: 'output_raw', 46 | help: 'raw output file path', 47 | metavar: '' 48 | } 49 | }, 50 | { 51 | args: [ '--optimize' ], 52 | options: { 53 | dest: 'optimize', 54 | help: 'Use integers as keys instead of strings to speed up lookup', 55 | action: 'store_true', 56 | default: false 57 | } 58 | }, 59 | { 60 | args: [ '-l' ], 61 | options: { 62 | dest: 'base_locale', 63 | help: 'base locale (default: en/en-GB/en-US)', 64 | metavar: '' 65 | } 66 | } 67 | ]; 68 | 69 | 70 | module.exports.execute = function (args) { 71 | let translationKeys = new TranslationKeys(); 72 | 73 | translationKeys.loadFiles(args.translations); 74 | 75 | if (!translationKeys.filesCount) { 76 | throw new AppError ('Failed to find any translation file.'); 77 | } 78 | 79 | if (args.base_locale && !translationKeys.localeDefaultFile[args.base_locale]) { 80 | throw new AppError(` 81 | You specified base locale "${args.base_locale}", but it was not found in loaded translations. 82 | Found locales are {${Object.keys(translationKeys.localeDefaultFile).join(',')}}. 83 | `); 84 | } 85 | 86 | if (!args.base_locale) { 87 | // Try to guess locale (en / en-GB / en-US) 88 | for (let guess of [ 'en', 'en-GB', 'en-US' ]) { 89 | /*eslint-disable max-depth*/ 90 | for (let l of Object.keys(translationKeys.localeDefaultFile)) { 91 | if ((l.toLowerCase().replace(/_/g, '-') === guess.toLowerCase()) && 92 | !args.base_locale) { 93 | args.base_locale = l; 94 | } 95 | } 96 | } 97 | 98 | if (!args.base_locale) { 99 | throw new AppError(` 100 | You did not specified locale and we could not autodetect it. Use '-l' option. 101 | `); 102 | } 103 | 104 | /*eslint-disable no-console*/ 105 | console.log(`Base locale '${args.base_locale}' (autodetected)`); 106 | } 107 | 108 | if (!args.output && !args.output_raw) { 109 | throw new AppError('You should specify output folder or raw output file option'); 110 | } 111 | 112 | // 113 | // Create sorted locales, with default one first. 114 | // 115 | let sorted_locales = [ args.base_locale ]; 116 | 117 | Object.keys(translationKeys.localeDefaultFile).forEach(locale => { 118 | if (locale !== args.base_locale) sorted_locales.push(locale); 119 | }); 120 | 121 | // 122 | // Fill data 123 | // 124 | let data = { 125 | singularKeys: [], 126 | pluralKeys: [] 127 | }; 128 | 129 | // Pre-fill .singular & plural props for edge case - missed locale 130 | sorted_locales.forEach(l => { 131 | data[l] = { singular: {}, plural: {} }; 132 | }); 133 | 134 | translationKeys.phrases.forEach(p => { 135 | if (p.value?.constructor !== Object) { 136 | if (!data.singularKeys.includes(p.key)) { 137 | data.singularKeys.push(p.key); 138 | } 139 | } else if (!data.pluralKeys.includes(p.key)) { 140 | data.pluralKeys.push(p.key); 141 | } 142 | }); 143 | data.singularKeys.sort(); 144 | data.pluralKeys.sort(); 145 | 146 | translationKeys.phrases.forEach(p => { 147 | if (p.value === null) return; 148 | 149 | // Singular 150 | if (p.value?.constructor !== Object) { 151 | Object.assign(data[p.locale]['singular'], { [p.key]: p.value }); 152 | return; 153 | } 154 | 155 | // Plural 156 | Object.entries(p.value).forEach(([ form, val ]) => { 157 | if (val === null) return; 158 | 159 | if (!data[p.locale]['plural'][form]) data[p.locale]['plural'][form] = {}; 160 | 161 | Object.assign(data[p.locale]['plural'][form], { [p.key]: val }); 162 | }); 163 | 164 | }); 165 | 166 | let raw_idx; 167 | if (args.optimize) raw_idx = '#define LV_I18N_OPTIMIZE 1\n'; 168 | else raw_idx = '#undef LV_I18N_OPTIMIZE\n'; 169 | raw_idx += getIDX(data); 170 | let raw = getRAW(args, sorted_locales, data); 171 | 172 | if (args.output_raw) { 173 | writeFileSync(args.output_raw, raw); 174 | let output_raw_header = join(dirname(args.output_raw), basename(args.output_raw, extname(args.output_raw)) + '.h'); 175 | writeFileSync(output_raw_header, raw_idx); 176 | } 177 | 178 | if (args.output) { 179 | if (!shell.test('-d', args.output)) { 180 | throw new AppError(`Output directory not exists (${args.output})`); 181 | } 182 | 183 | let txt_h = readFileSync(join(__dirname, '../src/lv_i18n.template.h'), 'utf-8'); 184 | txt_h = txt_h.replace(/\/\*SAMPLE_START\*\/([\s\S]+)\/\*SAMPLE_END\*\//, 185 | `${raw_idx} 186 | ////////////////////////////////////////////////////////////////////////////////`); 187 | writeFileSync(join(args.output, 'lv_i18n.h'), txt_h); 188 | 189 | let txt = readFileSync(join(__dirname, '../src/lv_i18n.template.c'), 'utf-8'); 190 | 191 | txt = txt.replace(/\/\*SAMPLE_START\*\/([\s\S]+)\/\*SAMPLE_END\*\//, 192 | `${raw} 193 | ////////////////////////////////////////////////////////////////////////////////`); 194 | 195 | writeFileSync(join(args.output, 'lv_i18n.c'), txt); 196 | } 197 | }; 198 | -------------------------------------------------------------------------------- /lib/cmd_extract.js: -------------------------------------------------------------------------------- 1 | // Extract new texts & update translations. 2 | 'use strict'; 3 | 4 | 5 | const SourceKeys = require('./source_keys'); 6 | const TranslationKeys = require('./translation_keys'); 7 | const AppError = require('./app_error'); 8 | 9 | const { getPluralKeys } = require('./plurals'); 10 | 11 | 12 | module.exports.subparserInfo = { 13 | command: 'extract', 14 | options: { 15 | description: 'Scan sources and update translations with missed keys' 16 | } 17 | }; 18 | 19 | 20 | module.exports.subparserArgsList = [ 21 | { 22 | args: [ '-s' ], 23 | options: { 24 | dest: 'sources', 25 | help: 'source file(s) path (glob patterns allowed)', 26 | type: 'str', 27 | action: 'append', 28 | metavar: '', 29 | required: true 30 | } 31 | }, 32 | { 33 | args: [ '-t' ], 34 | options: { 35 | dest: 'translations', 36 | help: 'translation file(s) path (glob patterns allowed)', 37 | action: 'append', 38 | metavar: '', 39 | required: true 40 | } 41 | }, 42 | { 43 | args: [ '-c' ], 44 | options: { 45 | dest: 'check_only', 46 | help: "check only, don't update translations", 47 | action: 'store_true', 48 | default: false 49 | } 50 | }, 51 | { 52 | args: [ '--orphaned-fail' ], 53 | options: { 54 | dest: 'orphaned_fail', 55 | help: 'stop with error on orphaner phrases', 56 | action: 'store_true', 57 | default: false 58 | } 59 | }, 60 | { 61 | args: [ '--dump-sourceref' ], 62 | options: { 63 | dest: 'dump_sourceref', 64 | help: 'dump the location of the extracted message keys to the given file', 65 | metavar: '' 66 | } 67 | } 68 | ]; 69 | 70 | 71 | module.exports.execute = function (args) { 72 | /* eslint no-console: off */ 73 | let sourceKeys = new SourceKeys(); 74 | let translationKeys = new TranslationKeys(); 75 | 76 | sourceKeys.loadFiles(args.sources); 77 | 78 | if (!sourceKeys.filesCount) { 79 | throw new AppError ('Failed to find any source file'); 80 | } 81 | 82 | if (!sourceKeys.keys.length) { 83 | console.log('No phrases to translate'); 84 | return; 85 | } 86 | 87 | translationKeys.loadFiles(args.translations); 88 | 89 | // We should have locales to fill with keys. Those may be missed only 90 | // when no files found at all (file without locale will fail to load). 91 | if (!translationKeys.filesCount) { 92 | throw new AppError (` 93 | Failed to find any translation file. Create empty templates with locale names 94 | to start. Example: 95 | 96 | File "en-EN.yml", content - "en-GB: ~" 97 | `); 98 | } 99 | 100 | // 101 | // Check orphaned phrases 102 | // 103 | let orphaned = {}; 104 | 105 | translationKeys.phrases.forEach(p => { 106 | if (!sourceKeys.uniques.hasOwnProperty(p.key)) { 107 | if (!orphaned[p.fileName]) orphaned[p.fileName] = []; 108 | 109 | orphaned[p.fileName].push(p.key); 110 | } 111 | }); 112 | 113 | if (Object.keys(orphaned).length) { 114 | let msg_orphaned = 'Your translations have orphaned phrases:\n\n'; 115 | 116 | Object.entries(orphaned).forEach(([ file, keys ]) => { 117 | msg_orphaned += ` ${file}\n`; 118 | msg_orphaned += keys.map(k => ` ${k}\n`).join(''); 119 | }); 120 | 121 | if (args.orphaned_fail) throw new AppError(msg_orphaned); 122 | else console.log(msg_orphaned); 123 | } 124 | 125 | // 126 | // fill missed phrases 127 | // 128 | Object.entries(translationKeys.localeDefaultFile).forEach(([ locale, fileName ]) => { 129 | Object.entries(sourceKeys.uniques).forEach(([ keyName, keyObj ]) => { 130 | let phraseObj = translationKeys.getPhraseObj(locale, keyName); 131 | 132 | if (!phraseObj) { 133 | // Not exists -> add new one 134 | translationKeys.addPhrase({ 135 | locale, 136 | key: keyName, 137 | value: !keyObj.plural ? null : Object.fromEntries(getPluralKeys(locale).map(k => [ k, null ])), 138 | fileName 139 | }); 140 | } else if (!(phraseObj.value?.constructor === Object) !== !keyObj.plural) { 141 | // Translation already exists -> check type (singular/plural) 142 | // and throw on mismatch 143 | throw new AppError(` 144 | "${keyName}" - mixed singular/plural in source and translation 145 | 146 | ${keyObj.fileName} 147 | ${phraseObj.fileName} 148 | `); 149 | } 150 | }); 151 | }); 152 | 153 | translationKeys.saveFiles(); 154 | 155 | if (args.dump_sourceref) { 156 | sourceKeys.dumpSourceRef(args.dump_sourceref); 157 | } 158 | }; 159 | -------------------------------------------------------------------------------- /lib/cmd_rename.js: -------------------------------------------------------------------------------- 1 | // Rename translation key in all files. 2 | 'use strict'; 3 | 4 | 5 | const TranslationKeys = require('./translation_keys'); 6 | const AppError = require('./app_error'); 7 | 8 | 9 | module.exports.subparserInfo = { 10 | command: 'rename', 11 | options: { 12 | description: 'Rename key in all translation files' 13 | } 14 | }; 15 | 16 | 17 | module.exports.subparserArgsList = [ 18 | { 19 | args: [ '-t' ], 20 | options: { 21 | dest: 'translations', 22 | help: 'translation file(s) path (glob patterns allowed)', 23 | action: 'append', 24 | metavar: '', 25 | required: true 26 | } 27 | }, 28 | { 29 | args: [ '--from' ], 30 | options: { 31 | dest: 'from', 32 | help: 'old translation key name', 33 | metavar: '', 34 | required: true 35 | } 36 | }, 37 | { 38 | args: [ '--to' ], 39 | options: { 40 | dest: 'to', 41 | help: 'new translation key name', 42 | metavar: '', 43 | required: true 44 | } 45 | } 46 | ]; 47 | 48 | 49 | module.exports.execute = function (args) { 50 | let translationKeys = new TranslationKeys(); 51 | 52 | translationKeys.loadFiles(args.translations); 53 | 54 | if (!translationKeys.filesCount) { 55 | throw new AppError ('Failed to find any translation file'); 56 | } 57 | 58 | 59 | if (!translationKeys.phrases.find(p => p.key === args.from)) { 60 | throw new AppError(`Could not find key '${args.from}' in any translation`); 61 | } 62 | 63 | /* eslint-disable no-console */ 64 | console.log('Renaming...'); 65 | 66 | // Traverse locales 67 | Object.keys(translationKeys.localeDefaultFile).forEach(locale => { 68 | let obj = translationKeys.getPhraseObj(locale, args.from); 69 | 70 | // If key found in this locale - drop destination entry & rename 71 | if (obj) { 72 | translationKeys.removePhraseObj(locale, args.to); 73 | obj.key = args.to; 74 | console.log(obj.fileName); 75 | } 76 | }); 77 | 78 | translationKeys.saveFiles(); 79 | 80 | console.log('Done!'); 81 | }; 82 | -------------------------------------------------------------------------------- /lib/compiler_template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const { create_c_plural_fn } = require('./plurals'); 5 | 6 | 7 | // en-GB => en_gb 8 | function to_c(locale) { 9 | return locale.toLowerCase().replace(/-/g, '_'); 10 | } 11 | 12 | // escape C string to write all in one line. 13 | function esc(str) { 14 | // TODO: simple & dirty, should be improved. 15 | return JSON.stringify(str).slice(1, -1); 16 | } 17 | 18 | const pf_enum = { 19 | zero: 'LV_I18N_PLURAL_TYPE_ZERO', 20 | one: 'LV_I18N_PLURAL_TYPE_ONE', 21 | two: 'LV_I18N_PLURAL_TYPE_TWO', 22 | few: 'LV_I18N_PLURAL_TYPE_FEW', 23 | many: 'LV_I18N_PLURAL_TYPE_MANY', 24 | other: 'LV_I18N_PLURAL_TYPE_OTHER' 25 | }; 26 | 27 | 28 | const plural_helpers = ` 29 | //////////////////////////////////////////////////////////////////////////////// 30 | // Define plural operands 31 | // http://unicode.org/reports/tr35/tr35-numbers.html#Operands 32 | 33 | // Integer version, simplified 34 | 35 | #define UNUSED(x) (void)(x) 36 | 37 | static inline uint32_t op_n(int32_t val) { return (uint32_t)(val < 0 ? -val : val); } 38 | static inline uint32_t op_i(uint32_t val) { return val; } 39 | // always zero, when decimal part not exists. 40 | static inline uint32_t op_v(uint32_t val) { UNUSED(val); return 0;} 41 | static inline uint32_t op_w(uint32_t val) { UNUSED(val); return 0; } 42 | static inline uint32_t op_f(uint32_t val) { UNUSED(val); return 0; } 43 | static inline uint32_t op_t(uint32_t val) { UNUSED(val); return 0; } 44 | static inline uint32_t op_e(uint32_t val) { UNUSED(val); return 0; } 45 | `.trim(); 46 | 47 | 48 | function lang_plural_template(l, form, data) { 49 | const loc = to_c(l); 50 | let result = ''; 51 | 52 | result = ` 53 | static const char * ${loc}_plurals_${form}[] = { 54 | `; 55 | 56 | let index = 0; 57 | Object.values(data.pluralKeys).forEach(k => { 58 | if (!data[l].plural || !data[l].plural[form] || !data[l].plural[form][k]) { 59 | result += ' NULL, // ' + index + '=\"' + esc(k) + '\"\n'; 60 | } else { 61 | result += ' \"' + esc(data[l].plural[form][k]) + '\", // ' + index + '=\"' + esc(k) + '\"\n'; 62 | } 63 | index++; 64 | }); 65 | 66 | result += '};'; 67 | 68 | return result.trim(); 69 | 70 | } 71 | 72 | function lang_singular_template(l, data) { 73 | const loc = to_c(l); 74 | let result = ''; 75 | 76 | result = ` 77 | static const char * ${loc}_singulars[] = { 78 | `; 79 | 80 | let index = 0; 81 | Object.values(data.singularKeys).forEach(k => { 82 | if (!data[l].singular || !data[l].singular[k]) { 83 | result += ' NULL, // ' + index + '=\"' + esc(k) + '\"\n'; 84 | } else { 85 | result += ' \"' + esc(data[l].singular[k]) + '\", // ' + index + '=\"' + esc(k) + '\"\n'; 86 | } 87 | index++; 88 | }); 89 | 90 | result += '};'; 91 | 92 | return result.trim(); 93 | } 94 | 95 | 96 | function lang_template(args, l, data) { 97 | let pforms = Object.keys(data[l].plural); 98 | const loc = to_c(l); 99 | 100 | return ` 101 | ${Object.keys(data[l].singular).length ? lang_singular_template(l, data) : ''} 102 | 103 | ${pforms.map(pf => lang_plural_template(l, pf, data)).join('\n\n')} 104 | 105 | ${create_c_plural_fn(l, `${loc}_plural_fn`)} 106 | 107 | static const lv_i18n_lang_t ${loc}_lang = { 108 | .locale_name = "${l}", 109 | ${Object.keys(data[l].singular).length ? ` .singulars = ${loc}_singulars,` : ''} 110 | ${pforms.map(pf => ` .plurals[${pf_enum[pf]}] = ${loc}_plurals_${pf},`).join('\n')} 111 | .locale_plural_fn = ${loc}_plural_fn 112 | }; 113 | `.trim(); 114 | } 115 | 116 | function generate_idx(keys) { 117 | let result = keys.length > 0 ? '' : ' NULL,'; 118 | Object.values(keys).forEach(k => { 119 | result += ' \"' + esc(k) + '\",\n'; 120 | }); 121 | 122 | return result; 123 | } 124 | 125 | function getIDX2(keys, i) { 126 | let result = 'LV_I18N_ID_NOT_FOUND'; 127 | 128 | let key = keys[i]; 129 | if (key) { 130 | result = '(!strcmp(str, \"' + esc(key) + '\")?' + i + ':' + getIDX2(keys, i + 1) + ')'; 131 | } 132 | return result; 133 | } 134 | 135 | module.exports.getIDX = function (data) { 136 | let result = '#define LV_I18N_IDX_s(str) ' + getIDX2(data.singularKeys, 0) + '\n'; 137 | result += '#define LV_I18N_IDX_p(str) ' + getIDX2(data.pluralKeys, 0) + '\n'; 138 | return result; 139 | }; 140 | 141 | module.exports.getRAW = function (args, locales, data) { 142 | return ` 143 | ${plural_helpers} 144 | 145 | ${locales.map(l => lang_template(args, l, data)).join('\n\n')} 146 | 147 | const lv_i18n_language_pack_t lv_i18n_language_pack[] = { 148 | ${locales.map(l => ` &${to_c(l)}_lang,`).join('\n')} 149 | NULL // End mark 150 | }; 151 | 152 | #ifndef LV_I18N_OPTIMIZE 153 | 154 | static const char * singular_idx[] = { 155 | ${generate_idx(data.singularKeys)} 156 | }; 157 | 158 | static const char * plural_idx[] = { 159 | ${generate_idx(data.pluralKeys)} 160 | }; 161 | 162 | #endif 163 | 164 | `; 165 | }; 166 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | // Parser to extract translateable texts from source string 2 | // 3 | // TODO: current implementation is very simplified. 4 | // 5 | 'use strict'; 6 | 7 | 8 | const defaults = { 9 | singularName: '_', 10 | pluralName: '_p' 11 | }; 12 | 13 | 14 | function escape_re(s) { 15 | return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 16 | } 17 | 18 | function create_singular_re(fn_name) { 19 | return new RegExp( 20 | '(?:^|[ =+,;\(])' + escape_re(fn_name) + '\\("(.*?)"\\)', 21 | 'g' 22 | ); 23 | } 24 | 25 | function create_plural_re(fn_name) { 26 | return new RegExp( 27 | '(?:^|[ =+,;\(])' + escape_re(fn_name) + '\\("(.*?)",', 28 | 'g' 29 | ); 30 | } 31 | 32 | // unescape C/C++ literal 33 | // https://en.wikipedia.org/wiki/Escape_sequences_in_C 34 | // https://timsong-cpp.github.io/cppwp/n3337/lex.ccon 35 | let unescape_c_table = { 36 | a: 0x07, b: 0x08, e: 0x1b, E: 0x1b, f: 0x0c, 37 | n: 0x0a, r: 0x0d, t: 0x09, v: 0x0b 38 | }; 39 | 40 | function unescape_c(src) { 41 | return src.replace(/\\U([0-9A-Fa-f]{8})|\\u([0-9A-Fa-f]{4})|\\x([0-9A-Fa-f]{1,4})|\\([0-7]{1,3})|\\(.)/g, 42 | function (_, unicode32, unicode16, hex, octal, simple) { 43 | let code; 44 | if (unicode32) { 45 | code = parseInt(unicode32, 16); 46 | } else if (unicode16) { 47 | code = parseInt(unicode16, 16); 48 | } else if (hex) { 49 | code = parseInt(hex, 16); 50 | } else if (octal) { 51 | code = parseInt(octal, 8); 52 | } else { 53 | code = unescape_c_table[simple]; 54 | if (typeof code === 'undefined') code = simple.codePointAt(0); 55 | } 56 | return String.fromCodePoint(code); 57 | }); 58 | } 59 | 60 | 61 | function getLine(text, offset) { 62 | return text.substring(0, offset).split('\n').length; 63 | } 64 | 65 | 66 | function extract(text, re) { 67 | let result = []; 68 | 69 | for (;;) { 70 | let match = re.exec(text); 71 | 72 | if (!match) break; 73 | 74 | result.push({ 75 | key: unescape_c(match[1]), 76 | line: getLine(text, match.index) 77 | }); 78 | } 79 | 80 | return result; 81 | } 82 | 83 | 84 | module.exports = function parse(text, options) { 85 | let opts = Object.assign({}, defaults, options || {}); 86 | 87 | let s_re = create_singular_re(opts.singularName); 88 | let p_re = create_plural_re(opts.pluralName); 89 | 90 | let singulars = extract(text, s_re).map(o => Object.assign(o, { plural: false })); 91 | let plurals = extract(text, p_re).map(o => Object.assign(o, { plural: true })); 92 | 93 | return singulars.concat(plurals).sort((a, b) => a.line - b.line); 94 | }; 95 | 96 | module.exports._unescape_c = unescape_c; 97 | -------------------------------------------------------------------------------- /lib/plurals.js: -------------------------------------------------------------------------------- 1 | // Assorted plurals helpers 2 | 'use strict'; 3 | 4 | 5 | const cardinals = require('cldr-core/supplemental/plurals.json').supplemental['plurals-type-cardinal']; 6 | 7 | 8 | // Strip key prefixes to get clear names: zero / one / two / few / many / other 9 | function renameKeys(rules) { 10 | var result = {}; 11 | Object.keys(rules).forEach(function (k) { 12 | result[k.match(/[^-]+$/)] = rules[k]; 13 | }); 14 | return result; 15 | } 16 | 17 | 18 | module.exports.getPluralKeys = function (locale) { 19 | let lang = locale.toLowerCase().split(/[-_]/)[0]; 20 | 21 | return Object.keys(renameKeys(cardinals[lang])); 22 | }; 23 | 24 | 25 | const pf_enum = { 26 | zero: 'LV_I18N_PLURAL_TYPE_ZERO', 27 | one: 'LV_I18N_PLURAL_TYPE_ONE', 28 | two: 'LV_I18N_PLURAL_TYPE_TWO', 29 | few: 'LV_I18N_PLURAL_TYPE_FEW', 30 | many: 'LV_I18N_PLURAL_TYPE_MANY', 31 | other: 'LV_I18N_PLURAL_TYPE_OTHER' 32 | }; 33 | 34 | function toSingleRule(str) { 35 | // replace A or B or C => (A) or (B) or (C), 36 | // to suppress -Werror=parentheses warnings 37 | str = str.split('or').map(s => `(${s.trim()})`).join(' || '); 38 | 39 | return str 40 | // replace modulus with shortcuts 41 | .replace(/([nivwfte]) % (\d+)/g, '$1$2') 42 | // replace ranges 43 | .replace(/([nivwfte]\d*) (=|\!=) (\d+[.,][.,\d]+)/g, function (match, v, cond, range) { 44 | // range = 5,8,9 (simple set) 45 | if (range.indexOf('..') < 0 && range.indexOf(',') >= 0) { 46 | if (cond === '=') { 47 | return `(${range.split(',').map(r => `(${v} == ${r})`).join(' || ')})`; 48 | } 49 | return `(!(${range.split(',').map(r => `(${v} == ${r})`).join(' || ')}))`; 50 | } 51 | // range = 0..5 or 0..5,8..20 or 0..5,8 52 | var conditions = range.split(',').map(function (interval) { 53 | // simple value 54 | if (interval.indexOf('..') < 0) { 55 | return `(${v} ${cond} ${interval})`; 56 | } 57 | // range 58 | var start = interval.split('..')[0], 59 | end = interval.split('..')[1]; 60 | if (cond === '=') { 61 | return `(${start} <= ${v} && ${v} <= ${end})`; 62 | } 63 | return `(!(${start} <= ${v} && ${v} <= ${end}))`; 64 | }); 65 | 66 | var joined; 67 | if (conditions.length > 1) { 68 | joined = '(' + conditions.join(cond === '=' ? ' || ' : ' && ') + ')'; 69 | } else { 70 | joined = conditions[0]; 71 | } 72 | return joined; 73 | }) 74 | .replace(/ = /g, ' == ') 75 | //.replace(/ != /g, ' !== ') 76 | .replace(/ or /g, ' || ') 77 | .replace(/ and /g, ' && '); 78 | } 79 | 80 | module.exports.create_c_plural_fn = function (locale, fn_name) { 81 | let cldr_rules = renameKeys(cardinals[locale.toLowerCase().split('-')[0]]); 82 | 83 | let conditions = {}; 84 | 85 | Object.entries(cldr_rules).forEach(([ form, rule ]) => { 86 | if (form === 'other') return; 87 | 88 | conditions[form] = toSingleRule(rule.split('@')[0].trim()); 89 | }); 90 | 91 | let operands = [ ...new Set(Object.values(conditions).join(' ').match(/[nivwfte]/g) || []) ]; // unique 92 | 93 | let shortcuts = [ ...new Set(Object.values(conditions).join(' ').match(/[nivwfte]\d+/g) || []) ]; 94 | /*eslint-disable max-len*/ 95 | return ` 96 | static uint8_t ${fn_name}(int32_t num) 97 | {${operands.length ? '\n uint32_t n = op_n(num); UNUSED(n);' : ''} 98 | ${operands.map(op => (op !== 'n' ? ` uint32_t ${op} = op_${op}(n); UNUSED(${op});` : '')).filter(Boolean).join('\n')} 99 | ${shortcuts.map(sh => ` uint32_t ${sh} = ${sh[0]} % ${sh.slice(1)};`).join('\n')} 100 | ${Object.entries(conditions).map(([ form, cond ]) => ` if (${cond}) return ${pf_enum[form]};`).join('\n')} 101 | return ${pf_enum.other}; 102 | } 103 | `.trim(); 104 | }; 105 | -------------------------------------------------------------------------------- /lib/source_keys.js: -------------------------------------------------------------------------------- 1 | // Stuff to operate with source (C/CPP) files 2 | // 3 | 'use strict'; 4 | 5 | 6 | const glob = require('glob').sync; 7 | const debug = require('debug')('sourcee_keys'); 8 | const AppError = require('./app_error'); 9 | const parse = require('./parser'); 10 | 11 | const { readFileSync, writeFileSync } = require('fs'); 12 | 13 | 14 | module.exports = class SourceKeys { 15 | constructor() { 16 | this.filesCount = 0; 17 | 18 | this.keys = []; 19 | 20 | // Used to check conflicts, when key is used as both singular and plural 21 | // Contain first entry of key. 22 | this.uniques = {}; 23 | } 24 | 25 | addKey(obj) { 26 | let { key, line, plural, fileName } = obj; 27 | 28 | if (!this.uniques.hasOwnProperty(key)) { 29 | this.uniques[key] = obj; 30 | } else if (this.uniques[key].plural !== plural) { 31 | throw new AppError (` 32 | Conflicting key '${key}' - should not be used as singular and plural at once. 33 | 34 | Files: 35 | 36 | ${fileName}, line ${line} 37 | ${this.uniques[key].fileName}, line ${this.uniques[key].line} 38 | `); 39 | } 40 | 41 | this.keys.push(obj); 42 | } 43 | 44 | // convenient for testing, to inline content 45 | loadText(text, fileName) { 46 | debug(`Load: ${fileName}`); 47 | 48 | let result = parse(text).map(k => Object.assign(k, { fileName })); 49 | 50 | result.forEach(k => this.addKey(k)); 51 | 52 | this.filesCount++; 53 | } 54 | 55 | loadFile(name) { 56 | this.loadText(readFileSync(name, 'utf8'), name); 57 | } 58 | 59 | loadFiles(paths) { 60 | paths.forEach(p => { 61 | glob(p, { nodir: true }).forEach(name => this.loadFile(name)); 62 | }); 63 | } 64 | 65 | dumpSourceRef(filename) { 66 | let result = []; 67 | 68 | Object.values(this.uniques).forEach(k1 => { 69 | Object.values(this.keys).forEach(k2 => { 70 | if (k1.key === k2.key) { 71 | result.push(k2); 72 | } 73 | }); 74 | }); 75 | 76 | writeFileSync(filename, JSON.stringify(result, null, 2)); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /lib/translation_keys.js: -------------------------------------------------------------------------------- 1 | // Stuff to operate with translation (YAML) files 2 | // 3 | 'use strict'; 4 | 5 | 6 | const glob = require('glob').sync; 7 | const yaml = require('js-yaml'); 8 | const debug = require('debug')('translate_keys'); 9 | const AppError = require('./app_error'); 10 | 11 | const { getPluralKeys } = require('./plurals'); 12 | const { readFileSync, writeFileSync } = require('fs'); 13 | 14 | 15 | function isValidSingularValue(val) { 16 | return val === null || typeof val === 'string'; 17 | } 18 | 19 | function normalize_locale(l) { 20 | return l.toLowerCase().replace(/_/g, '-'); 21 | } 22 | 23 | 24 | module.exports = class TranslationKeys { 25 | constructor() { 26 | this.filesCount = 0; 27 | // First occurence of locales, used to guess where to add 28 | // new phrases 29 | this.localeDefaultFile = {}; 30 | 31 | this.phrases = []; 32 | } 33 | 34 | addPhrase(obj) { 35 | let { locale, key, value, fileName } = obj; 36 | // value can be: 37 | // - null (empty) 38 | // - string 39 | // - object (plural) 40 | if (!isValidSingularValue(value) && (value?.constructor !== Object)) { 41 | throw new AppError(` 42 | Error in ${fileName} 43 | Wrong value for '${key}', should be string, plural object or null ('~') 44 | `); 45 | } 46 | 47 | // Additional check for plurals 48 | if (value?.constructor === Object) { 49 | let validKeys = getPluralKeys(locale); 50 | Object.keys(value).forEach(k => { 51 | if (!validKeys.includes(k)) { 52 | throw new AppError(` 53 | Error in ${fileName} 54 | Bad plural key name '${k}' in '${key}' 55 | Allowed values are: ${validKeys.join(', ')} 56 | `); 57 | } 58 | 59 | if (!isValidSingularValue(value[k])) { 60 | throw new AppError(` 61 | Error in ${fileName} 62 | Bad plural value for '${k}' in '${key}', should be string or null ('~') 63 | `); 64 | } 65 | }); 66 | } 67 | 68 | this.phrases.push({ 69 | locale, 70 | key, 71 | value, 72 | fileName 73 | }); 74 | } 75 | 76 | getPhraseObj(locale, key) { 77 | return this.phrases.find(p => p.locale === locale && p.key === key); 78 | } 79 | 80 | removePhraseObj(locale, key) { 81 | this.phrases = this.phrases.filter(p => !(p.locale === locale && p.key === key)); 82 | } 83 | 84 | // convenient for testing, to inline content 85 | loadText(text, fileName) { 86 | debug(`Load: ${fileName}`); 87 | 88 | let obj = yaml.load(text, { filename: fileName }); 89 | 90 | if (typeof obj === 'undefined') { 91 | throw new AppError(` 92 | Error in ${fileName} 93 | Empty file, should contain locale name entry at least: 94 | 95 | en-GB: {} 96 | `); 97 | } 98 | 99 | if (obj?.constructor !== Object) { 100 | throw new AppError(` 101 | Error in ${fileName} 102 | Can not recognize content. Should be locale with phrase keys (or empty locale): 103 | 104 | en-GB: {} 105 | 106 | ru-RU: 107 | foo: bar 108 | `); 109 | } 110 | 111 | if (!Object.keys(obj).length) { 112 | throw new AppError(` 113 | Error in ${fileName} 114 | No locales found, should have at least one 115 | `); 116 | } 117 | 118 | // Validate locales name 119 | Object.keys(obj).forEach(locale => { 120 | if (!/^[a-zA-Z]+([-_][a-zA-Z]+)*$/.test(locale)) { 121 | throw new AppError(` 122 | Error in ${fileName} 123 | Bad locale name '${locale}'. Only english letters, '-' and '_' allowed: 124 | 125 | en-GB, ru-RU, en 126 | `); 127 | } 128 | }); 129 | 130 | // scan locales data 131 | Object.entries(obj).forEach(([ locale, content ]) => { 132 | debug(`Scan locale ${locale}`); 133 | 134 | Object.keys(this.localeDefaultFile).forEach(l => { 135 | if ((normalize_locale(l) === normalize_locale(locale)) && (l !== locale)) { 136 | throw new AppError(` 137 | Error in ${fileName} 138 | Locale '${locale}' was already defined as '${l}' in ${this.localeDefaultFile[l]}. 139 | 140 | You should use the same name everywhere. 141 | `); 142 | } 143 | }); 144 | 145 | 146 | // Store default file name for locale 147 | if (!this.localeDefaultFile.hasOwnProperty(locale)) { 148 | this.localeDefaultFile[locale] = fileName; 149 | } 150 | 151 | // Workaround for special case - empty file with `en-GB:` created manually 152 | if (content === null) content = {}; 153 | 154 | if (content?.constructor !== Object) { 155 | throw new AppError(` 156 | Error in ${fileName} 157 | Locale '${locale}' content should be an object 158 | `); 159 | } 160 | 161 | // 162 | // load phrases 163 | // 164 | Object.entries(content).forEach(([ key, value ]) => { 165 | this.addPhrase({ 166 | locale, 167 | key, 168 | value, 169 | fileName 170 | }); 171 | }); 172 | }); 173 | 174 | this.filesCount++; 175 | } 176 | 177 | loadFile(name) { 178 | this.loadText(readFileSync(name, 'utf8'), name); 179 | } 180 | 181 | loadFiles(paths) { 182 | paths.forEach(p => { 183 | glob(p, { nodir: true }).forEach(name => this.loadFile(name)); 184 | }); 185 | } 186 | 187 | createFilesData() { 188 | let result = {}; 189 | 190 | this.phrases.forEach(({ fileName, locale, key, value }) => { 191 | if (!result[fileName]) result[fileName] = {}; 192 | if (!result[fileName][locale]) result[fileName][locale] = {}; 193 | result[fileName][locale][key] = value; 194 | }); 195 | 196 | return result; 197 | } 198 | 199 | saveFiles() { 200 | let data = this.createFilesData(); 201 | 202 | Object.entries(data).forEach(([ fileName, content ]) => { 203 | writeFileSync(fileName, yaml.dump(content, { 204 | styles: { 205 | '!!null': 'canonical' 206 | } 207 | })); 208 | }); 209 | } 210 | }; 211 | -------------------------------------------------------------------------------- /lv_i18n.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const AppError = require('./lib/app_error'); 6 | 7 | try { 8 | require('./lib/cli').run(); 9 | } catch (err) { 10 | // Try to beaytify normal errors 11 | if (err instanceof AppError) { 12 | /*eslint-disable no-console*/ 13 | console.error(err.message.trim()); 14 | process.exit(1); 15 | } 16 | // rethrow crashes 17 | throw err; 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lv_i18n", 3 | "version": "0.2.1", 4 | "description": "CLI tools for LittlevGL i18n support.", 5 | "keywords": [ 6 | "i18n", 7 | "internationalization" 8 | ], 9 | "repository": "littlevgl/lv_i18n", 10 | "license": "MIT", 11 | "files": [ 12 | "lv_i18n.js", 13 | "lib/", 14 | "src/" 15 | ], 16 | "bin": { 17 | "lv_i18n": "lv_i18n.js" 18 | }, 19 | "scripts": { 20 | "lint": "eslint .", 21 | "test": "npm run lint && nyc mocha --recursive", 22 | "coverage": "npm run test && nyc report --reporter html", 23 | "template_update": "./support/template_update.js", 24 | "shrink-deps": "shx rm -rf node_modules/js-yaml/dist node_modules/lodash/fp/", 25 | "prepublishOnly": "npm run shrink-deps" 26 | }, 27 | "dependencies": { 28 | "argparse": "^2.0.1", 29 | "cldr-core": "^42.0.0", 30 | "debug": "^4.3.4", 31 | "es6-error": "^4.1.1", 32 | "glob": "^8.0.3", 33 | "js-yaml": "^4.1.0", 34 | "shelljs": "^0.8.5" 35 | }, 36 | "bundledDependencies": [ 37 | "argparse", 38 | "cldr-core", 39 | "debug", 40 | "es6-error", 41 | "glob", 42 | "js-yaml", 43 | "shelljs" 44 | ], 45 | "devDependencies": { 46 | "eslint": "^8.28.0", 47 | "mocha": "^10.1.0", 48 | "nyc": "^15.1.0", 49 | "shx": "^0.3.4" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lv_i18n.template.c: -------------------------------------------------------------------------------- 1 | #include "./lv_i18n.h" 2 | 3 | // Internal state 4 | static const lv_i18n_language_pack_t * current_lang_pack; 5 | static const lv_i18n_lang_t * current_lang; 6 | 7 | /*SAMPLE_START*/ 8 | 9 | //////////////////////////////////////////////////////////////////////////////// 10 | // Define plural operands 11 | // http://unicode.org/reports/tr35/tr35-numbers.html#Operands 12 | 13 | // Integer version, simplified 14 | 15 | #define UNUSED(x) (void)(x) 16 | 17 | static inline uint32_t op_n(int32_t val) { return (uint32_t)(val < 0 ? -val : val); } 18 | static inline uint32_t op_i(uint32_t val) { return val; } 19 | // always zero, when decimal part not exists. 20 | static inline uint32_t op_v(uint32_t val) { UNUSED(val); return 0; } 21 | static inline uint32_t op_w(uint32_t val) { UNUSED(val); return 0; } 22 | static inline uint32_t op_f(uint32_t val) { UNUSED(val); return 0; } 23 | static inline uint32_t op_t(uint32_t val) { UNUSED(val); return 0; } 24 | static inline uint32_t op_e(uint32_t val) { UNUSED(val); return 0; } 25 | 26 | static const char * en_gb_singulars[] = { 27 | "english only", // 0="s_en_only" 28 | "s translated", // 1="s_translated" 29 | NULL, // 2="s_untranslated" 30 | }; 31 | 32 | static const char * en_gb_plurals_one[] = { 33 | "I have %d dog", // 0="p_i_have_dogs" 34 | }; 35 | 36 | static const char * en_gb_plurals_other[] = { 37 | "I have %d dogs", // 0="p_i_have_dogs" 38 | }; 39 | 40 | static uint8_t en_gb_plural_fn(int32_t num) 41 | { 42 | uint32_t n = op_n(num); UNUSED(n); 43 | uint32_t i = op_i(n); UNUSED(i); 44 | uint32_t v = op_v(n); UNUSED(v); 45 | 46 | if ((i == 1 && v == 0)) return LV_I18N_PLURAL_TYPE_ONE; 47 | return LV_I18N_PLURAL_TYPE_OTHER; 48 | } 49 | 50 | static const lv_i18n_lang_t en_gb_lang = { 51 | .locale_name = "en-GB", 52 | .singulars = en_gb_singulars, 53 | .plurals[LV_I18N_PLURAL_TYPE_ONE] = en_gb_plurals_one, 54 | .plurals[LV_I18N_PLURAL_TYPE_OTHER] = en_gb_plurals_other, 55 | .locale_plural_fn = en_gb_plural_fn 56 | }; 57 | 58 | static const char * ru_ru_singulars[] = { 59 | NULL, // 0="s_en_only" 60 | "s переведено", // 1="s_translated" 61 | NULL, // 2="s_untranslated" 62 | }; 63 | 64 | static const char * ru_ru_plurals_one[] = { 65 | "У меня %d собакен", // 0="p_i_have_dogs" 66 | }; 67 | 68 | static const char * ru_ru_plurals_few[] = { 69 | "У меня %d собакена", // 0="p_i_have_dogs" 70 | }; 71 | 72 | static const char * ru_ru_plurals_many[] = { 73 | "У меня %d собакенов", // 0="p_i_have_dogs" 74 | }; 75 | 76 | static uint8_t ru_ru_plural_fn(int32_t num) 77 | { 78 | uint32_t n = op_n(num); UNUSED(n); 79 | uint32_t v = op_v(n); UNUSED(v); 80 | uint32_t i = op_i(n); UNUSED(i); 81 | uint32_t i10 = i % 10; 82 | uint32_t i100 = i % 100; 83 | if ((v == 0 && i10 == 1 && i100 != 11)) return LV_I18N_PLURAL_TYPE_ONE; 84 | if ((v == 0 && (2 <= i10 && i10 <= 4) && (!(12 <= i100 && i100 <= 14)))) return LV_I18N_PLURAL_TYPE_FEW; 85 | if ((v == 0 && i10 == 0) || (v == 0 && (5 <= i10 && i10 <= 9)) || (v == 0 && (11 <= i100 && i100 <= 14))) return LV_I18N_PLURAL_TYPE_MANY; 86 | return LV_I18N_PLURAL_TYPE_OTHER; 87 | } 88 | 89 | static const lv_i18n_lang_t ru_ru_lang = { 90 | .locale_name = "ru-RU", 91 | .singulars = ru_ru_singulars, 92 | .plurals[LV_I18N_PLURAL_TYPE_ONE] = ru_ru_plurals_one, 93 | .plurals[LV_I18N_PLURAL_TYPE_FEW] = ru_ru_plurals_few, 94 | .plurals[LV_I18N_PLURAL_TYPE_MANY] = ru_ru_plurals_many, 95 | .locale_plural_fn = ru_ru_plural_fn 96 | }; 97 | 98 | static uint8_t de_de_plural_fn(int32_t num) 99 | { 100 | uint32_t n = op_n(num); UNUSED(n); 101 | uint32_t i = op_i(n); UNUSED(i); 102 | uint32_t v = op_v(n); UNUSED(v); 103 | 104 | if ((i == 1 && v == 0)) return LV_I18N_PLURAL_TYPE_ONE; 105 | return LV_I18N_PLURAL_TYPE_OTHER; 106 | } 107 | 108 | static const lv_i18n_lang_t de_de_lang = { 109 | .locale_name = "de-DE", 110 | 111 | 112 | .locale_plural_fn = de_de_plural_fn 113 | }; 114 | 115 | const lv_i18n_language_pack_t lv_i18n_language_pack[] = { 116 | &en_gb_lang, 117 | &ru_ru_lang, 118 | &de_de_lang, 119 | NULL // End mark 120 | }; 121 | 122 | #ifndef LV_I18N_OPTIMIZE 123 | 124 | static const char * singular_idx[] = { 125 | "s_en_only", 126 | "s_translated", 127 | "s_untranslated", 128 | 129 | }; 130 | 131 | static const char * plural_idx[] = { 132 | "p_i_have_dogs", 133 | 134 | }; 135 | 136 | #endif 137 | 138 | 139 | /*SAMPLE_END*/ 140 | 141 | /** 142 | * Get the translation from a message ID 143 | * @param msg_id message ID 144 | * @param msg_index the index of the msg_id 145 | * @return the translation of `msg_id` on the set local 146 | */ 147 | const char * lv_i18n_get_singular_by_idx(const char *msg_id, int msg_index) 148 | { 149 | if(current_lang == NULL || msg_index == LV_I18N_ID_NOT_FOUND) return msg_id; 150 | 151 | const lv_i18n_lang_t * lang = current_lang; 152 | const char * txt; 153 | 154 | // Search in current locale 155 | if(lang->singulars != NULL) { 156 | txt = lang->singulars[msg_index]; 157 | if (txt != NULL) return txt; 158 | } 159 | 160 | // Try to fallback 161 | if(lang == current_lang_pack[0]) return msg_id; 162 | lang = current_lang_pack[0]; 163 | 164 | // Repeat search for default locale 165 | if(lang->singulars != NULL) { 166 | txt = lang->singulars[msg_index]; 167 | if (txt != NULL) return txt; 168 | } 169 | 170 | return msg_id; 171 | } 172 | 173 | /** 174 | * Get the translation from a message ID and apply the language's plural rule to get correct form 175 | * @param msg_id message ID 176 | * @param msg_index the index of the msg_id 177 | * @param num an integer to select the correct plural form 178 | * @return the translation of `msg_id` on the set local 179 | */ 180 | const char * lv_i18n_get_plural_by_idx(const char * msg_id, int msg_index, int32_t num) 181 | { 182 | if(current_lang == NULL || msg_index == LV_I18N_ID_NOT_FOUND) return msg_id; 183 | 184 | const lv_i18n_lang_t * lang = current_lang; 185 | const char * txt; 186 | lv_i18n_plural_type_t ptype; 187 | 188 | // Search in current locale 189 | if(lang->locale_plural_fn != NULL) { 190 | ptype = lang->locale_plural_fn(num); 191 | 192 | if(lang->plurals[ptype] != NULL) { 193 | txt = lang->plurals[ptype][msg_index]; 194 | if (txt != NULL) return txt; 195 | } 196 | } 197 | 198 | // Try to fallback 199 | if(lang == current_lang_pack[0]) return msg_id; 200 | lang = current_lang_pack[0]; 201 | 202 | // Repeat search for default locale 203 | if(lang->locale_plural_fn != NULL) { 204 | ptype = lang->locale_plural_fn(num); 205 | 206 | if(lang->plurals[ptype] != NULL) { 207 | txt = lang->plurals[ptype][msg_index]; 208 | if (txt != NULL) return txt; 209 | } 210 | } 211 | 212 | return msg_id; 213 | } 214 | 215 | #ifdef LV_I18N_OPTIMIZE 216 | // Modern compilers calculate phrase IDs at compile time 217 | 218 | #else 219 | // Fallback for ancient compilers, search phrase IDs in runtime (slow) 220 | 221 | static int __lv_i18n_get_id(const char * phrase, const char * * list, int len) 222 | { 223 | uint16_t i; 224 | for(i = 0; i < len; i++) { 225 | if(strcmp(list[i], phrase) == 0) return i; 226 | } 227 | return LV_I18N_ID_NOT_FOUND; 228 | } 229 | 230 | int lv_i18n_get_singular_id(const char * phrase) 231 | { 232 | return __lv_i18n_get_id(phrase, singular_idx, sizeof(singular_idx) / sizeof(singular_idx[0])); 233 | } 234 | 235 | int lv_i18n_get_plural_id(const char * phrase) 236 | { 237 | return __lv_i18n_get_id(phrase, plural_idx, sizeof(plural_idx) / sizeof(plural_idx[0])); 238 | } 239 | 240 | #endif 241 | 242 | 243 | //////////////////////////////////////////////////////////////////////////////// 244 | 245 | 246 | 247 | /** 248 | * Reset internal state. For testing. 249 | */ 250 | void __lv_i18n_reset(void) 251 | { 252 | current_lang_pack = NULL; 253 | current_lang = NULL; 254 | } 255 | 256 | /** 257 | * Set the languages for internationalization 258 | * @param langs pointer to the array of languages. (Last element has to be `NULL`) 259 | */ 260 | int lv_i18n_init(const lv_i18n_language_pack_t * langs) 261 | { 262 | if(langs == NULL) return -1; 263 | if(langs[0] == NULL) return -1; 264 | 265 | current_lang_pack = langs; 266 | current_lang = langs[0]; /*Automatically select the first language*/ 267 | return 0; 268 | } 269 | 270 | /** 271 | * Sugar for simplified `lv_i18n_init` call 272 | */ 273 | int lv_i18n_init_default(void) 274 | { 275 | return lv_i18n_init(lv_i18n_language_pack); 276 | } 277 | 278 | /** 279 | * Change the localization (language) 280 | * @param l_name name of the translation locale to use. E.g. "en-GB" 281 | */ 282 | int lv_i18n_set_locale(const char * l_name) 283 | { 284 | if(current_lang_pack == NULL) return -1; 285 | 286 | uint16_t i; 287 | 288 | for(i = 0; current_lang_pack[i] != NULL; i++) { 289 | // Found -> finish 290 | if(strcmp(current_lang_pack[i]->locale_name, l_name) == 0) { 291 | current_lang = current_lang_pack[i]; 292 | return 0; 293 | } 294 | } 295 | 296 | return -1; 297 | } 298 | 299 | /** 300 | * Get the name of the currently used locale. 301 | * @return name of the currently used locale. E.g. "en-GB" 302 | */ 303 | const char * lv_i18n_get_current_locale(void) 304 | { 305 | if(!current_lang) return NULL; 306 | return current_lang->locale_name; 307 | } 308 | -------------------------------------------------------------------------------- /src/lv_i18n.template.h: -------------------------------------------------------------------------------- 1 | #ifndef LV_I18N_H 2 | #define LV_I18N_H 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | #include 9 | #include 10 | 11 | //////////////////////////////////////////////////////////////////////////////// 12 | 13 | typedef enum { 14 | LV_I18N_PLURAL_TYPE_ZERO, 15 | LV_I18N_PLURAL_TYPE_ONE, 16 | LV_I18N_PLURAL_TYPE_TWO, 17 | LV_I18N_PLURAL_TYPE_FEW, 18 | LV_I18N_PLURAL_TYPE_MANY, 19 | LV_I18N_PLURAL_TYPE_OTHER, 20 | _LV_I18N_PLURAL_TYPE_NUM, 21 | } lv_i18n_plural_type_t; 22 | 23 | typedef struct { 24 | const char * locale_name; 25 | const char * * singulars; 26 | const char * * plurals[_LV_I18N_PLURAL_TYPE_NUM]; 27 | uint8_t (*locale_plural_fn)(int32_t num); 28 | } lv_i18n_lang_t; 29 | 30 | #define LV_I18N_ID_NOT_FOUND 0xFFFF 31 | 32 | // Null-terminated list of languages. First one used as default. 33 | typedef const lv_i18n_lang_t * lv_i18n_language_pack_t; 34 | 35 | extern const lv_i18n_language_pack_t lv_i18n_language_pack[]; 36 | 37 | /*SAMPLE_START*/ 38 | #undef LV_I18N_OPTIMIZE 39 | #define LV_I18N_IDX_s(str) (!strcmp(str, "s_en_only")?0:(!strcmp(str, "s_translated")?1:(!strcmp(str, "s_untranslated")?2:LV_I18N_ID_NOT_FOUND))) 40 | #define LV_I18N_IDX_p(str) (!strcmp(str, "p_i_have_dogs")?0:LV_I18N_ID_NOT_FOUND) 41 | 42 | /*SAMPLE_END*/ 43 | 44 | /** 45 | * Get the translation from a message ID 46 | * @param msg_id message ID 47 | * @param msg_index the index of the msg_id 48 | * @return the translation of `msg_id` on the set local 49 | */ 50 | const char * lv_i18n_get_singular_by_idx(const char * msg_id, int msg_index); 51 | 52 | /** 53 | * Get the translation from a message ID and apply the language's plural rule to get correct form 54 | * @param msg_id message ID 55 | * @param msg_index the index of the msg_id 56 | * @param num an integer to select the correct plural form 57 | * @return the translation of `msg_id` on the set local 58 | */ 59 | const char * lv_i18n_get_plural_by_idx(const char * msg_id, int msg_index, int32_t num); 60 | 61 | #ifdef LV_I18N_OPTIMIZE 62 | 63 | #define _(text) lv_i18n_get_singular_by_idx(text, LV_I18N_IDX_s(text)) 64 | #define _p(text, num) lv_i18n_get_plural_by_idx(text, LV_I18N_IDX_p(text), num) 65 | 66 | #else 67 | 68 | int lv_i18n_get_singular_id(const char * phrase); 69 | int lv_i18n_get_plural_id(const char * phrase); 70 | 71 | #define _(text) lv_i18n_get_singular_by_idx(text, lv_i18n_get_singular_id(text)) 72 | #define _p(text, num) lv_i18n_get_plural_by_idx(text, lv_i18n_get_plural_id(text), num) 73 | 74 | #endif 75 | 76 | 77 | /** 78 | * Set the languages for internationalization 79 | * @param langs pointer to the array of languages. (Last element has to be `NULL`) 80 | */ 81 | int lv_i18n_init(const lv_i18n_language_pack_t * langs); 82 | 83 | /** 84 | * Sugar for simplified `lv_i18n_init` call 85 | */ 86 | int lv_i18n_init_default(void); 87 | 88 | /** 89 | * Change the localization (language) 90 | * @param l_name name of the translation locale to use. E.g. "en-GB" 91 | */ 92 | int lv_i18n_set_locale(const char * l_name); 93 | 94 | /** 95 | * Get the name of the currently used locale. 96 | * @return name of the currently used locale. E.g. "en-GB" 97 | */ 98 | const char * lv_i18n_get_current_locale(void); 99 | 100 | 101 | void __lv_i18n_reset(void); 102 | 103 | #ifdef __cplusplus 104 | } /* extern "C" */ 105 | #endif 106 | 107 | #endif /*LV_LANG_H*/ 108 | -------------------------------------------------------------------------------- /support/template_data.yml: -------------------------------------------------------------------------------- 1 | # Demo data for template & C code testing 2 | 3 | en-GB: 4 | s_untranslated: ~ 5 | s_en_only: english only 6 | s_translated: s translated 7 | p_i_have_dogs: 8 | one: I have %d dog 9 | other: I have %d dogs 10 | 11 | ru-RU: 12 | s_untranslated: ~ 13 | s_en_only: ~ 14 | s_translated: s переведено 15 | p_i_have_dogs: 16 | one: У меня %d собакен 17 | few: У меня %d собакена 18 | many: У меня %d собакенов 19 | other: ~ 20 | 21 | # Empty locale to test missed things lookup 22 | de-DE: {} 23 | -------------------------------------------------------------------------------- /support/template_update.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | 6 | const shell = require('shelljs'); 7 | const fs = require('fs'); 8 | const { join, dirname, basename, extname } = require('path'); 9 | const { run } = require('../lib/cli'); 10 | 11 | 12 | const tmp_file = join(__dirname, 'raw.tmp'); 13 | const tmp_file_h = join(dirname(tmp_file), basename(tmp_file, extname(tmp_file)) + '.h'); 14 | const template_c = join(__dirname, '../src/lv_i18n.template.c'); 15 | const template_h = join(__dirname, '../src/lv_i18n.template.h'); 16 | const template_yaml = join(__dirname, 'template_data.yml'); 17 | 18 | run([ 'compile', '-t', template_yaml, '--raw', tmp_file ]); 19 | 20 | let txt_raw = fs.readFileSync(tmp_file, 'utf-8'); 21 | let txt = fs.readFileSync(template_c, 'utf-8'); 22 | 23 | txt = txt.replace(/\/\*SAMPLE_START\*\/([\s\S]+)\/\*SAMPLE_END\*\//, 24 | `/*SAMPLE_START*/ 25 | ${txt_raw} 26 | /*SAMPLE_END*/`); 27 | 28 | //fs.writeFileSync(tmp_file + '.c', txt); 29 | fs.writeFileSync(template_c, txt); 30 | 31 | txt_raw = fs.readFileSync(tmp_file_h, 'utf-8'); 32 | txt = fs.readFileSync(template_h, 'utf-8'); 33 | 34 | txt = txt.replace(/\/\*SAMPLE_START\*\/([\s\S]+)\/\*SAMPLE_END\*\//, 35 | `/*SAMPLE_START*/ 36 | ${txt_raw} 37 | /*SAMPLE_END*/`); 38 | 39 | fs.writeFileSync(template_h, txt); 40 | 41 | shell.rm('-rf', tmp_file, tmp_file_h); 42 | -------------------------------------------------------------------------------- /test/c/.gitignore: -------------------------------------------------------------------------------- 1 | unity/ 2 | build/ 3 | *.gcno 4 | *.gcda 5 | -------------------------------------------------------------------------------- /test/c/Makefile: -------------------------------------------------------------------------------- 1 | CC = gcc 2 | ifeq ($(shell uname -s), Darwin) 3 | CC = clang 4 | endif 5 | ifeq ($(findstring clang, $(CC)), clang) 6 | E = -Weverything 7 | CFLAGS += $E -Wno-unknown-warning-option -Wno-missing-prototypes 8 | CFLAGS += -Wno-unused-macros -Wno-padded -Wno-missing-noreturn 9 | endif 10 | CFLAGS += -std=c99 -pedantic -Wall -Wextra -Wconversion -Werror 11 | CFLAGS += -Wno-switch-enum -Wno-double-promotion 12 | CFLAGS += -Wbad-function-cast -Wcast-qual -Wold-style-definition -Wshadow -Wstrict-overflow \ 13 | -Wstrict-prototypes -Wswitch-default -Wundef 14 | #DEBUG = -O0 -g 15 | CFLAGS += $(DEBUG) 16 | SRC = unity/src/unity.c ../../src/lv_i18n.template.c test.c 17 | INC_DIR = -I unity/src -I $(BUILD_DIR) 18 | COV_FLAGS = -fprofile-arcs -ftest-coverage 19 | BUILD_DIR = build 20 | TARGET = build/test.exe 21 | 22 | default: test 23 | .PHONY: default test-coverage test test-deps clean 24 | 25 | test: test_optimized 26 | mkdir -p $(BUILD_DIR) 27 | ../../lv_i18n.js compile -t ../../support/template_data.yml -o $(BUILD_DIR) -l en-GB 28 | $(CC) $(CFLAGS) $(DEFINES) $(INC_DIR) $(SRC) -o $(TARGET) 29 | ./$(TARGET) 30 | 31 | test-coverage: 32 | mkdir -p $(BUILD_DIR) 33 | $(CC) $(CFLAGS) $(DEFINES) $(INC_DIR) $(SRC) $(COV_FLAGS) -o $(TARGET) 34 | ./$(TARGET) 35 | rm -rf coverage 36 | mkdir -p coverage 37 | ~/.local/bin/gcovr --html-details --filter '../../src' -o coverage/index.html 38 | rm -rf *.gc* 39 | 40 | test-deps: 41 | mkdir unity 42 | cd ./unity; \ 43 | git init; \ 44 | git remote add origin https://github.com/ThrowTheSwitch/Unity.git; \ 45 | git fetch origin; \ 46 | git checkout v2.4.3 -- src 47 | # This fails in Travis, install manually 48 | #pip install gcovr 49 | 50 | clean: 51 | rm -f $(TARGET) $(BUILD_DIR)/*.gc* $(BUILD_DIR)/lv_i18n.h $(BUILD_DIR)/lv_i18n.c 52 | 53 | test: 54 | 55 | test_optimized: 56 | mkdir -p $(BUILD_DIR) 57 | ../../lv_i18n.js compile -t ../../support/template_data.yml --optimize -o $(BUILD_DIR) -l en-GB 58 | $(CC) $(CFLAGS) $(DEFINES) $(INC_DIR) unity/src/unity.c build/lv_i18n.c test.c -o $(TARGET) 59 | ./$(TARGET) 60 | 61 | 62 | c: 63 | $(CC) $(CFLAGS) $(DEFINES) -I combined-sample-2 $(INC_DIR) unity/src/unity.c combined-sample-2/lv_i18n.c test.c -o $(TARGET) 64 | ./$(TARGET) 65 | $(CC) $(CFLAGS) -DLV_I18N_OPTIMIZE $(DEFINES) -I combined-sample-2 $(INC_DIR) unity/src/unity.c combined-sample-2/lv_i18n.c test.c -o $(TARGET) 66 | ./$(TARGET) 67 | 68 | -------------------------------------------------------------------------------- /test/c/test.c: -------------------------------------------------------------------------------- 1 | #include "unity.h" 2 | #include 3 | #include "lv_i18n.h" 4 | 5 | //////////////////////////////////////////////////////////////////////////////// 6 | 7 | void test_init_should_ignore_NULL_input(void) 8 | { 9 | __lv_i18n_reset(); 10 | 11 | TEST_ASSERT_EQUAL(lv_i18n_init(NULL), -1); 12 | TEST_ASSERT_NULL(lv_i18n_get_current_locale()); 13 | } 14 | 15 | void test_init_should_ignore_empty_language_pack(void) 16 | { 17 | __lv_i18n_reset(); 18 | 19 | const lv_i18n_lang_t * empty_language_pack[] = { NULL }; 20 | 21 | TEST_ASSERT_EQUAL(lv_i18n_init(empty_language_pack), -1); 22 | TEST_ASSERT_NULL(lv_i18n_get_current_locale()); 23 | } 24 | 25 | void test_init_should_work(void) 26 | { 27 | __lv_i18n_reset(); 28 | 29 | TEST_ASSERT_EQUAL(lv_i18n_init(lv_i18n_language_pack), 0); 30 | TEST_ASSERT_EQUAL_STRING(lv_i18n_get_current_locale(), "en-GB"); 31 | } 32 | 33 | //////////////////////////////////////////////////////////////////////////////// 34 | 35 | void test_set_locale_should_fail_before_init(void) 36 | { 37 | __lv_i18n_reset(); 38 | 39 | TEST_ASSERT_EQUAL(lv_i18n_set_locale("ru-RU"), -1); 40 | TEST_ASSERT_NULL(lv_i18n_get_current_locale()); 41 | } 42 | 43 | void test_set_locale_should_work(void) 44 | { 45 | lv_i18n_init(lv_i18n_language_pack); 46 | 47 | TEST_ASSERT_EQUAL(lv_i18n_set_locale("ru-RU"), 0); 48 | TEST_ASSERT_EQUAL_STRING(lv_i18n_get_current_locale(), "ru-RU"); 49 | } 50 | 51 | void test_set_locale_should_fail_not_existing(void) 52 | { 53 | lv_i18n_init(lv_i18n_language_pack); 54 | 55 | TEST_ASSERT_EQUAL(lv_i18n_set_locale("invalid"), -1); 56 | TEST_ASSERT_EQUAL_STRING(lv_i18n_get_current_locale(), "en-GB"); 57 | } 58 | 59 | //////////////////////////////////////////////////////////////////////////////// 60 | 61 | void test_get_text_should_work(void) 62 | { 63 | lv_i18n_init(lv_i18n_language_pack); 64 | 65 | TEST_ASSERT_EQUAL_STRING(_("s_translated"), "s translated"); 66 | lv_i18n_set_locale("ru-RU"); 67 | TEST_ASSERT_EQUAL_STRING(_("s_translated"), "s переведено"); 68 | } 69 | 70 | void test_get_text_should_fallback_to_base(void) 71 | { 72 | lv_i18n_init(lv_i18n_language_pack); 73 | 74 | TEST_ASSERT_EQUAL_STRING(_("s_en_only"), "english only"); 75 | lv_i18n_set_locale("ru-RU"); 76 | TEST_ASSERT_EQUAL_STRING(_("s_en_only"), "english only"); 77 | } 78 | 79 | void test_get_text_should_fallback_to_orig(void) 80 | { 81 | lv_i18n_init(lv_i18n_language_pack); 82 | 83 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 84 | lv_i18n_set_locale("ru-RU"); 85 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 86 | } 87 | 88 | //////////////////////////////////////////////////////////////////////////////// 89 | 90 | void test_get_text_plural_should_work(void) 91 | { 92 | lv_i18n_init(lv_i18n_language_pack); 93 | 94 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 1), "I have %d dog"); 95 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 2), "I have %d dogs"); 96 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 5), "I have %d dogs"); 97 | lv_i18n_set_locale("ru-RU"); 98 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 1), "У меня %d собакен"); 99 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 2), "У меня %d собакена"); 100 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 5), "У меня %d собакенов"); 101 | } 102 | 103 | void test_get_text_plural_should_fallback_to_base(void) 104 | { 105 | lv_i18n_init(lv_i18n_language_pack); 106 | 107 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 1), "I have %d dog"); 108 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 2), "I have %d dogs"); 109 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 5), "I have %d dogs"); 110 | lv_i18n_set_locale("de-DE"); 111 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 1), "I have %d dog"); 112 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 2), "I have %d dogs"); 113 | TEST_ASSERT_EQUAL_STRING(_p("p_i_have_dogs", 5), "I have %d dogs"); 114 | } 115 | 116 | void test_get_text_plural_should_fallback_to_orig(void) 117 | { 118 | lv_i18n_init(lv_i18n_language_pack); 119 | 120 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 1), "not_existing"); 121 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 2), "not_existing"); 122 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 5), "not_existing"); 123 | lv_i18n_set_locale("ru-RU"); 124 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 1), "not_existing"); 125 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 2), "not_existing"); 126 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 5), "not_existing"); 127 | lv_i18n_set_locale("de-DE"); 128 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 1), "not_existing"); 129 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 2), "not_existing"); 130 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 5), "not_existing"); 131 | } 132 | 133 | //////////////////////////////////////////////////////////////////////////////// 134 | 135 | void test_should_fallback_without_langpack(void) 136 | { 137 | __lv_i18n_reset(); 138 | 139 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 140 | TEST_ASSERT_EQUAL_STRING(_p("not_existing", 1), "not_existing"); 141 | } 142 | 143 | 144 | static uint8_t fake_plural_fn(int32_t num __attribute__((unused))) 145 | { 146 | return LV_I18N_PLURAL_TYPE_OTHER; 147 | } 148 | 149 | void test_empty_base_tables_fallback(void) 150 | { 151 | static const lv_i18n_lang_t en_gb_lang = { 152 | .locale_name = "en-GB", 153 | .locale_plural_fn = fake_plural_fn 154 | }; 155 | 156 | static const lv_i18n_lang_t ru_ru_lang = { 157 | .locale_name = "ru-RU", 158 | .locale_plural_fn = fake_plural_fn 159 | }; 160 | 161 | const lv_i18n_language_pack_t fake_language_pack[] = { 162 | &en_gb_lang, 163 | &ru_ru_lang, 164 | NULL 165 | }; 166 | 167 | lv_i18n_init(fake_language_pack); 168 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 169 | lv_i18n_set_locale("ru-RU"); 170 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 171 | 172 | lv_i18n_init(fake_language_pack); 173 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 1), "not existing"); 174 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 2), "not existing"); 175 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 5), "not existing"); 176 | lv_i18n_set_locale("ru-RU"); 177 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 1), "not existing"); 178 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 2), "not existing"); 179 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 5), "not existing"); 180 | } 181 | 182 | 183 | void test_empty_plurals_fallback(void) 184 | { 185 | static const lv_i18n_lang_t en_gb_lang = { 186 | .locale_name = "en-GB", 187 | .locale_plural_fn = NULL 188 | }; 189 | 190 | static const lv_i18n_lang_t ru_ru_lang = { 191 | .locale_name = "ru-RU", 192 | .locale_plural_fn = NULL 193 | }; 194 | 195 | const lv_i18n_language_pack_t fake_language_pack[] = { 196 | &en_gb_lang, 197 | &ru_ru_lang, 198 | NULL 199 | }; 200 | 201 | lv_i18n_init(fake_language_pack); 202 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 203 | lv_i18n_set_locale("ru-RU"); 204 | TEST_ASSERT_EQUAL_STRING(_("not existing"), "not existing"); 205 | 206 | lv_i18n_init(fake_language_pack); 207 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 1), "not existing"); 208 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 2), "not existing"); 209 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 5), "not existing"); 210 | lv_i18n_set_locale("ru-RU"); 211 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 1), "not existing"); 212 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 2), "not existing"); 213 | TEST_ASSERT_EQUAL_STRING(_p("not existing", 5), "not existing"); 214 | } 215 | 216 | void test_empty_content_check(void) 217 | { 218 | static const char * en_gb_singulars[] = { 219 | NULL, // 1=s_en_only 220 | NULL, // 2=s_translated 221 | NULL, // 3="s_untranslated" 222 | }; 223 | 224 | static const lv_i18n_lang_t en_gb_lang = { 225 | .locale_name = "en-GB", 226 | .singulars = en_gb_singulars, 227 | .locale_plural_fn = fake_plural_fn 228 | }; 229 | 230 | const lv_i18n_language_pack_t fake_language_pack[] = { 231 | &en_gb_lang, 232 | NULL 233 | }; 234 | 235 | lv_i18n_init(fake_language_pack); 236 | 237 | TEST_ASSERT_EQUAL_STRING(_("s_empty"), "s_empty"); 238 | } 239 | 240 | 241 | //////////////////////////////////////////////////////////////////////////////// 242 | 243 | 244 | int main(void) 245 | { 246 | UNITY_BEGIN(); 247 | 248 | // lv_i18n_init 249 | RUN_TEST(test_init_should_ignore_NULL_input); 250 | RUN_TEST(test_init_should_ignore_empty_language_pack); 251 | RUN_TEST(test_init_should_work); 252 | 253 | // lv_i18n_set_locale 254 | RUN_TEST(test_set_locale_should_fail_before_init); 255 | RUN_TEST(test_set_locale_should_work); 256 | RUN_TEST(test_set_locale_should_fail_not_existing); 257 | 258 | // lv_i18n_get_text 259 | RUN_TEST(test_get_text_should_work); 260 | RUN_TEST(test_get_text_should_fallback_to_base); 261 | RUN_TEST(test_get_text_should_fallback_to_orig); 262 | 263 | // lv_i18n_get_plural_text 264 | RUN_TEST(test_get_text_plural_should_work); 265 | RUN_TEST(test_get_text_plural_should_fallback_to_base); 266 | RUN_TEST(test_get_text_plural_should_fallback_to_orig); 267 | 268 | // Other 269 | RUN_TEST(test_should_fallback_without_langpack); 270 | RUN_TEST(test_empty_base_tables_fallback); 271 | RUN_TEST(test_empty_plurals_fallback); 272 | RUN_TEST(test_empty_content_check); 273 | 274 | return UNITY_END(); 275 | } 276 | -------------------------------------------------------------------------------- /test/js/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | -------------------------------------------------------------------------------- /test/js/fixtures/broken.yml: -------------------------------------------------------------------------------- 1 | foo 2 | bar 3 | -------------------------------------------------------------------------------- /test/js/fixtures/c_escapes.yml: -------------------------------------------------------------------------------- 1 | - - foo bar 2 | - foo bar 3 | 4 | - - \a\b\e\Q\W 5 | - "\x07\b\x1bQW" 6 | 7 | - - \x23\x3a9 8 | - "#Ω" 9 | 10 | - - \12foo\345 11 | - "\nfooå" 12 | 13 | - - \u12345\u6789 14 | - "\u12345\u6789" 15 | 16 | - - \U00012345 17 | - "\U00012345" 18 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_compile/no_base.yml: -------------------------------------------------------------------------------- 1 | ru-RU: {} 2 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/empty_en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: ~ 2 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/empty_ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: ~ 2 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/mixed_en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | text2: 3 | one: ~ 4 | other: ~ -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/orphaned_en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | orphaned1: orphaned text 1 3 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/orphaned_ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: 2 | orphaned2: orphaned text 2 3 | orphaned3: orphaned text 3 4 | text1: not orphaned 5 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/partial_en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | text2: existing text 3 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/src_1.c: -------------------------------------------------------------------------------- 1 | #define _(x) (x) 2 | 3 | const char* txt1 = _("text1"); 4 | const char* txt2 = _("text2"); 5 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_extract/src_2.c: -------------------------------------------------------------------------------- 1 | #define _(x) (x) 2 | #define _p(x, y) (x) 3 | 4 | const char* txt3 = _p("text3", 5); 5 | const char* txt4 = _("text1"); // Intended duplicate 6 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_rename/en-GB.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | foo: ~ 3 | nail: 4 | one: nail 5 | other: nails 6 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_rename/en-US.yml: -------------------------------------------------------------------------------- 1 | en-US: 2 | # key 'foo' missed, for coverage 3 | nail: 4 | one: nail 5 | other: nails 6 | -------------------------------------------------------------------------------- /test/js/fixtures/cli_rename/ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: 2 | foo: фуу 3 | nail: 4 | one: гвоздь 5 | few: гвоздя 6 | many: гвоздей 7 | -------------------------------------------------------------------------------- /test/js/fixtures/empty_src.c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lvgl/lv_i18n/a09fa0e76d672d079bbc8deabf486e868df8a9ff/test/js/fixtures/empty_src.c -------------------------------------------------------------------------------- /test/js/fixtures/file_load/file_load.c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lvgl/lv_i18n/a09fa0e76d672d079bbc8deabf486e868df8a9ff/test/js/fixtures/file_load/file_load.c -------------------------------------------------------------------------------- /test/js/fixtures/file_load/file_load.h: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lvgl/lv_i18n/a09fa0e76d672d079bbc8deabf486e868df8a9ff/test/js/fixtures/file_load/file_load.h -------------------------------------------------------------------------------- /test/js/fixtures/file_load/file_load.yml: -------------------------------------------------------------------------------- 1 | en-GB: 2 | -------------------------------------------------------------------------------- /test/js/fixtures/newlines/partial_ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: ~ 2 | -------------------------------------------------------------------------------- /test/js/fixtures/newlines/ru-RU.yml: -------------------------------------------------------------------------------- 1 | ru-RU: 2 | "line1\nline2\ttext\nline3": "строка1\nстрока2\tтекст\nстрока3" 3 | -------------------------------------------------------------------------------- /test/js/fixtures/newlines/src.c: -------------------------------------------------------------------------------- 1 | #define _(x) (x) 2 | 3 | const char* txt_escapes = _("line1\nline2\ttext\nline3"); 4 | -------------------------------------------------------------------------------- /test/js/plurajs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const create_plural = require('../../lib/plurals').create_c_plural_fn; 5 | const assert = require('assert'); 6 | 7 | 8 | describe('Plurals', function () { 9 | 10 | it('should not add "uint32_t n = op_n(n); UNUSED(n);"', function () { 11 | let fn_body = create_plural('tr', 'p').toString(); 12 | 13 | assert.ok(!fn_body.includes('uint32_t n = op_n(n); UNUSED(n);')); 14 | }); 15 | 16 | // Just run all branches without result check. Generator code is taken 17 | // from well-tested https://github.com/nodeca/plurals-cldr. So, everything 18 | // should be safe enougth. 19 | it('coverage fix', function () { 20 | create_plural('br', 'p'); 21 | create_plural('bo', 'p'); 22 | create_plural('da', 'p'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/js/test_cli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const { join } = require('path'); 6 | const { execFileSync } = require('child_process'); 7 | 8 | 9 | const bad_yaml_path = join(__dirname, 'fixtures/broken.yml'); 10 | const script_path = join(__dirname, '../../lv_i18n.js'); 11 | 12 | 13 | describe('Script', function () { 14 | 15 | it('Should run', function () { 16 | let out = execFileSync(script_path, [], { stdio: 'pipe' }); 17 | assert.equal(out.toString().substring(0, 5), 'usage'); 18 | }); 19 | 20 | it('Should display crash dump', function () { 21 | assert.throws( 22 | () => execFileSync( 23 | script_path, 24 | [ 'rename', '-t', `${bad_yaml_path}`, '--from', 'foo', '--to', 'bar' ], 25 | { stdio: 'pipe' } 26 | ), 27 | /YAMLException/ 28 | ); 29 | }); 30 | 31 | it('Should display normal errors', function () { 32 | assert.throws( 33 | () => execFileSync( 34 | script_path, 35 | [ 'rename', '-t', 'bad_path', '--from', 'foo', '--to', 'bar' ], 36 | { stdio: 'pipe' } 37 | ), 38 | /Failed to find any translation file/ 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/js/test_cli_compile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const shell = require('shelljs'); 6 | const { join } = require('path'); 7 | const { run } = require('../../lib/cli'); 8 | 9 | const fixtures_src_dir = join(__dirname, 'fixtures/cli_compile'); 10 | const fixtures_tmp_dir = join(__dirname, 'fixtures/cli_compile.tmp'); 11 | const demo_data_path = join(__dirname, '../../support/template_data.yml'); 12 | 13 | 14 | describe('CLI compile', function () { 15 | beforeEach(function () { 16 | shell.rm('-rf', fixtures_tmp_dir); 17 | shell.cp('-R', fixtures_src_dir, fixtures_tmp_dir); 18 | }); 19 | 20 | it('Should compile template data (raw)', function () { 21 | run([ 'compile', '-t', demo_data_path, 22 | '--raw', join(fixtures_tmp_dir, 'out.raw') ]); 23 | 24 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'out.raw'))); 25 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'out.h'))); 26 | }); 27 | 28 | it('Should compile template data (.c/.h)', function () { 29 | run([ 'compile', '-t', demo_data_path, '-o', fixtures_tmp_dir ]); 30 | 31 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'lv_i18n.h'))); 32 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'lv_i18n.c'))); 33 | }); 34 | 35 | it('Should compile optimized (raw)', function () { 36 | run([ 'compile', '-t', demo_data_path, '--optimize', 37 | '--raw', join(fixtures_tmp_dir, 'out.raw') ]); 38 | 39 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'out.raw'))); 40 | assert.ok(shell.test('-f', join(fixtures_tmp_dir, 'out.h'))); 41 | }); 42 | 43 | it('Should fail on missed files', function () { 44 | assert.throws( 45 | () => { 46 | run([ 'compile', '-t', 'bad_path', '--raw', '123' ]); 47 | }, 48 | /Failed to find any translation file/ 49 | ); 50 | }); 51 | 52 | it('Should fail on not specified and not autodetected base locale', function () { 53 | assert.throws( 54 | () => { 55 | run([ 'compile', '-t', join(fixtures_tmp_dir, 'no_base.yml'), '--raw', '123' ]); 56 | }, 57 | /You did not specified locale/ 58 | ); 59 | }); 60 | 61 | it('Should fail on missed base locale', function () { 62 | assert.throws( 63 | () => { 64 | run([ 'compile', '-t', join(fixtures_tmp_dir, 'no_base.yml'), '-l', 'en', '--raw', '123' ]); 65 | }, 66 | /You specified base locale .* but it was not found in loaded translations/ 67 | ); 68 | }); 69 | 70 | it('Should be ok with non-standard locale', function () { 71 | run([ 72 | 'compile', 73 | '-t', join(fixtures_tmp_dir, 'no_base.yml'), 74 | '-l', 'ru-RU', 75 | '--raw', join(fixtures_tmp_dir, '123') 76 | ]); 77 | }); 78 | 79 | 80 | it('Should fail on missed output options', function () { 81 | assert.throws( 82 | () => { 83 | run([ 'compile', '-t', demo_data_path ]); 84 | }, 85 | /You should specify output folder or raw output file option/ 86 | ); 87 | }); 88 | 89 | it('Should fail on missed output dir', function () { 90 | assert.throws( 91 | () => { 92 | run([ 'compile', '-t', demo_data_path, '-o', 'bad_dir' ]); 93 | }, 94 | /Output directory not exists/ 95 | ); 96 | }); 97 | 98 | afterEach(function () { 99 | shell.rm('-rf', fixtures_tmp_dir); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /test/js/test_cli_extract.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const shell = require('shelljs'); 6 | const yaml = require('js-yaml'); 7 | const { join } = require('path'); 8 | const { readFileSync } = require('fs'); 9 | 10 | const { run } = require('../../lib/cli'); 11 | 12 | const fixtures_src_dir = join(__dirname, 'fixtures/cli_extract'); 13 | const fixtures = join(__dirname, 'fixtures/cli_extract.tmp'); 14 | 15 | 16 | describe('CLI extract', function () { 17 | beforeEach(function () { 18 | shell.rm('-rf', fixtures); 19 | shell.cp('-R', fixtures_src_dir, fixtures); 20 | }); 21 | 22 | it('Should fail on missed sources', function () { 23 | assert.throws( 24 | () => { 25 | run([ 'extract', '-s', 'bad_path', '-t', 'anything' ]); 26 | }, 27 | /Failed to find any source file/ 28 | ); 29 | }); 30 | 31 | it('Should fail on missed locales', function () { 32 | assert.throws( 33 | () => { 34 | run([ 'extract', '-s', join(fixtures, 'src_*.c'), '-t', 'bad_path' ]); 35 | }, 36 | /Failed to find any translation file/ 37 | ); 38 | }); 39 | 40 | it('Should fails (with report) on orphaned phrases, --orphaned-fail', function () { 41 | assert.throws( 42 | () => { 43 | run([ 'extract', '-s', join(fixtures, 'src_*.c'), '-t', join(fixtures, 'orphaned_*.yml'), '--orphaned-fail' ]); 44 | }, 45 | /Your translations have orphaned phrases/ 46 | ); 47 | }); 48 | 49 | it('Should not fails on orphaned phrases, without --orphaned-fail', function () { 50 | run([ 'extract', '-s', join(fixtures, 'src_*.c'), '-t', join(fixtures, 'orphaned_*.yml') ]); 51 | }); 52 | 53 | it('Should exit without error if no source phrases exist', function () { 54 | run([ 'extract', '-s', join(__dirname, 'fixtures/empty_src.c'), '-t', 'any-value' ]); 55 | }); 56 | 57 | it('Should exit without error with sourceref', function () { 58 | run([ 'extract', '-s', join(fixtures, 'src_*.c'), '--dump-sourceref', join(fixtures, 'sourceref'), 59 | '-t', join(fixtures, 'empty_en-GB.yml') ]); 60 | }); 61 | 62 | it('Should fill empty locales', function () { 63 | run([ 'extract', '-s', join(fixtures, 'src_*.c'), '-t', join(fixtures, 'empty_*.yml') ]); 64 | 65 | assert.deepStrictEqual( 66 | yaml.load(readFileSync(join(fixtures, 'empty_en-GB.yml'))), 67 | { 68 | 'en-GB': { 69 | text1: null, 70 | text2: null, 71 | text3: { 72 | one: null, 73 | other: null 74 | } 75 | } 76 | } 77 | ); 78 | assert.deepStrictEqual( 79 | yaml.load(readFileSync(join(fixtures, 'empty_ru-RU.yml'))), 80 | { 81 | 'ru-RU': { 82 | text1: null, 83 | text2: null, 84 | text3: { 85 | one: null, 86 | few: null, 87 | many: null, 88 | other: null 89 | } 90 | } 91 | } 92 | ); 93 | }); 94 | 95 | it('Should add missed keys', function () { 96 | run([ 'extract', '-s', join(fixtures, 'src_1.c'), '-t', join(fixtures, 'partial_*.yml') ]); 97 | 98 | assert.deepStrictEqual( 99 | yaml.load(readFileSync(join(fixtures, 'partial_en-GB.yml'))), 100 | { 101 | 'en-GB': { 102 | text1: null, 103 | text2: 'existing text' 104 | } 105 | } 106 | ); 107 | }); 108 | 109 | it('Should fail on singular/plural mix', function () { 110 | assert.throws( 111 | () => { 112 | run([ 'extract', '-s', join(fixtures, 'src_1.c'), '-t', join(fixtures, 'mixed_*.yml') ]); 113 | }, 114 | /mixed singular\/plural/ 115 | ); 116 | }); 117 | 118 | afterEach(function () { 119 | shell.rm('-rf', fixtures); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/js/test_cli_rename.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const shell = require('shelljs'); 6 | const yaml = require('js-yaml'); 7 | const { join } = require('path'); 8 | const { readFileSync } = require('fs'); 9 | 10 | const { run } = require('../../lib/cli'); 11 | 12 | const fixtures_src_dir = join(__dirname, 'fixtures/cli_rename'); 13 | const fixtures_tmp_dir = join(__dirname, 'fixtures/cli_rename.tmp'); 14 | const fixtures_yaml_path = join(fixtures_tmp_dir, '*.yml'); 15 | 16 | 17 | describe('CLI rename', function () { 18 | beforeEach(function () { 19 | shell.rm('-rf', fixtures_tmp_dir); 20 | shell.cp('-R', fixtures_src_dir, fixtures_tmp_dir); 21 | }); 22 | 23 | it('Should rename singulars', function () { 24 | run([ 'rename', '-t', `${fixtures_yaml_path}`, '--from', 'foo', '--to', 'new_foo' ]); 25 | 26 | assert.deepStrictEqual( 27 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'en-GB.yml'))), 28 | { 29 | 'en-GB': { 30 | new_foo: null, 31 | nail: { 32 | one: 'nail', 33 | other: 'nails' 34 | } 35 | } 36 | } 37 | ); 38 | 39 | assert.deepStrictEqual( 40 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'ru-RU.yml'))), 41 | { 42 | 'ru-RU': { 43 | new_foo: 'фуу', 44 | nail: { 45 | one: 'гвоздь', 46 | few: 'гвоздя', 47 | many: 'гвоздей' 48 | } 49 | } 50 | } 51 | ); 52 | }); 53 | 54 | it('Should rename plurals', function () { 55 | run([ 'rename', '-t', `${fixtures_yaml_path}`, '--from', 'nail', '--to', 'new_nail' ]); 56 | 57 | assert.deepStrictEqual( 58 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'en-GB.yml'))), 59 | { 60 | 'en-GB': { 61 | foo: null, 62 | new_nail: { 63 | one: 'nail', 64 | other: 'nails' 65 | } 66 | } 67 | } 68 | ); 69 | 70 | assert.deepStrictEqual( 71 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'ru-RU.yml'))), 72 | { 73 | 'ru-RU': { 74 | foo: 'фуу', 75 | new_nail: { 76 | one: 'гвоздь', 77 | few: 'гвоздя', 78 | many: 'гвоздей' 79 | } 80 | } 81 | } 82 | ); 83 | }); 84 | 85 | it('Should override existing keys', function () { 86 | run([ 'rename', '-t', `${fixtures_yaml_path}`, '--from', 'nail', '--to', 'foo' ]); 87 | 88 | assert.deepStrictEqual( 89 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'en-GB.yml'))), 90 | { 91 | 'en-GB': { 92 | foo: { 93 | one: 'nail', 94 | other: 'nails' 95 | } 96 | } 97 | } 98 | ); 99 | 100 | assert.deepStrictEqual( 101 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'ru-RU.yml'))), 102 | { 103 | 'ru-RU': { 104 | foo: { 105 | one: 'гвоздь', 106 | few: 'гвоздя', 107 | many: 'гвоздей' 108 | } 109 | } 110 | } 111 | ); 112 | }); 113 | 114 | it('Should fail on missed files', function () { 115 | assert.throws( 116 | () => { 117 | run([ 'rename', '-t', 'bad_path', '--from', 'foo', '--to', 'new_foo' ]); 118 | }, 119 | /Failed to find any translation file/ 120 | ); 121 | }); 122 | 123 | it('Should fail on wrong key name ', function () { 124 | assert.throws( 125 | () => { 126 | run([ 'rename', '-t', `${fixtures_yaml_path}`, '--from', 'bad-key', '--to', 'new_foo' ]); 127 | }, 128 | /Could not find key/ 129 | ); 130 | }); 131 | 132 | afterEach(function () { 133 | shell.rm('-rf', fixtures_tmp_dir); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/js/test_newlines.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const shell = require('shelljs'); 6 | const yaml = require('js-yaml'); 7 | const { join } = require('path'); 8 | const { readFileSync } = require('fs'); 9 | 10 | const { run } = require('../../lib/cli'); 11 | 12 | const fixtures_src_dir = join(__dirname, 'fixtures/newlines'); 13 | const fixtures_tmp_dir = join(__dirname, 'fixtures/newlines.tmp'); 14 | 15 | 16 | describe('newlines', function () { 17 | beforeEach(function () { 18 | shell.rm('-rf', fixtures_tmp_dir); 19 | shell.cp('-R', fixtures_src_dir, fixtures_tmp_dir); 20 | }); 21 | 22 | it('Should extract escaped C literal', function () { 23 | run([ 'extract', '-s', join(fixtures_tmp_dir, 'src.c'), '-t', join(fixtures_tmp_dir, 'partial_*.yml') ]); 24 | 25 | assert.deepStrictEqual( 26 | yaml.load(readFileSync(join(fixtures_tmp_dir, 'partial_ru-RU.yml'))), 27 | { 28 | 'ru-RU': { 29 | 'line1\nline2\ttext\nline3': null 30 | } 31 | } 32 | ); 33 | }); 34 | 35 | it('Should compile template data (.c/.h)', function () { 36 | run([ 'compile', '-t', join(fixtures_tmp_dir, 'ru-RU.yml'), '-o', fixtures_tmp_dir, '-l', 'ru-RU' ]); 37 | 38 | let compiled_src = readFileSync(join(fixtures_tmp_dir, 'lv_i18n.c')).toString(); 39 | 40 | assert.match(compiled_src, /"строка1\\nстрока2\\tтекст\\nстрока3",/); 41 | }); 42 | 43 | afterEach(function () { 44 | shell.rm('-rf', fixtures_tmp_dir); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/js/test_parser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const yaml = require('js-yaml'); 6 | const { join } = require('path'); 7 | const { readFileSync } = require('fs'); 8 | 9 | 10 | const parse = require('../../lib/parser'); 11 | 12 | 13 | describe('Parser', function () { 14 | 15 | it('Should find singulars', function () { 16 | assert.deepStrictEqual( 17 | parse(` 18 | const char* my_text = _("My text"); 19 | int i; 20 | const char* my_text2 = _("My text2"); 21 | `), 22 | [ 23 | { 24 | key: 'My text', 25 | line: 2, 26 | plural: false 27 | }, 28 | { 29 | key: 'My text2', 30 | line: 4, 31 | plural: false 32 | } 33 | ] 34 | ); 35 | }); 36 | 37 | 38 | it('Should find scoped singular', function () { 39 | assert.deepStrictEqual( 40 | parse(` 41 | printf(_("msgid1")); 42 | `), 43 | [ 44 | { 45 | key: 'msgid1', 46 | line: 2, 47 | plural: false 48 | } 49 | ] 50 | ); 51 | }); 52 | 53 | 54 | it('Should find plurals', function () { 55 | assert.deepStrictEqual( 56 | parse(` 57 | const char* my_text = _p("My text", number); 58 | int i; 59 | const char* my_text2 = _p("My text2", number); 60 | `), 61 | [ 62 | { 63 | key: 'My text', 64 | line: 2, 65 | plural: true 66 | }, 67 | { 68 | key: 'My text2', 69 | line: 4, 70 | plural: true 71 | } 72 | ] 73 | ); 74 | }); 75 | 76 | 77 | it('Should find multiple entries at the same line', function () { 78 | assert.deepStrictEqual( 79 | parse(` 80 | foo(_("BAR"), _("BAZ"));; 81 | `), 82 | [ 83 | { 84 | key: 'BAR', 85 | line: 2, 86 | plural: false 87 | }, 88 | { 89 | key: 'BAZ', 90 | line: 2, 91 | plural: false 92 | } 93 | ] 94 | ); 95 | }); 96 | 97 | 98 | it('Should keep order of results by lines', function () { 99 | assert.deepStrictEqual( 100 | parse(` 101 | const char* p1 = _p("plural 1", number); 102 | const char* s1 = _("singular 1"); 103 | const char* p2 = _p("plural 2", number); 104 | `), 105 | [ 106 | { 107 | key: 'plural 1', 108 | line: 2, 109 | plural: true 110 | }, 111 | { 112 | key: 'singular 1', 113 | line: 3, 114 | plural: false 115 | }, 116 | { 117 | key: 'plural 2', 118 | line: 4, 119 | plural: true 120 | } 121 | ] 122 | ); 123 | }); 124 | 125 | 126 | describe('unescape_c', function () { 127 | const test_file = join(__dirname, 'fixtures/c_escapes.yml'); 128 | let tests = yaml.load(readFileSync(test_file)); 129 | 130 | for (let [ src, dst ] of Object.values(tests)) { 131 | it(src, function () { 132 | assert.strictEqual(parse._unescape_c(src), dst); 133 | }); 134 | } 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/js/test_source_keys.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const { join } = require('path'); 6 | 7 | const SourceKeys = require('../../lib/source_keys'); 8 | 9 | 10 | describe('SourceKeys', function () { 11 | 12 | it('Should find source files', function () { 13 | let sk = new SourceKeys(); 14 | 15 | sk.loadFiles([ join(__dirname, 'fixtures/file_load/*.+(c|h)') ]); 16 | assert.equal(sk.filesCount, 2); 17 | }); 18 | 19 | it('Should accept duplicated keys', function () { 20 | let sk = new SourceKeys(); 21 | 22 | sk.loadText(` 23 | const char* singular = _("the same text"); 24 | const char* duplicated_singular = _("the same text"); 25 | `, 'test.c'); 26 | assert.equal(sk.keys.length, 2); 27 | }); 28 | 29 | it('Should fail on singulars/plurals collision', function () { 30 | let sk = new SourceKeys(); 31 | 32 | assert.throws( 33 | () => { 34 | sk.loadText(` 35 | const char* singular = _("the same text"); 36 | const char* plural = _p("the same text", 5); 37 | `, 'test.c'); 38 | }, 39 | /Conflicting key/ 40 | ); 41 | }); 42 | 43 | }); 44 | -------------------------------------------------------------------------------- /test/js/test_translation_keys.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const assert = require('assert'); 5 | const { join } = require('path'); 6 | 7 | 8 | const TranslationKeys = require('../../lib/translation_keys'); 9 | 10 | 11 | describe('TranslationKeys', function () { 12 | 13 | describe('Files', function () { 14 | it('Should find source files', function () { 15 | let tk = new TranslationKeys(); 16 | 17 | tk.loadFiles([ join(__dirname, 'fixtures/file_load/*.yml') ]); 18 | assert.equal(tk.filesCount, 1); 19 | }); 20 | 21 | it('Should fail on empty file', function () { 22 | let tk = new TranslationKeys(); 23 | 24 | assert.throws(() => tk.loadText('', 'test.yml'), /Empty file/); 25 | }); 26 | 27 | it('Should fail on bad top struct', function () { 28 | let tk = new TranslationKeys(); 29 | 30 | assert.throws(() => tk.loadText('[]', 'test.yml'), /Can not recognize content/); 31 | }); 32 | 33 | it('Should fail on no locales', function () { 34 | let tk = new TranslationKeys(); 35 | 36 | assert.throws(() => tk.loadText('{}', 'test.yml'), /No locales found/); 37 | }); 38 | 39 | it('Should fail on bad local name', function () { 40 | let tk = new TranslationKeys(); 41 | 42 | assert.throws(() => tk.loadText('{ foo#bar: {} }', 'test.yml'), /Bad locale name/); 43 | }); 44 | 45 | it('Should fail if locale data is not object', function () { 46 | let tk = new TranslationKeys(); 47 | 48 | assert.throws(() => tk.loadText('en-GB: []', 'test.yml'), /Locale .* content should be an object/); 49 | }); 50 | 51 | it('Should allow locale with null value (special case)', function () { 52 | let tk = new TranslationKeys(); 53 | 54 | tk.loadText('en-GB:', 'test.yml'); 55 | }); 56 | 57 | it('Should remember first locale occurence', function () { 58 | let tk = new TranslationKeys(); 59 | 60 | tk.loadText('en-GB:', 'test1.yml'); 61 | tk.loadText('en-GB:', 'test2.yml'); 62 | 63 | assert.equal(tk.localeDefaultFile['en-GB'], 'test1.yml'); 64 | }); 65 | 66 | it('Should create files data', function () { 67 | let tk = new TranslationKeys(); 68 | 69 | tk.loadText("{ 'en-GB': { 'foo': null, 'bar': null } }", 'test.yml'); 70 | tk.removePhraseObj('en-GB', 'foo'); 71 | assert.deepStrictEqual(tk.createFilesData(), { 72 | 'test.yml': { 73 | 'en-GB': { 74 | bar: null 75 | } 76 | } 77 | }); 78 | }); 79 | 80 | it('Should throw on homoglyph locales occurence', function () { 81 | let tk = new TranslationKeys(); 82 | 83 | assert.throws( 84 | () => tk.loadText("{ 'en-GB': {}, 'en-gb': {} }", 'test.yml'), 85 | /was already defined/ 86 | ); 87 | 88 | assert.throws( 89 | () => tk.loadText("{ 'en-GB': {}, 'en_GB': {} }", 'test.yml'), 90 | /was already defined/ 91 | ); 92 | 93 | }); 94 | }); 95 | 96 | describe('Phrases', function () { 97 | it('Should accept empty singular', function () { 98 | let tk = new TranslationKeys(); 99 | 100 | tk.loadText("{ 'en-GB': { 'foo': null } }", 'test.yml'); 101 | assert.strictEqual(tk.getPhraseObj('en-GB', 'foo').value, null); 102 | }); 103 | 104 | it('Should accept string singular', function () { 105 | let tk = new TranslationKeys(); 106 | 107 | tk.loadText("{ 'en-GB': { 'foo': 'bar' } }", 'test.yml'); 108 | assert.equal(tk.getPhraseObj('en-GB', 'foo').value, 'bar'); 109 | }); 110 | 111 | it('Should accept plural', function () { 112 | let tk = new TranslationKeys(); 113 | 114 | tk.loadText("{ 'en-GB': { 'foo': { 'one': 'nail', 'other': 'nails'} } }", 'test.yml'); 115 | assert.equal(tk.getPhraseObj('en-GB', 'foo').value.other, 'nails'); 116 | }); 117 | 118 | it('Should fail on bad singular', function () { 119 | let tk = new TranslationKeys(); 120 | 121 | assert.throws( 122 | () => tk.loadText("{ 'en-GB': { 'foo': [] } }", 'test.yml'), 123 | /Wrong value for/ 124 | ); 125 | }); 126 | 127 | it('Should fail on bad plural key', function () { 128 | let tk = new TranslationKeys(); 129 | 130 | assert.throws( 131 | () => tk.loadText("{ 'en-GB': { 'foo': { 'few': 'nail' } } }", 'test.yml'), 132 | /Bad plural key name/ 133 | ); 134 | }); 135 | 136 | it('Should fail on bad conten of plural key', function () { 137 | let tk = new TranslationKeys(); 138 | 139 | assert.throws( 140 | () => tk.loadText("{ 'en-GB': { 'foo': { 'one': [] } } }", 'test.yml'), 141 | /Bad plural value for/ 142 | ); 143 | }); 144 | 145 | it('Should remove phrase', function () { 146 | let tk = new TranslationKeys(); 147 | 148 | tk.loadText("{ 'en-GB': { 'foo': null, 'bar': null } }", 'test.yml'); 149 | assert.equal(tk.phrases.length, 2); 150 | 151 | tk.removePhraseObj('ru-RU', 'foo'); 152 | assert.equal(tk.phrases.length, 2); 153 | 154 | tk.removePhraseObj('en-GB', 'foo'); 155 | assert.equal(tk.phrases.length, 1); 156 | assert.equal(tk.phrases[0].key, 'bar'); 157 | }); 158 | }); 159 | 160 | }); 161 | --------------------------------------------------------------------------------