├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .haxerc ├── .vscode ├── settings.json └── tasks.json ├── README.md ├── bin └── node │ ├── package.json │ └── yarn.lock ├── completion.hxml ├── haxe_libraries ├── ansi.hxml ├── hxcpp.hxml ├── hxcs.hxml ├── hxjava.hxml ├── hxnodejs.hxml ├── tink_chunk.hxml ├── tink_cli.hxml ├── tink_core.hxml ├── tink_io.hxml ├── tink_json.hxml ├── tink_macro.hxml ├── tink_priority.hxml ├── tink_streams.hxml ├── tink_stringly.hxml ├── tink_syntaxhub.hxml ├── tink_testrunner.hxml ├── tink_typecrawler.hxml ├── tink_unittest.hxml ├── travix.hxml └── turnwing.hxml ├── haxelib.json ├── hxformat.json ├── src └── turnwing │ ├── Dummy.hx │ ├── Macro.hx │ ├── Manager.hx │ ├── import.hx │ ├── provider │ ├── FluentProvider.hx │ ├── FluentProvider.macro.hx │ ├── JsonProvider.hx │ ├── JsonProvider.macro.hx │ └── Provider.hx │ ├── source │ ├── CachedSource.hx │ ├── ExtendedFluentSource.hx │ ├── ResourceStringSource.hx │ ├── Source.hx │ └── WebStringSource.hx │ ├── template │ ├── HaxeTemplate.hx │ └── Template.hx │ └── util │ └── Prefix.hx ├── tests.hxml └── tests ├── DummyTest.hx ├── ExtendedFluentTest.hx ├── FluentTest.hx ├── JsonTest.hx ├── Locales.hx ├── RunTests.hx └── data ├── ftl ├── child-en.ftl ├── en.ftl ├── validation1-en.ftl ├── validation2-en.ftl ├── validation3-en.ftl └── validation4-en.ftl └── json ├── child-en.json ├── en.json └── extended-en.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, v2] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | haxe-version: 16 | - stable 17 | target: 18 | - node 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - run: echo "::set-output name=dir::$(yarn cache dir)" 24 | id: yarn-cache-dir-path 25 | 26 | - uses: actions/cache@v1 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | 33 | - uses: actions/cache@v1 34 | with: 35 | path: ~/haxe 36 | key: ${{ runner.os }}-haxe-${{ hashFiles('haxe_libraries/*') }} 37 | 38 | - uses: lix-pm/setup-lix@master 39 | - run: lix install haxe ${{ matrix.haxe-version }} 40 | - run: lix download 41 | - run: lix run travix ${{ matrix.target }} 42 | env: 43 | CI: true 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/node/tests.js 2 | node_modules -------------------------------------------------------------------------------- /.haxerc: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.2.1", 3 | "resolveLibs": "scoped" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnPaste": true, 3 | "editor.formatOnSave": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "test", 8 | "type": "shell", 9 | "command": "lix", 10 | "args": ["run", "travix", "node"], 11 | "problemMatcher": [ 12 | "$haxe-absolute", 13 | "$haxe", 14 | "$haxe-error", 15 | "$haxe-trace" 16 | ], 17 | "group": { 18 | "kind": "build", 19 | "isDefault": true 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # turnwing 2 | 3 | Hackable localization library for Haxe 4 | 5 | ## What? 6 | 7 | ### Type safety 8 | 9 | Translations are done with interfaces. You will never mis-spell the translation key anymore. 10 | 11 | In many existing localization libraries, the translation function looks like this: 12 | 13 | ```haxe 14 | loc.translate('hello', {name: 'World'}); 15 | loc.translate('orange', {number: 1}); 16 | ``` 17 | 18 | There is one and only one translation function and its type is `String->Dynamic->String`. 19 | That means it takes a String key, a Dynamic parameter object and returns a substituted string. 20 | Several things can go wrong here: wrong translation key, wrong param name or wrong param data type. 21 | 22 | With turnwing, we have typed translators. 23 | Each of them is a user-defined function and typed specifically. 24 | 25 | ```haxe 26 | loc.hello('World'); // String->String 27 | loc.orange(1); // Int->String 28 | ``` 29 | 30 | ### Peace of mind 31 | 32 | There is only one place where errors could happen, that is when the localization data is loaded. 33 | 34 | This is because data are validated when they are loaded. The data provider does all the heavy lifting to make sure the loaded data includes all the needed translation keys and values. As a result, there is no chance for actual translation calls to fail. 35 | 36 | ### Hackable 37 | 38 | Users can plug in different implementations at various part of the library. 39 | 40 | For example, `JsonProvider` uses JSON as the underlying localization format. 41 | One can easily write a `XmlProvider` (perhaps with tink_xml). 42 | 43 | Also, an `ErazorTemplate` may replace the default `HaxeTemplate` implementation. 44 | 45 | ## Usage 46 | 47 | ```haxe 48 | import turnwing.*; 49 | import turnwing.provider.*; 50 | import turnwing.template.*; 51 | 52 | interface MyLocale { 53 | function hello(name:String):String; 54 | function orange(number:Int):String; 55 | var sub(get, never):SubLocale; 56 | } 57 | 58 | interface SubLocale { 59 | function yo():String; 60 | } 61 | 62 | class Main { 63 | static function main() { 64 | var source = new ResourceStringSource(lang -> '$lang.json'); 65 | var template = new HaxeTemplate(); 66 | var loc = new Manager(new JsonProvider(source, template)); 67 | loc.get('en').handle(function(o) switch o { 68 | case Success(localizer): 69 | // data prepared, we can now translate something 70 | $type(localizer); // MyLocale 71 | trace(localizer.hello('World')); // "Hello, World!" 72 | trace(localizer.orange(4)); // "There are 4 orange(s)!" 73 | case Failure(e): 74 | // something went wrong when fetching the localization data 75 | trace(e); 76 | }); 77 | } 78 | } 79 | 80 | // and your json data looks like this: 81 | { 82 | "hello": "Hello, ::name::!", 83 | "orange": "There are ::number:: orange(s)!", 84 | "sub": { 85 | "yo": "Yo!" 86 | } 87 | } 88 | ``` 89 | 90 | ## Providers 91 | 92 | #### JsonProvider 93 | 94 | `JsonProvider` is a provider for JSON sources. 95 | Its data validation is powered by `tink_json`, 96 | which generates the validation code with macro at compile time 97 | according to the type information of the user-defined locale interface. 98 | 99 | Requires a templating engine to interpolate the parameters. 100 | The interface is defined in `Template.hx`. 101 | `HaxeTemplate` is an implementation based on `haxe.Template` from the Haxe standard library. 102 | 103 | Usage: 104 | 105 | ```haxe 106 | var source = new ResourceStringSource(lang -> '$lang.json'); 107 | var template = new HaxeTemplate(); 108 | var provider = new JsonProvider(source, template); 109 | ``` 110 | 111 | To use it, install `tink_json` and include it as dependency in your project 112 | 113 | #### FluentProvider (JS Only) 114 | 115 | `FluentProvider` is a provider for [Fluent](https://projectfluent.org/). 116 | 117 | Messages in the FTL file should be named the same as the Locale interface functions. 118 | Nested interfaces should be delimited by a dash (`-`). 119 | Please refer to the files in the `tests/data/ftl` folder as an example. 120 | 121 | At the moment, the validation logic is incomplete and only performs a very rough check. 122 | So, runtime error _may_ occur in a locale function call. This will be improved in the future. 123 | 124 | Usage: 125 | 126 | ```haxe 127 | var source = new ResourceStringSource(lang -> '$lang.ftl'); 128 | var provider = new FluentProvider(source); 129 | ``` 130 | 131 | To use it, you have to install the npm package `@fluent/bundle` 132 | -------------------------------------------------------------------------------- /bin/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@fluent/bundle": "^0.16.1", 4 | "@fluent/syntax": "^0.17.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /bin/node/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@fluent/bundle@^0.16.1": 6 | version "0.16.1" 7 | resolved "https://registry.yarnpkg.com/@fluent/bundle/-/bundle-0.16.1.tgz#2d359eb2eca4c6053335258f6d3e1025f3fd3b1d" 8 | integrity sha512-l/y8uvAC1vAIEGXfbt97g36s9gJ6wdEFRWCJo4vtoxD1e210MxBAXfLhU+wfzaatlxwh+HjJ15G41ee6V8Y4ww== 9 | 10 | "@fluent/syntax@^0.17.0": 11 | version "0.17.0" 12 | resolved "https://registry.yarnpkg.com/@fluent/syntax/-/syntax-0.17.0.tgz#3e6385c44bda2a44a68b83a1efbadc1a134e9805" 13 | integrity sha512-fgJNUZRBk/n5MO5AxZ7Vvv8aCzMF6NanGBS1GFR2HG2lnsGqHLAe9OeHpyg+FkKfP5SvOtbyk3Nkza5iIwN/Ug== 14 | -------------------------------------------------------------------------------- /completion.hxml: -------------------------------------------------------------------------------- 1 | tests.hxml 2 | 3 | -lib turnwing 4 | -lib travix 5 | -lib hxnodejs 6 | 7 | -js bin/completion/index.js 8 | --no-output 9 | -------------------------------------------------------------------------------- /haxe_libraries/ansi.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:ansi#1.0.0" into ansi/1.0.0/haxelib 2 | -D ansi=1.0.0 3 | -cp ${HAXESHIM_LIBCACHE}/ansi/1.0.0/haxelib/src 4 | -------------------------------------------------------------------------------- /haxe_libraries/hxcpp.hxml: -------------------------------------------------------------------------------- 1 | -D hxcpp=4.0.8 2 | # @install: lix --silent download "haxelib:/hxcpp#4.0.8" into hxcpp/4.0.8/haxelib 3 | # @run: haxelib run-dir hxcpp ${HAXE_LIBCACHE}/hxcpp/4.0.8/haxelib 4 | -cp ${HAXE_LIBCACHE}/hxcpp/4.0.8/haxelib/ 5 | -------------------------------------------------------------------------------- /haxe_libraries/hxcs.hxml: -------------------------------------------------------------------------------- 1 | -D hxcs=3.4.0 2 | # @install: lix --silent download "haxelib:/hxcs#3.4.0" into hxcs/3.4.0/haxelib 3 | # @run: haxelib run-dir hxcs ${HAXE_LIBCACHE}/hxcs/3.4.0/haxelib 4 | -cp ${HAXE_LIBCACHE}/hxcs/3.4.0/haxelib/ 5 | -------------------------------------------------------------------------------- /haxe_libraries/hxjava.hxml: -------------------------------------------------------------------------------- 1 | -D hxjava=3.2.0 2 | # @install: lix --silent download "haxelib:/hxjava#3.2.0" into hxjava/3.2.0/haxelib 3 | # @run: haxelib run-dir hxjava ${HAXE_LIBCACHE}/hxjava/3.2.0/haxelib 4 | -cp ${HAXE_LIBCACHE}/hxjava/3.2.0/haxelib/ 5 | -java-lib lib/hxjava-std.jar 6 | -------------------------------------------------------------------------------- /haxe_libraries/hxnodejs.hxml: -------------------------------------------------------------------------------- 1 | -D hxnodejs=6.9.1 2 | # @install: lix --silent download "gh://github.com/haxefoundation/hxnodejs#38bdefd853f8d637ffb6e74c69ccaedc01985cac" into hxnodejs/6.9.1/github/38bdefd853f8d637ffb6e74c69ccaedc01985cac 3 | -cp ${HAXE_LIBCACHE}/hxnodejs/6.9.1/github/38bdefd853f8d637ffb6e74c69ccaedc01985cac/src 4 | --macro allowPackage('sys') 5 | # should behave like other target defines and not be defined in macro context 6 | --macro define('nodejs') 7 | -------------------------------------------------------------------------------- /haxe_libraries/tink_chunk.hxml: -------------------------------------------------------------------------------- 1 | -D tink_chunk=0.2.0 2 | # @install: lix --silent download "haxelib:/tink_chunk#0.2.0" into tink_chunk/0.2.0/haxelib 3 | -cp ${HAXE_LIBCACHE}/tink_chunk/0.2.0/haxelib/src 4 | -------------------------------------------------------------------------------- /haxe_libraries/tink_cli.hxml: -------------------------------------------------------------------------------- 1 | -D tink_cli=0.4.1 2 | # @install: lix --silent download "haxelib:/tink_cli#0.4.1" into tink_cli/0.4.1/haxelib 3 | -lib tink_io 4 | -lib tink_stringly 5 | -lib tink_macro 6 | -cp ${HAXE_LIBCACHE}/tink_cli/0.4.1/haxelib/src 7 | # Make sure docs are generated 8 | -D use-rtti-doc -------------------------------------------------------------------------------- /haxe_libraries/tink_core.hxml: -------------------------------------------------------------------------------- 1 | -D tink_core=1.24.0 2 | # @install: lix --silent download "gh://github.com/haxetink/tink_core#a182e8c47e54b0587594010d435f8464379cc648" into tink_core/1.24.0/github/a182e8c47e54b0587594010d435f8464379cc648 3 | -cp ${HAXE_LIBCACHE}/tink_core/1.24.0/github/a182e8c47e54b0587594010d435f8464379cc648/src 4 | -------------------------------------------------------------------------------- /haxe_libraries/tink_io.hxml: -------------------------------------------------------------------------------- 1 | -D tink_io=0.6.2 2 | # @install: lix --silent download "haxelib:/tink_io#0.6.2" into tink_io/0.6.2/haxelib 3 | -lib tink_chunk 4 | -lib tink_streams 5 | -cp ${HAXE_LIBCACHE}/tink_io/0.6.2/haxelib/src 6 | -------------------------------------------------------------------------------- /haxe_libraries/tink_json.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_json#5d69a27db7fee6b09a9bbe1118fb8c3b2f3c2652" into tink_json/0.10.5/github/5d69a27db7fee6b09a9bbe1118fb8c3b2f3c2652 2 | -lib tink_typecrawler 3 | -cp ${HAXE_LIBCACHE}/tink_json/0.10.5/github/5d69a27db7fee6b09a9bbe1118fb8c3b2f3c2652/src 4 | -D tink_json=0.10.5 -------------------------------------------------------------------------------- /haxe_libraries/tink_macro.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_macro#7e2dfad607abcd77b26830a3552c026e9facf26c" into tink_macro/0.22.0/github/7e2dfad607abcd77b26830a3552c026e9facf26c 2 | -lib tink_core 3 | -cp ${HAXE_LIBCACHE}/tink_macro/0.22.0/github/7e2dfad607abcd77b26830a3552c026e9facf26c/src 4 | -D tink_macro=0.22.0 -------------------------------------------------------------------------------- /haxe_libraries/tink_priority.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:tink_priority#0.1.3" into tink_priority/0.1.3/haxelib 2 | -D tink_priority=0.1.3 3 | -cp ${HAXESHIM_LIBCACHE}/tink_priority/0.1.3/haxelib/src 4 | -------------------------------------------------------------------------------- /haxe_libraries/tink_streams.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_streams#c51ff28d69ea844995696f10575d1a150ce47159" into tink_streams/0.3.3/github/c51ff28d69ea844995696f10575d1a150ce47159 2 | -lib tink_core 3 | -cp ${HAXE_LIBCACHE}/tink_streams/0.3.3/github/c51ff28d69ea844995696f10575d1a150ce47159/src 4 | -D tink_streams=0.3.3 5 | # temp for development, delete this file when pure branch merged 6 | -D pure -------------------------------------------------------------------------------- /haxe_libraries/tink_stringly.hxml: -------------------------------------------------------------------------------- 1 | -D tink_stringly=0.3.1 2 | # @install: lix --silent download "haxelib:/tink_stringly#0.3.1" into tink_stringly/0.3.1/haxelib 3 | -lib tink_core 4 | -cp ${HAXE_LIBCACHE}/tink_stringly/0.3.1/haxelib/src 5 | -------------------------------------------------------------------------------- /haxe_libraries/tink_syntaxhub.hxml: -------------------------------------------------------------------------------- 1 | -D tink_syntaxhub=0.4.3 2 | # @install: lix --silent download "gh://github.com/haxetink/tink_syntaxhub#8b928af11fb39170dcb7254d02923777cddcc678" into tink_syntaxhub/0.4.3/github/8b928af11fb39170dcb7254d02923777cddcc678 3 | -lib tink_priority 4 | -lib tink_macro 5 | -cp ${HAXE_LIBCACHE}/tink_syntaxhub/0.4.3/github/8b928af11fb39170dcb7254d02923777cddcc678/src 6 | --macro tink.SyntaxHub.use() -------------------------------------------------------------------------------- /haxe_libraries/tink_testrunner.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_testrunner#3dfc89b41818b2bc9264f9c8964cdbbdfbfaf252" into tink_testrunner/0.8.0/github/3dfc89b41818b2bc9264f9c8964cdbbdfbfaf252 2 | -lib ansi 3 | -lib tink_macro 4 | -lib tink_streams 5 | -cp ${HAXE_LIBCACHE}/tink_testrunner/0.8.0/github/3dfc89b41818b2bc9264f9c8964cdbbdfbfaf252/src 6 | -D tink_testrunner=0.8.0 -------------------------------------------------------------------------------- /haxe_libraries/tink_typecrawler.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_typecrawler#abd2dec61e8fe98305021372e3a31efdce92bbc8" into tink_typecrawler/0.7.0/github/abd2dec61e8fe98305021372e3a31efdce92bbc8 2 | -lib tink_macro 3 | -cp ${HAXE_LIBCACHE}/tink_typecrawler/0.7.0/github/abd2dec61e8fe98305021372e3a31efdce92bbc8/src 4 | -D tink_typecrawler=0.7.0 -------------------------------------------------------------------------------- /haxe_libraries/tink_unittest.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_unittest#1c26b50064855d3e7810d4d871103964d5ac9fba" into tink_unittest/0.7.0/github/1c26b50064855d3e7810d4d871103964d5ac9fba 2 | -lib tink_syntaxhub 3 | -lib tink_testrunner 4 | -cp ${HAXE_LIBCACHE}/tink_unittest/0.7.0/github/1c26b50064855d3e7810d4d871103964d5ac9fba/src 5 | -D tink_unittest=0.7.0 6 | --macro tink.unit.AssertionBufferInjector.use() -------------------------------------------------------------------------------- /haxe_libraries/travix.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/back2dos/travix#b92388c36b17faae2374066f7f0d796c5412e96d" into travix/0.13.1/github/b92388c36b17faae2374066f7f0d796c5412e96d 2 | # @post-install: cd ${HAXE_LIBCACHE}/travix/0.13.1/github/b92388c36b17faae2374066f7f0d796c5412e96d && haxe -cp src --run travix.PostDownload 3 | # @run: haxelib run-dir travix ${HAXE_LIBCACHE}/travix/0.13.1/github/b92388c36b17faae2374066f7f0d796c5412e96d 4 | -lib tink_cli 5 | -cp ${HAXE_LIBCACHE}/travix/0.13.1/github/b92388c36b17faae2374066f7f0d796c5412e96d/src 6 | -D travix=0.13.1 7 | -------------------------------------------------------------------------------- /haxe_libraries/turnwing.hxml: -------------------------------------------------------------------------------- 1 | -cp src 2 | -D localize 3 | 4 | -lib tink_core -------------------------------------------------------------------------------- /haxelib.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turnwing", 3 | "classPath": "src", 4 | "dependencies": { 5 | "tink_core": "" 6 | }, 7 | "url": "https://github.com/kevinresol/turnwing", 8 | "contributors": [ 9 | "kevinresol" 10 | ], 11 | "version": "2.0.0", 12 | "releasenote": "API Overhaul. Support Fluent (FTL)", 13 | "tags": [ 14 | "localize", 15 | "i18n", 16 | "cross" 17 | ], 18 | "license": "MIT" 19 | } -------------------------------------------------------------------------------- /hxformat.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/turnwing/Dummy.hx: -------------------------------------------------------------------------------- 1 | package turnwing; 2 | 3 | #if !macro 4 | @:genericBuild(turnwing.Dummy.build()) 5 | class Dummy {} 6 | #else 7 | import haxe.macro.Expr; 8 | import turnwing.Macro.*; 9 | import tink.macro.BuildCache; 10 | 11 | using haxe.macro.Tools; 12 | using tink.MacroApi; 13 | 14 | class Dummy { 15 | public static function build() { 16 | return BuildCache.getType('turnwing.Dummy', (ctx:BuildContext) -> { 17 | final name = ctx.name; 18 | final ct = ctx.type.toComplex(); 19 | final tp = switch ct { 20 | case TPath(p): p; 21 | case v: 22 | trace(v); 23 | throw 'assert'; 24 | } 25 | 26 | final def = macro class $name implements $tp { 27 | public function new() {} 28 | } 29 | 30 | final info = process(ctx.type, ctx.pos); 31 | for (entry in info.entries) { 32 | def.fields.push({ 33 | name: entry.name, 34 | pos: entry.pos, 35 | access: entry.kind.match(Sub(Final, _)) ? [APublic, AFinal] : [APublic], 36 | kind: switch entry.kind { 37 | case Sub(access, info): 38 | final ct = info.complex; 39 | access == Getter ? FProp('get', 'never', ct) : FVar(ct, macro new turnwing.Dummy<$ct>()); 40 | case Term(args): 41 | FFun({ 42 | args: args.map(arg -> { 43 | name: arg.name, 44 | opt: arg.opt, 45 | type: arg.t.toComplex(), 46 | meta: null, 47 | value: null 48 | }), 49 | ret: macro:String, 50 | expr: { 51 | final exprs = [ 52 | (macro final buf = new StringBuf()), 53 | (macro buf.addChar('<'.code)), 54 | (macro buf.add($v{entry.name})), 55 | ]; 56 | for (arg in args) 57 | exprs.push(macro buf.add(' ' + $v{arg.name} + ':' + $i{arg.name})); 58 | exprs.push(macro buf.addChar('>'.code)); 59 | exprs.push(macro return buf.toString()); 60 | macro $b{exprs} 61 | } 62 | }); 63 | } 64 | }); 65 | 66 | switch entry.kind { 67 | case Sub(Getter, info): 68 | final ct = info.complex; 69 | def.fields.push({ 70 | name: 'get_' + entry.name, 71 | pos: entry.pos, 72 | kind: FFun({ 73 | args: [], 74 | expr: macro return new turnwing.Dummy<$ct>(), 75 | }), 76 | }); 77 | case _: 78 | } 79 | } 80 | 81 | def; 82 | }); 83 | } 84 | } 85 | #end 86 | -------------------------------------------------------------------------------- /src/turnwing/Macro.hx: -------------------------------------------------------------------------------- 1 | package turnwing; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Type; 5 | import haxe.macro.Context; 6 | import tink.macro.BuildCache; 7 | 8 | using tink.MacroApi; 9 | using StringTools; 10 | 11 | class Macro { 12 | public static inline function process(type:Type, pos:Position):LocaleInfo { 13 | return processInterface(type, pos); 14 | } 15 | 16 | public static function processInterface(type:Type, pos:Position):LocaleInfo { 17 | final cls = getInterface(type, pos); 18 | final entries = []; 19 | final prev = null; 20 | for (field in type.getFields().sure()) { 21 | if (field.meta.has(':compilerGenerated')) // getters/setters 22 | continue; 23 | 24 | final kind = switch field.type.reduce() { 25 | case TFun(args, t): 26 | if (t.getID() == 'String') { 27 | Term(args); 28 | } else { 29 | field.pos.error('Function must return string'); 30 | } 31 | 32 | case TInst(_.get() => cls, params): 33 | if (!cls.isInterface) { 34 | field.pos.error('Field must be interface'); 35 | } else if (params.length > 0) { 36 | field.pos.error('Paramterized interface is not supported'); 37 | } else { 38 | Sub(getAccess(field), processInterface(field.type, field.pos)); 39 | } 40 | 41 | case v: 42 | field.pos.error('Unsupported type'); 43 | } 44 | 45 | entries.push({ 46 | name: field.name, 47 | pos: field.pos, 48 | type: field.type, 49 | kind: kind, 50 | }); 51 | } 52 | return { 53 | type: cls, 54 | complex: type.toComplex(), 55 | entries: entries, 56 | }; 57 | } 58 | 59 | static function getInterface(type:Type, pos:Position):ClassType { 60 | return switch type.reduce() { 61 | case TInst(_.get() => cls, _) if (cls.isInterface): 62 | cls; 63 | default: 64 | pos.error(type.getID() + ' should be an interface (${type})'); 65 | } 66 | } 67 | 68 | static function getAccess(field:ClassField):VarRead { 69 | return switch field.kind { 70 | case FVar(AccCall, AccNever | AccNo): Getter; 71 | case FVar(AccNormal, AccNever | AccNo): Default; 72 | #if haxe4 73 | case FVar(AccNormal, AccCtor) if (field.isFinal): Final; 74 | #end 75 | case _: field.pos.error('Locale interface can only define functions and properties with (default/get, null/never) access'); 76 | } 77 | } 78 | } 79 | 80 | typedef LocaleInfo = { 81 | type:ClassType, 82 | complex:ComplexType, 83 | entries:Array, 84 | } 85 | 86 | typedef LocaleEntry = { 87 | name:String, 88 | kind:EntryKind, 89 | type:Type, 90 | pos:Position, 91 | } 92 | 93 | enum EntryKind { 94 | Sub(access:VarRead, info:LocaleInfo); // must not be function 95 | Term(args:Array<{name:String, opt:Bool, t:Type}>); // must be function 96 | } 97 | 98 | enum VarRead { 99 | Default; 100 | Getter; 101 | #if haxe4 102 | Final; 103 | #end 104 | } 105 | -------------------------------------------------------------------------------- /src/turnwing/Manager.hx: -------------------------------------------------------------------------------- 1 | package turnwing; 2 | 3 | import turnwing.provider.Provider; 4 | 5 | class Manager { 6 | final provider:Provider; 7 | final locales:Map; 8 | 9 | public function new(provider) { 10 | this.provider = provider; 11 | this.locales = new Map(); 12 | } 13 | 14 | public function get(language:String, forceRefresh = false):Promise { 15 | // @formatter:off 16 | return 17 | if (forceRefresh || !locales.exists(language)) 18 | provider.prepare(language).next(locale -> { 19 | locales.set(language, locale); 20 | locale; 21 | }); 22 | else 23 | Promise.resolve(locales.get(language)); 24 | // @formatter:on 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/turnwing/import.hx: -------------------------------------------------------------------------------- 1 | using tink.CoreApi; -------------------------------------------------------------------------------- /src/turnwing/provider/FluentProvider.hx: -------------------------------------------------------------------------------- 1 | package turnwing.provider; 2 | 3 | import haxe.DynamicAccess; 4 | import turnwing.source.Source; 5 | import turnwing.util.Prefix; 6 | 7 | /** 8 | * FluentProvider consumes the FTL syntax. 9 | * Each message id corresponds to the function name of the Locale interface. 10 | * Nested interfaces are represented as prefixed message ids (delimited with a dash). 11 | * 12 | * Example: 13 | * ```ftl 14 | * plain = plain value 15 | * parametrized = parameterized { $variableName } 16 | * nested-another = value 17 | * ``` 18 | */ 19 | @:genericBuild(turnwing.provider.FluentProvider.build()) 20 | class FluentProvider {} 21 | 22 | @:genericBuild(turnwing.provider.FluentProvider.buildLocale()) 23 | class FluentLocale {} 24 | 25 | class FluentProviderBase implements Provider { 26 | final source:Source; 27 | final opt:{?useIsolating:Bool}; 28 | 29 | public function new(source, ?opt) { 30 | this.source = source; 31 | if (opt != null) 32 | this.opt = opt; // let it remain undefined otherwise the js lib will choke 33 | } 34 | 35 | public function prepare(language:String):Promise 36 | return source.fetch(language).next(bundle.bind(language)).next(make); 37 | 38 | function bundle(language:String, ftl:String):Outcome { 39 | return if (ftl == null) { 40 | Failure(new Error('Empty ftl data')); 41 | } else { 42 | final ctx = new FluentContext(ftl, language, opt); 43 | validate(ctx); 44 | } 45 | } 46 | 47 | function validate(ctx:FluentContext):Outcome 48 | throw 'abstract'; 49 | 50 | // note: suppliedVariables is the list of argument names specified in the Locale interface 51 | function validateMessage(ctx:FluentContext, id:String, suppliedVariables:Array):Option { 52 | return switch ctx.bundle.getMessage(id) { 53 | case null: 54 | Some(ctx.makeError('Missing Message "$id"')); 55 | case message: 56 | validatePattern(ctx, message.value, 'Message "$id"', suppliedVariables, true); 57 | } 58 | } 59 | 60 | // TODO: complete the validation (rescusively) 61 | function validatePattern(ctx:FluentContext, pattern:Pattern, location:String, suppliedVariables:Array, root = false):Option { 62 | if (Std.is(pattern, Array)) { 63 | for (element in (pattern : Array)) 64 | switch (element : Expression).type { 65 | case null: // plain String 66 | case 'select': 67 | final select:SelectExpression = cast element; 68 | 69 | // skip check var because terms (and such) may define its own var 70 | if(root || select.selector.type != 'var') 71 | switch validatePattern(ctx, [select.selector], location, suppliedVariables) { 72 | case Some(e): return Some(e); 73 | case None: // continue 74 | } 75 | 76 | final name = switch exprToSyntax(select.selector) { 77 | case null: 'selector'; 78 | case v: v; 79 | } 80 | 81 | for (v in select.variants) 82 | switch validatePattern(ctx, v.value, '$location : $name -> [${exprToSyntax(v.key)}]', suppliedVariables) { 83 | case Some(e): return Some(e); 84 | case None: // continue 85 | } 86 | case 'var': 87 | final variable:VariableReference = cast element; 88 | if (suppliedVariables.indexOf(variable.name) == -1) 89 | return Some(ctx.makeError('Superfluous variable "${variable.name}". (Not provided in the Locale interface)')); 90 | case 'term': 91 | final term:TermReference = cast element; 92 | if (!ctx.bundle._terms.has('-' + term.name)) 93 | return Some(ctx.makeError('Term "${term.name}" does not exist. (Required by $location)')); 94 | case 'mesg': 95 | final message:MessageReference = cast element; 96 | case 'func': 97 | final func:FunctionReference = cast element; 98 | case 'narg': 99 | final narg:NamedArgument = cast element; 100 | case 'str': 101 | final str:StringLiteral = cast element; 102 | case 'num': 103 | final num:NumberLiteral = cast element; 104 | } 105 | } 106 | return None; 107 | } 108 | 109 | static function exprToSyntax(e:Expression) { 110 | return switch e.type { 111 | case 'var': 112 | final variable:VariableReference = cast e; 113 | '$' + variable.name; 114 | case 'term': 115 | final term:TermReference = cast e; 116 | '-' + term.name; 117 | case 'mesg': 118 | final message:MessageReference = cast e; 119 | message.name; 120 | case 'str': 121 | final str:StringLiteral = cast e; 122 | str.value; 123 | case 'num': 124 | final num:NumberLiteral = cast e; 125 | num.value + ''; 126 | case _: 127 | null; 128 | } 129 | } 130 | 131 | function make(bundle:FluentBundle):Locale 132 | throw 'abstract'; 133 | } 134 | 135 | class FluentContext { 136 | public final source:String; 137 | public final resource:FluentResource; 138 | public final bundle:FluentBundle; 139 | 140 | public function new(ftl, language, opt) { 141 | source = ftl; 142 | resource = new FluentResource(ftl); 143 | bundle = new FluentBundle(language, opt); 144 | bundle.addResource(resource); 145 | } 146 | 147 | public inline function makeError(message:String) { 148 | return Error.withData(message, {source: source}); 149 | } 150 | } 151 | 152 | class FluentLocaleBase { 153 | final __bundle__:FluentBundle; 154 | final __prefix__:Prefix; 155 | 156 | public function new(bundle, prefix) { 157 | __bundle__ = bundle; 158 | __prefix__ = prefix; 159 | } 160 | 161 | function __exec__(id:String, params:Dynamic) { 162 | return __bundle__.formatPattern(__bundle__.getMessage(__prefix__.add(id, '-')).value, __sanitize__(params)); 163 | } 164 | 165 | function __sanitize__(params:DynamicAccess) { 166 | final ret = new DynamicAccess(); 167 | for (field => value in params) { 168 | // Fluent does not support boolean param, we change it to 0/1 169 | ret[field] = Type.typeof(value) == TBool ? (value ? 1 : 0) : value; 170 | } 171 | return ret; 172 | } 173 | } 174 | 175 | abstract Verification(Array) { 176 | public var name(get, never):String; 177 | public var value(get, never):Array; 178 | 179 | inline function get_name() 180 | return this[0]; 181 | 182 | inline function get_value():Array 183 | return untyped (this[1] || []); 184 | 185 | @:from 186 | public static inline function nameOnly(name:String):Verification 187 | return cast [name]; 188 | 189 | public inline function new(name:String, value:Array) 190 | this = [name, value]; 191 | } 192 | 193 | // JS Externs below: 194 | 195 | @:jsRequire('@fluent/bundle', 'FluentBundle') 196 | extern class FluentBundle { 197 | final _terms:js.lib.Map; 198 | final _messages:js.lib.Map; 199 | 200 | function new(lang:String, ?opts:{}); 201 | function addResource(res:FluentResource):Array; 202 | function getMessage(id:String):Message; 203 | function formatPattern(pattern:Pattern, params:Dynamic):String; 204 | } 205 | 206 | @:jsRequire('@fluent/bundle', 'FluentResource') 207 | extern class FluentResource { 208 | function new(ftl:String); 209 | } 210 | 211 | // https://github.com/projectfluent/fluent.js/blob/%40fluent%2Fbundle%400.16.1/fluent-bundle/src/ast.ts 212 | typedef Message = { 213 | id:String, 214 | value:Pattern, 215 | } 216 | 217 | typedef Term = { 218 | id:String, 219 | value:Pattern, 220 | } 221 | 222 | typedef Pattern = haxe.extern.EitherType>; 223 | typedef PatternElement = haxe.extern.EitherType; 224 | 225 | typedef Expression = { 226 | ?type:String, 227 | } 228 | 229 | typedef SelectExpression = Expression & { 230 | selector:Expression, 231 | variants:Array, 232 | star:Int, 233 | } 234 | 235 | typedef VariableReference = Expression & { 236 | name:String, 237 | } 238 | 239 | typedef TermReference = Expression & { 240 | name:String, 241 | attr:String, 242 | args:Array>, 243 | } 244 | 245 | typedef MessageReference = Expression & { 246 | name:String, 247 | attr:String, 248 | } 249 | 250 | typedef FunctionReference = Expression & { 251 | name:String, 252 | args:Array>, 253 | }; 254 | 255 | typedef Variant = Expression & { 256 | key:Literal, 257 | value:Pattern, 258 | } 259 | 260 | typedef NamedArgument = Expression & { 261 | name:String, 262 | value:Literal, 263 | } 264 | 265 | typedef Literal = haxe.extern.EitherType; 266 | 267 | typedef StringLiteral = Expression & { 268 | value:String, 269 | } 270 | 271 | typedef NumberLiteral = Expression & { 272 | value:Int, 273 | precision:Int, 274 | } 275 | -------------------------------------------------------------------------------- /src/turnwing/provider/FluentProvider.macro.hx: -------------------------------------------------------------------------------- 1 | package turnwing.provider; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Type; 5 | import tink.macro.BuildCache; 6 | import turnwing.util.Prefix; 7 | 8 | using tink.MacroApi; 9 | 10 | class FluentProvider { 11 | public static function build() { 12 | return BuildCache.getType('turnwing.provider.FluentProvider', (ctx:BuildContext) -> { 13 | final name = ctx.name; 14 | final localeCt = ctx.type.toComplex(); 15 | 16 | final validations = []; 17 | 18 | function generate(info:turnwing.Macro.LocaleInfo, prefix:Prefix) { 19 | for (entry in info.entries) { 20 | final fullname = prefix.add(entry.name, '-'); 21 | switch entry.kind { 22 | case Term([]): 23 | validations.push(macro turnwing.provider.FluentProvider.Verification.nameOnly($v{fullname})); 24 | case Term(args): 25 | final variables = macro $a{args.map(arg -> macro $v{arg.name})}; 26 | validations.push(macro new turnwing.provider.FluentProvider.Verification($v{fullname}, $variables)); 27 | case Sub(_, info): 28 | generate(info, fullname); 29 | } 30 | } 31 | } 32 | 33 | generate(Macro.process(ctx.type, ctx.pos), new Prefix()); 34 | 35 | final def = macro class $name extends turnwing.provider.FluentProvider.FluentProviderBase<$localeCt> { 36 | override function validate(ctx:turnwing.provider.FluentProvider.FluentContext) { 37 | final validations = $a{validations}; 38 | for (v in validations) 39 | switch validateMessage(ctx, v.name, v.value) { 40 | case Some(error): 41 | return tink.core.Outcome.Failure(error); 42 | case None: // ok 43 | } 44 | return tink.core.Outcome.Success(ctx.bundle); 45 | } 46 | 47 | override function make(bundle:turnwing.provider.FluentProvider.FluentBundle):$localeCt 48 | return new turnwing.provider.FluentProvider.FluentLocale<$localeCt>(bundle, new turnwing.util.Prefix()); 49 | } 50 | 51 | def.pack = ['turnwing', 'provider']; 52 | def; 53 | }); 54 | } 55 | 56 | // https://github.com/HaxeFoundation/haxe/issues/9271 57 | public static function buildLocale() { 58 | return FluentLocale.build(); 59 | } 60 | } 61 | 62 | class FluentLocale { 63 | public static function build() { 64 | return BuildCache.getType('turnwing.provider.FluentLocale', (ctx:BuildContext) -> { 65 | final name = ctx.name; 66 | final localeCt = ctx.type.toComplex(); 67 | final localeTp = switch localeCt { 68 | case TPath(tp): tp; 69 | default: throw 'assert'; 70 | } 71 | 72 | final info = Macro.process(ctx.type, ctx.pos); 73 | final inits = []; 74 | 75 | final def = macro class $name extends turnwing.provider.FluentProvider.FluentLocaleBase implements $localeTp { 76 | public function new(__bundle__, __prefix__) { 77 | super(__bundle__, __prefix__); 78 | @:mergeBlock $b{inits} 79 | } 80 | } // unbreak haxe-formatter (see: https://github.com/HaxeCheckstyle/haxe-formatter/issues/565) 81 | 82 | for (entry in info.entries) { 83 | final name = entry.name; 84 | 85 | switch entry.kind { 86 | case Term(args): 87 | final params = EObjectDecl([for (arg in args) {field: arg.name, expr: macro $i{arg.name}}]).at(entry.pos); 88 | final body = macro __exec__($v{entry.name}, $params); 89 | 90 | final f = body.func(args.map(a -> a.name.toArg(a.t.toComplex(), a.opt)), macro:String); 91 | def.fields.push({ 92 | access: [APublic], 93 | name: name, 94 | kind: FFun(f), 95 | pos: entry.pos, 96 | }); 97 | 98 | case Sub(access, info): 99 | final subLocaleCt = entry.type.toComplex(); 100 | final factory = macro new turnwing.provider.FluentProvider.FluentLocale<$subLocaleCt>(__bundle__, __prefix__.add($v{entry.name}, '-')); 101 | final init = macro $i{name} = $factory; 102 | 103 | switch access { 104 | case Default: 105 | def.fields.push({ 106 | access: [APublic], 107 | name: name, 108 | kind: FProp('default', 'null', subLocaleCt, null), 109 | pos: entry.pos, 110 | }); 111 | inits.push(init); 112 | case Getter: 113 | def.fields.push({ 114 | access: [APublic], 115 | name: name, 116 | kind: FProp('get', 'never', subLocaleCt, null), 117 | pos: entry.pos, 118 | }); 119 | def.fields.push({ 120 | access: [AInline], 121 | name: 'get_$name', 122 | kind: FFun(factory.func(subLocaleCt)), 123 | pos: entry.pos, 124 | }); 125 | case Final: 126 | def.fields.push({ 127 | access: [APublic, AFinal], 128 | name: name, 129 | kind: FVar(subLocaleCt, null), 130 | pos: entry.pos, 131 | }); 132 | inits.push(init); 133 | } 134 | } 135 | } 136 | 137 | def.pack = ['turnwing', 'provider']; 138 | def; 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/turnwing/provider/JsonProvider.hx: -------------------------------------------------------------------------------- 1 | package turnwing.provider; 2 | 3 | import turnwing.source.Source; 4 | import turnwing.template.Template; 5 | 6 | /** 7 | * JsonProvider consumes the JSON syntax. 8 | * Each key corresponds to the function name of the Locale interface. 9 | * Nested interfaces are represented as nested objects. 10 | * 11 | * Example: (usage with HaxeTemplate) 12 | * ```json 13 | * { 14 | * "plain": "plain value", 15 | * "parametrized": "parameterized ::variableName::", 16 | * "nested": { 17 | * "another": "value" 18 | * } 19 | * } 20 | * ``` 21 | */ 22 | @:genericBuild(turnwing.provider.JsonProvider.build()) 23 | class JsonProvider {} 24 | 25 | @:genericBuild(turnwing.provider.JsonProvider.buildLocale()) 26 | class JsonLocale {} 27 | 28 | @:genericBuild(turnwing.provider.JsonProvider.buildData()) 29 | class JsonData {} 30 | 31 | class JsonProviderBase implements Provider { 32 | final source:Source; 33 | final template:Template; 34 | 35 | public function new(source, template) { 36 | this.source = source; 37 | this.template = template; 38 | } 39 | 40 | public function prepare(language:String):Promise 41 | return source.fetch(language).next(parse).next(make); 42 | 43 | function parse(v:String):Outcome 44 | throw 'abstract'; 45 | 46 | function make(data:Data):Locale 47 | throw 'abstract'; 48 | } 49 | 50 | class JsonLocaleBase { 51 | final __template__:Template; 52 | final __data__:Data; 53 | 54 | public function new(template, data) { 55 | __template__ = template; 56 | __data__ = data; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/turnwing/provider/JsonProvider.macro.hx: -------------------------------------------------------------------------------- 1 | package turnwing.provider; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Type; 5 | import tink.macro.BuildCache; 6 | 7 | using tink.MacroApi; 8 | 9 | class JsonProvider { 10 | public static function build() { 11 | return BuildCache.getType('turnwing.provider.JsonProvider', (ctx:BuildContext) -> { 12 | final name = ctx.name; 13 | final localeCt = ctx.type.toComplex(); 14 | final dataCt = macro:turnwing.provider.JsonProvider.JsonData<$localeCt>; 15 | 16 | final def = macro class $name extends turnwing.provider.JsonProvider.JsonProviderBase<$localeCt, $dataCt> { 17 | override function parse(v:String) 18 | return tink.Json.parse((v : $dataCt)); 19 | 20 | override function make(data:$dataCt):$localeCt 21 | return new turnwing.provider.JsonProvider.JsonLocale<$localeCt>(template, data); 22 | } 23 | 24 | def.pack = ['turnwing', 'provider']; 25 | def; 26 | }); 27 | } 28 | 29 | // https://github.com/HaxeFoundation/haxe/issues/9271 30 | public static function buildLocale() { 31 | return JsonLocale.build(); 32 | } 33 | 34 | // https://github.com/HaxeFoundation/haxe/issues/9271 35 | public static function buildData() { 36 | return JsonData.build(); 37 | } 38 | } 39 | 40 | class JsonLocale { 41 | public static function build() { 42 | return BuildCache.getType('turnwing.provider.JsonLocale', (ctx:BuildContext) -> { 43 | final name = ctx.name; 44 | final localeCt = ctx.type.toComplex(); 45 | final localeTp = switch localeCt { 46 | case TPath(tp): tp; 47 | default: throw 'assert'; 48 | } 49 | final dataCt = macro:turnwing.provider.JsonProvider.JsonData<$localeCt>; 50 | 51 | final info = Macro.process(ctx.type, ctx.pos); 52 | final inits = []; 53 | 54 | final def = macro class $name extends turnwing.provider.JsonProvider.JsonLocaleBase<$dataCt> implements $localeTp { 55 | public function new(__template__, __data__) { 56 | super(__template__, __data__); 57 | @:mergeBlock $b{inits} 58 | } 59 | } // unbreak haxe-formatter (see: https://github.com/HaxeCheckstyle/haxe-formatter/issues/565) 60 | 61 | for (entry in info.entries) { 62 | final name = entry.name; 63 | 64 | switch entry.kind { 65 | case Term(args): 66 | final params = EObjectDecl([for (arg in args) {field: arg.name, expr: macro $i{arg.name}}]).at(entry.pos); 67 | final body = macro __template__.execute(__data__.$name, $params); 68 | final f = body.func(args.map(a -> a.name.toArg(a.t.toComplex(), a.opt)), macro:String); 69 | def.fields.push({ 70 | access: [APublic], 71 | name: name, 72 | kind: FFun(f), 73 | pos: entry.pos, 74 | }); 75 | 76 | case Sub(access, info): 77 | final subLocaleCt = entry.type.toComplex(); 78 | final factory = macro new turnwing.provider.JsonProvider.JsonLocale<$subLocaleCt>(__template__, __data__.$name); 79 | final init = macro $i{name} = $factory; 80 | 81 | switch access { 82 | case Default: 83 | def.fields.push({ 84 | access: [APublic], 85 | name: name, 86 | kind: FProp('default', 'null', subLocaleCt, null), 87 | pos: entry.pos, 88 | }); 89 | inits.push(init); 90 | case Getter: 91 | def.fields.push({ 92 | access: [APublic], 93 | name: name, 94 | kind: FProp('get', 'never', subLocaleCt, null), 95 | pos: entry.pos, 96 | }); 97 | def.fields.push({ 98 | access: [AInline], 99 | name: 'get_$name', 100 | kind: FFun(factory.func(subLocaleCt)), 101 | pos: entry.pos, 102 | }); 103 | case Final: 104 | def.fields.push({ 105 | access: [APublic, AFinal], 106 | name: name, 107 | kind: FVar(subLocaleCt, null), 108 | pos: entry.pos, 109 | }); 110 | inits.push(init); 111 | } 112 | } 113 | } 114 | 115 | def.pack = ['turnwing', 'provider']; 116 | def; 117 | }); 118 | } 119 | } 120 | 121 | class JsonData { 122 | public static function build() { 123 | return BuildCache.getType('turnwing.provider.JsonData', (ctx:BuildContext) -> { 124 | fields: getDataFields(ctx.type, ctx.pos), 125 | name: ctx.name, 126 | pack: ['turnwing', 'provider'], 127 | pos: ctx.pos, 128 | kind: TDStructure, 129 | }); 130 | } 131 | 132 | static function getDataFields(type:Type, pos:Position):Array { 133 | final fields:Array = []; 134 | final info = Macro.process(type, pos); 135 | for (entry in info.entries) { 136 | final ct = entry.type.toComplex(); 137 | fields.push({ 138 | name: entry.name, 139 | pos: entry.pos, 140 | kind: FVar(entry.kind.match(Term(_)) ? macro:String : macro:turnwing.provider.JsonProvider.JsonData<$ct>, null), 141 | }); 142 | } 143 | return fields; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/turnwing/provider/Provider.hx: -------------------------------------------------------------------------------- 1 | package turnwing.provider; 2 | 3 | interface Provider { 4 | function prepare(language:String):Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/turnwing/source/CachedSource.hx: -------------------------------------------------------------------------------- 1 | package turnwing.source; 2 | 3 | class CachedSource implements Source { 4 | final getKey:String->String; 5 | final source:Source; 6 | final cache:Map>; 7 | 8 | public function new(getKey, source, ?cache) { 9 | this.getKey = getKey; 10 | this.source = source; 11 | this.cache = cache == null ? [] : cache; 12 | } 13 | 14 | public function fetch(language:String):Promise { 15 | final key = getKey(language); 16 | return switch cache[key] { 17 | case null: cache[key] = source.fetch(language); 18 | case v: v; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/turnwing/source/ExtendedFluentSource.hx: -------------------------------------------------------------------------------- 1 | package turnwing.source; 2 | 3 | using haxe.io.Path; 4 | using StringTools; 5 | 6 | /** 7 | * A custom extension of the Fluent FTL format. 8 | * Supports a special "include" directive to import other .ftl files 9 | * 10 | * Syntax: `# @include [into ]` 11 | * 12 | * `path` is a relative path (supports `..`) resolved against the current file 13 | * - if ending with `.ftl` the path will be used as is 14 | * - otherwise it is considered a directory, and the current language plus a `.ftl` extension will be appended to the path 15 | * 16 | * An optional prefix can be specified with the `into` syntax. 17 | * In that case, every Fluent Message in the included file will be prefixed with the specified value plus a dash `-`, 18 | * which is useful for nesting localizers with variables or properties while reusing existing ftl files. 19 | */ 20 | class ExtendedFluentSource implements Source { 21 | final path:String; 22 | final getSource:(getPath:(lang:String) -> String) -> Source; 23 | 24 | public function new(path, getSource) { 25 | this.path = path; 26 | this.getSource = getSource; 27 | } 28 | 29 | public function fetch(language:String):Promise { 30 | return load(path, language, [], []); 31 | } 32 | 33 | function load(path:String, lang:String, prefixes:Array, rootIncludes:Array):Promise { 34 | final source = getSource(language -> path.endsWith('.ftl') ? path : Path.join([path, '$language.ftl'])); 35 | return source.fetch(lang).next(src -> follow(src, path, lang, prefixes, rootIncludes)); 36 | } 37 | 38 | function follow(source:String, path:String, lang:String, prefixes:Array, rootIncludes:Array) { 39 | final regex = ~/^# @include ([^ ]+)( into (\w+))?$/; 40 | final promises = []; 41 | 42 | var start = 0; 43 | var end = 0; 44 | 45 | function add(s, ?e) { 46 | if (e == null) 47 | e = source.length; // This is necessary because substring(s, null) gives empty string, but substring(s, undefined) will give the full string. Thank you, js 🎉 48 | final sub = source.substring(s, e).trim(); 49 | final matched = regex.match(sub); 50 | if (matched) { 51 | var rel = regex.matched(1).trim(); 52 | if (!rel.endsWith('.ftl')) 53 | rel = Path.join([rel, '$lang.ftl']); 54 | 55 | final newPrefixes = switch regex.matched(3) { 56 | case null: 57 | switch rootIncludes.indexOf(rel) { 58 | case -1: 59 | rootIncludes.push(rel); 60 | case i: // already included 61 | return true; 62 | } 63 | prefixes; 64 | case prefix: 65 | prefixes.concat([prefix]); 66 | } 67 | 68 | final sections = [path.endsWith('.ftl') ? path.directory() : path, rel]; 69 | promises.push(load(Path.join(sections).normalize(), lang, prefixes, rootIncludes).next(appendPrefixes.bind(_, newPrefixes))); 70 | } 71 | return matched; 72 | } 73 | 74 | var ended = false; 75 | while ((end = source.indexOf('\n', start + 1)) != -1) { 76 | final added = add(start, end); 77 | start = end; 78 | if (!added) { 79 | ended = true; // skip the remaining of the file 80 | break; 81 | } 82 | } 83 | 84 | if (!ended) 85 | add(start); 86 | 87 | return Promise.inParallel(promises).next(list -> source + '\n' + list.join('\n')); 88 | } 89 | 90 | function appendPrefixes(source:String, prefixes:Array):String { 91 | if (prefixes.length == 0) 92 | return source; 93 | final syntax:Dynamic = js.Lib.require('@fluent/syntax'); 94 | final resource:{body:Array} = syntax.parse(source); 95 | for (entry in resource.body) { 96 | if (js.Syntax.instanceof(entry, syntax.Message)) { 97 | entry.id = js.Syntax.code('new {0}.Identifier({1})', syntax, prefixes.concat([entry.id.name]).join('-')); 98 | } 99 | } 100 | return syntax.serialize(resource); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/turnwing/source/ResourceStringSource.hx: -------------------------------------------------------------------------------- 1 | package turnwing.source; 2 | 3 | import turnwing.source.Source; 4 | 5 | class ResourceStringSource implements Source { 6 | final getResourceName:(lang:String) -> String; 7 | 8 | public function new(getResourceName) 9 | this.getResourceName = getResourceName; 10 | 11 | public function fetch(language:String):Promise { 12 | final name = getResourceName(language); 13 | return Error.catchExceptions(() -> switch haxe.Resource.getString(name) { 14 | case null: throw new Error(NotFound, 'No resource named "$name"'); 15 | case v: v; 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/turnwing/source/Source.hx: -------------------------------------------------------------------------------- 1 | package turnwing.source; 2 | 3 | interface Source { 4 | function fetch(language:String):Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/turnwing/source/WebStringSource.hx: -------------------------------------------------------------------------------- 1 | package turnwing.source; 2 | 3 | import turnwing.source.Source; 4 | 5 | class WebStringSource implements Source { 6 | final getUrl:(lang:String) -> String; 7 | 8 | public function new(getUrl) 9 | this.getUrl = getUrl; 10 | 11 | public function fetch(language:String):Promise 12 | return tink.http.Fetch.fetch(getUrl(language)).all().next(res -> res.body.toString()); 13 | } 14 | -------------------------------------------------------------------------------- /src/turnwing/template/HaxeTemplate.hx: -------------------------------------------------------------------------------- 1 | package turnwing.template; 2 | 3 | class HaxeTemplate implements Template { 4 | public function new() {} 5 | 6 | public function execute(raw:String, params:Dynamic):String 7 | return new haxe.Template(raw).execute(params); 8 | } 9 | -------------------------------------------------------------------------------- /src/turnwing/template/Template.hx: -------------------------------------------------------------------------------- 1 | package turnwing.template; 2 | 3 | interface Template { 4 | function execute(raw:String, params:Dynamic):String; 5 | } 6 | -------------------------------------------------------------------------------- /src/turnwing/util/Prefix.hx: -------------------------------------------------------------------------------- 1 | package turnwing.util; 2 | 3 | abstract Prefix(String) to String { 4 | public inline function new() 5 | this = ''; 6 | 7 | public inline function add(name:String, delimiter = ''):Prefix 8 | return cast(this == '' ? name : this + delimiter + name); 9 | } 10 | -------------------------------------------------------------------------------- /tests.hxml: -------------------------------------------------------------------------------- 1 | -cp tests 2 | -main RunTests 3 | -dce full 4 | -lib tink_unittest 5 | -lib tink_json 6 | 7 | -D analyzer-optimize 8 | 9 | -resource tests/data/json/child-en.json@child-en.json 10 | -resource tests/data/json/en.json@en.json 11 | -resource tests/data/json/extended-en.json@extended-en.json 12 | -resource tests/data/ftl/child-en.ftl@child-en.ftl 13 | -resource tests/data/ftl/en.ftl@en.ftl 14 | -resource tests/data/ftl/validation1-en.ftl@validation1-en.ftl 15 | -resource tests/data/ftl/validation2-en.ftl@validation2-en.ftl 16 | -resource tests/data/ftl/validation3-en.ftl@validation3-en.ftl 17 | -resource tests/data/ftl/validation4-en.ftl@validation4-en.ftl -------------------------------------------------------------------------------- /tests/DummyTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import turnwing.*; 4 | import tink.unit.Assert.*; 5 | import Locales; 6 | 7 | using tink.CoreApi; 8 | 9 | @:asserts 10 | class DummyTest { 11 | public function new() {} 12 | 13 | public function localize() { 14 | final locale = new Dummy(); 15 | asserts.assert(locale.empty() == ''); 16 | asserts.assert(locale.hello('World') == ''); 17 | asserts.assert(locale.bool(true) == ''); 18 | asserts.assert(locale.bool(false) == ''); 19 | return asserts.done(); 20 | } 21 | 22 | public function child() { 23 | final loc = new Dummy(); 24 | 25 | function test(loc:MyLocale) { 26 | asserts.assert(loc.empty() == ''); 27 | asserts.assert(loc.hello('World') == ''); 28 | asserts.assert(loc.bool(true) == ''); 29 | asserts.assert(loc.bool(false) == ''); 30 | } 31 | test(loc.normal); 32 | test(loc.getter); 33 | test(loc.const); 34 | return asserts.done(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/ExtendedFluentTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import turnwing.source.*; 4 | import ExtendedFluentTest.*; 5 | 6 | using tink.CoreApi; 7 | 8 | @:asserts 9 | class ExtendedFluentTest { 10 | public static final FOO_EN = 'foo = Foo'; 11 | public static final FOO_ZH = 'foo = 呼'; 12 | public static final BAR_EN = 'bar = Bar'; 13 | public static final BAR_ZH = 'bar = 巴'; 14 | public static final BAZ_EN = 'baz = Baz'; 15 | public static final BAZ_ZH = 'baz = 巴斯'; 16 | public static final BAZ1_EN = 'baz1 = Baz1'; 17 | public static final BAZ1_ZH = 'baz1 = 巴斯1'; 18 | public static final PREFIX = 'myprefix'; 19 | 20 | static final ESCAPED_NEWLINE = '\\n'; 21 | 22 | public function new() {} 23 | 24 | // @formatter:off 25 | @:variant('foo', 'en', [ExtendedFluentTest.FOO_EN, ExtendedFluentTest.BAZ_EN]) 26 | @:variant('bar', 'en', [ExtendedFluentTest.BAR_EN, ExtendedFluentTest.PREFIX + '-' + ExtendedFluentTest.BAZ_EN]) 27 | @:variant('deep', 'en', ['deep-' + ExtendedFluentTest.BAR_EN, 'deep-' + ExtendedFluentTest.PREFIX + '-' + ExtendedFluentTest.BAZ_EN]) 28 | @:variant('multi', 'en', [ExtendedFluentTest.BAZ_EN, ExtendedFluentTest.BAZ1_EN]) 29 | 30 | @:variant('foo', 'zh', [ExtendedFluentTest.FOO_ZH, ExtendedFluentTest.BAZ_ZH]) 31 | @:variant('bar', 'zh', [ExtendedFluentTest.BAR_ZH, ExtendedFluentTest.PREFIX + '-' + ExtendedFluentTest.BAZ_ZH]) 32 | @:variant('deep', 'zh', ['deep-' + ExtendedFluentTest.BAR_ZH, 'deep-' + ExtendedFluentTest.PREFIX + '-' + ExtendedFluentTest.BAZ_ZH]) 33 | @:variant('multi', 'zh', [ExtendedFluentTest.BAZ_ZH, ExtendedFluentTest.BAZ1_ZH]) 34 | // @formatter:on 35 | public function basic(name:String, lang:String, expected:Array) { 36 | final source = new ExtendedFluentSource(name, LocalStringSource.new); 37 | source.fetch(lang).next(source -> { 38 | final lines = source.split('\n'); 39 | for (v in expected) 40 | asserts.assert(lines.contains(v), '"${lines.filter(line -> line.charCodeAt(0) != '#'.code).join(' $ESCAPED_NEWLINE ')}" contains "$v"'); 41 | Noise; 42 | }).handle(asserts.handle); 43 | return asserts; 44 | } 45 | 46 | public function duplicate() { 47 | final source = new ExtendedFluentSource('duplicate', LocalStringSource.new); 48 | source.fetch('en').next(source -> { 49 | asserts.assert(occurrence(source, BAZ_EN) == 1); 50 | Noise; 51 | }).handle(asserts.handle); 52 | return asserts; 53 | } 54 | 55 | static function occurrence(source:String, query:String) { 56 | var start = 0; 57 | var count = 0; 58 | while (true) { 59 | switch source.indexOf(query, start) { 60 | case -1: 61 | return count; 62 | case i: 63 | count++; 64 | start = i + query.length; 65 | } 66 | } 67 | } 68 | } 69 | 70 | class LocalStringSource implements Source { 71 | // @formatter:off 72 | static final map:Map = [ 73 | 'foo/en.ftl' => '# @include ../baz\n$FOO_EN', 74 | 'foo/zh.ftl' => '# @include ../baz\n$FOO_ZH', 75 | 'bar/en.ftl' => '# @include ../baz into $PREFIX\n$BAR_EN', 76 | 'bar/zh.ftl' => '# @include ../baz into $PREFIX\n$BAR_ZH', 77 | 'baz/en.ftl' => '$BAZ_EN', 78 | 'baz/zh.ftl' => '$BAZ_ZH', 79 | 'baz1/en.ftl' => '$BAZ1_EN', 80 | 'baz1/zh.ftl' => '$BAZ1_ZH', 81 | 'deep/en.ftl' => '# @include ../bar into deep', 82 | 'deep/zh.ftl' => '# @include ../bar into deep', 83 | 'multi/en.ftl' => '# @include ../baz\n# @include ../baz1\nmulti = Multi', 84 | 'multi/zh.ftl' => '# @include ../baz\n# @include ../baz1\nmulti = Multi', 85 | 'transitive/en.ftl' => '# @include ../baz', 86 | 'transitive/zh.ftl' => '# @include ../baz', 87 | 'duplicate/en.ftl' => '# @include ../transitive\n# @include ../foo\n# @include ../baz', 88 | 'duplicate/zh.ftl' => '# @include ../transitive\n# @include ../foo\n# @include ../baz', 89 | ]; 90 | // @formatter:on 91 | final getPath:String->String; 92 | 93 | public function new(getPath) 94 | this.getPath = getPath; 95 | 96 | public function fetch(language:String):Promise { 97 | return map[getPath(language)]; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/FluentTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import turnwing.*; 4 | import turnwing.provider.*; 5 | import turnwing.source.*; 6 | import turnwing.template.*; 7 | import tink.unit.Assert.*; 8 | import Locales; 9 | 10 | using tink.CoreApi; 11 | 12 | @:asserts 13 | class FluentTest { 14 | final source:Source = new ResourceStringSource(lang -> '$lang.ftl'); 15 | 16 | public function new() {} 17 | 18 | public function localize() { 19 | final loc = new Manager(new FluentProvider(source, {useIsolating: false})); 20 | return loc.get('en').next(locale -> { 21 | asserts.assert(locale.empty() == 'Hello, World!'); 22 | asserts.assert(locale.hello('World') == 'Hello, World!'); 23 | asserts.assert(locale.bool(true) == 'Yes'); 24 | asserts.assert(locale.bool(false) == 'No'); 25 | asserts.done(); 26 | }); 27 | } 28 | 29 | public function noData() { 30 | final loc = new Manager(new FluentProvider(source, {useIsolating: false})); 31 | return loc.get('dummy').map(o -> assert(!o.isSuccess())); 32 | } 33 | 34 | public function invalid() { 35 | final loc = new Manager(new FluentProvider(source, {useIsolating: false})); 36 | return loc.get('en').map(o -> assert(!o.isSuccess())); 37 | } 38 | 39 | public function child() { 40 | final source = new ResourceStringSource(lang -> 'child-$lang.ftl'); 41 | final loc = new Manager(new FluentProvider(source, {useIsolating: false})); 42 | return loc.get('en').next(en -> { 43 | function test(loc:MyLocale) { 44 | asserts.assert(loc.empty() == 'Hello, World!'); 45 | asserts.assert(loc.hello('World') == 'Hello, World!'); 46 | asserts.assert(loc.bool(true) == 'Yes'); 47 | asserts.assert(loc.bool(false) == 'No'); 48 | } 49 | test(en.normal); 50 | test(en.getter); 51 | test(en.const); 52 | asserts.done(); 53 | }); 54 | } 55 | 56 | @:variant(1) 57 | @:variant(2) 58 | @:variant(3) 59 | @:variant(4) 60 | public function validation(i:Int) { 61 | final source = new ResourceStringSource(lang -> 'validation$i-$lang.ftl'); 62 | final loc = new Manager(new FluentProvider(source, {useIsolating: false})); 63 | return loc.get('en').map(o -> switch o { 64 | case Success(loc): 65 | asserts.fail('Expected failure'); 66 | case Failure(e): 67 | // trace(e.message); 68 | asserts.assert(true, 'Expected failure'); 69 | asserts.done(); 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/JsonTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import turnwing.*; 4 | import turnwing.provider.*; 5 | import turnwing.source.*; 6 | import turnwing.template.*; 7 | import tink.unit.Assert.*; 8 | import Locales; 9 | 10 | using tink.CoreApi; 11 | 12 | @:asserts 13 | class JsonTest { 14 | final source:Source = new ResourceStringSource(lang -> '$lang.json'); 15 | final template:Template = new HaxeTemplate(); 16 | 17 | public function new() {} 18 | 19 | public function localize() { 20 | final loc = new Manager(new JsonProvider(source, template)); 21 | return loc.get('en').next(locale -> { 22 | asserts.assert(locale.empty() == 'Hello, World!'); 23 | asserts.assert(locale.hello('World') == 'Hello, World!'); 24 | asserts.assert(locale.bool(true) == 'Yes'); 25 | asserts.assert(locale.bool(false) == 'No'); 26 | asserts.done(); 27 | }); 28 | } 29 | 30 | public function noData() { 31 | final loc = new Manager(new JsonProvider(source, template)); 32 | return loc.get('dummy').map(o -> assert(!o.isSuccess())); 33 | } 34 | 35 | public function invalid() { 36 | final loc = new Manager(new JsonProvider(source, template)); 37 | return loc.get('en').map(o -> assert(!o.isSuccess())); 38 | } 39 | 40 | public function child() { 41 | final source = new ResourceStringSource(lang -> 'child-$lang.json'); 42 | final loc = new Manager(new JsonProvider(source, template)); 43 | return loc.get('en').next(en -> { 44 | function test(loc:MyLocale) { 45 | asserts.assert(loc.empty() == 'Hello, World!'); 46 | asserts.assert(loc.hello('World') == 'Hello, World!'); 47 | asserts.assert(loc.bool(true) == 'Yes'); 48 | asserts.assert(loc.bool(false) == 'No'); 49 | } 50 | test(en.normal); 51 | test(en.getter); 52 | test(en.const); 53 | asserts.done(); 54 | }); 55 | } 56 | 57 | public function extended() { 58 | final source = new ResourceStringSource(lang -> 'extended-$lang.json'); 59 | final loc = new Manager(new JsonProvider(source, template)); 60 | return loc.get('en').next(locale -> { 61 | asserts.assert(locale.extended() == 'Extension!'); 62 | asserts.assert(locale.empty() == 'Hello, World!'); 63 | asserts.assert(locale.hello('World') == 'Hello, World!'); 64 | asserts.assert(locale.bool(true) == 'Yes'); 65 | asserts.assert(locale.bool(false) == 'No'); 66 | asserts.done(); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Locales.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | interface MyLocale { 4 | function empty():String; 5 | function hello(name:String):String; 6 | function bool(value:Bool):String; 7 | } 8 | 9 | interface InvalidLocale { // test invalid source 10 | function foo(name:String):String; 11 | } 12 | 13 | interface ParentLocale { 14 | var normal(default, null):MyLocale; 15 | var getter(get, null):MyLocale; 16 | final const:MyLocale; 17 | } 18 | 19 | interface ExtendedLocale extends MyLocale { 20 | function extended():String; 21 | } -------------------------------------------------------------------------------- /tests/RunTests.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.*; 4 | import tink.testrunner.*; 5 | 6 | class RunTests { 7 | static function main() { 8 | Runner.run(TestBatch.make([ 9 | // 10 | new DummyTest(), 11 | new JsonTest(), 12 | new FluentTest(), 13 | new ExtendedFluentTest(), 14 | ])).handle(Runner.exit); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/data/ftl/child-en.ftl: -------------------------------------------------------------------------------- 1 | normal-hello = Hello, { $name }! 2 | normal-empty = Hello, World! 3 | normal-bool = 4 | { $value -> 5 | [0] No 6 | *[1] Yes 7 | } 8 | 9 | getter-hello = Hello, { $name }! 10 | getter-empty = Hello, World! 11 | getter-bool = 12 | { $value -> 13 | [0] No 14 | *[1] Yes 15 | } 16 | 17 | const-hello = Hello, { $name }! 18 | const-empty = Hello, World! 19 | const-bool = 20 | { $value -> 21 | [0] No 22 | *[1] Yes 23 | } 24 | -------------------------------------------------------------------------------- /tests/data/ftl/en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, { $name }! 2 | empty = Hello, World! 3 | bool = 4 | { $value -> 5 | [0] No 6 | *[1] Yes 7 | } -------------------------------------------------------------------------------- /tests/data/ftl/validation1-en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, { $name }! 2 | empty = Hello, World! 3 | bool = 4 | { $value -> 5 | [0] { -no } 6 | *[1] { -yes } 7 | } 8 | 9 | -yes = Yes 10 | # -no = No -------------------------------------------------------------------------------- /tests/data/ftl/validation2-en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, { $name }! 2 | empty = Hello, World! 3 | bool = 4 | { -value -> 5 | [0] { -no } 6 | *[1] { -yes } 7 | } 8 | 9 | -yes = Yes 10 | -no = No -------------------------------------------------------------------------------- /tests/data/ftl/validation3-en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, { $name }! 2 | empty = Hello, World! 3 | bool = 4 | { $value -> 5 | [0] { -empty -> 6 | [no] { -no } 7 | *[yes] { -yes } 8 | } 9 | *[1] { -yes } 10 | } 11 | 12 | -yes = Yes 13 | -no = No -------------------------------------------------------------------------------- /tests/data/ftl/validation4-en.ftl: -------------------------------------------------------------------------------- 1 | hello = Hello, { $name }! 2 | empty = Hello, World! 3 | bool = 4 | { $value -> 5 | [0] { $value -> 6 | [no] { -no } 7 | *[yes] { -yes } 8 | } 9 | *[1] { -yes } 10 | } 11 | 12 | -yes = Yes 13 | # -no = No -------------------------------------------------------------------------------- /tests/data/json/child-en.json: -------------------------------------------------------------------------------- 1 | { 2 | "normal": { 3 | "hello": "Hello, ::name::!", 4 | "empty": "Hello, World!", 5 | "bool": "::if value::Yes::else::No::end::" 6 | }, 7 | "getter": { 8 | "hello": "Hello, ::name::!", 9 | "empty": "Hello, World!", 10 | "bool": "::if value::Yes::else::No::end::" 11 | }, 12 | "const": { 13 | "hello": "Hello, ::name::!", 14 | "empty": "Hello, World!", 15 | "bool": "::if value::Yes::else::No::end::" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/data/json/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Hello, ::name::!", 3 | "empty": "Hello, World!", 4 | "bool": "::if value::Yes::else::No::end::" 5 | } 6 | -------------------------------------------------------------------------------- /tests/data/json/extended-en.json: -------------------------------------------------------------------------------- 1 | { 2 | "extended": "Extension!", 3 | "hello": "Hello, ::name::!", 4 | "empty": "Hello, World!", 5 | "bool": "::if value::Yes::else::No::end::" 6 | } 7 | --------------------------------------------------------------------------------