├── .gitattributes ├── .gitignore ├── .idea ├── .gitignore ├── .name ├── misc.xml ├── modules.xml └── vcs.xml ├── IntlFluent.iml ├── LICENSE ├── META6.json ├── README.md ├── lib ├── .precomp │ └── .lock ├── Fluent.pm6 └── Fluent │ ├── Actions.pm6 │ ├── Classes.pm6 │ ├── Functions.pm6 │ └── Grammar.pm6 ├── playground ├── en.ftl ├── es.ftl └── playground.p6 └── t ├── 01-Subtokens.t ├── 02-Subtag-specificity.t ├── 02-data ├── es-AR.ftl ├── es-CL.ftl ├── es-ES.ftl └── es.ftl ├── 03-Selectors.t ├── 03-data └── en.ftl ├── 04-Functions.t └── 04-data └── en.ftl /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files # 2 | ###################### 3 | .DS_Store 4 | .DS_Store? 5 | ._* 6 | .Spotlight-V100 7 | .Trashes 8 | ehthumbs.db 9 | Thumbs.db 10 | # precompiled files 11 | .precomp 12 | lib/.precomp 13 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | IntlFluent -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /IntlFluent.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Artistic License 2.0 2 | 3 | Copyright (c) 2000-2006, The Perl Foundation. 4 | 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | This license establishes the terms under which a given free software 11 | Package may be copied, modified, distributed, and/or redistributed. 12 | The intent is that the Copyright Holder maintains some artistic 13 | control over the development of that Package while still keeping the 14 | Package available as open source and free software. 15 | 16 | You are always permitted to make arrangements wholly outside of this 17 | license directly with the Copyright Holder of a given Package. If the 18 | terms of this license do not permit the full use that you propose to 19 | make of the Package, you should contact the Copyright Holder and seek 20 | a different licensing arrangement. 21 | 22 | Definitions 23 | 24 | "Copyright Holder" means the individual(s) or organization(s) 25 | named in the copyright notice for the entire Package. 26 | 27 | "Contributor" means any party that has contributed code or other 28 | material to the Package, in accordance with the Copyright Holder's 29 | procedures. 30 | 31 | "You" and "your" means any person who would like to copy, 32 | distribute, or modify the Package. 33 | 34 | "Package" means the collection of files distributed by the 35 | Copyright Holder, and derivatives of that collection and/or of 36 | those files. A given Package may consist of either the Standard 37 | Version, or a Modified Version. 38 | 39 | "Distribute" means providing a copy of the Package or making it 40 | accessible to anyone else, or in the case of a company or 41 | organization, to others outside of your company or organization. 42 | 43 | "Distributor Fee" means any fee that you charge for Distributing 44 | this Package or providing support for this Package to another 45 | party. It does not mean licensing fees. 46 | 47 | "Standard Version" refers to the Package if it has not been 48 | modified, or has been modified only in ways explicitly requested 49 | by the Copyright Holder. 50 | 51 | "Modified Version" means the Package, if it has been changed, and 52 | such changes were not explicitly requested by the Copyright 53 | Holder. 54 | 55 | "Original License" means this Artistic License as Distributed with 56 | the Standard Version of the Package, in its current version or as 57 | it may be modified by The Perl Foundation in the future. 58 | 59 | "Source" form means the source code, documentation source, and 60 | configuration files for the Package. 61 | 62 | "Compiled" form means the compiled bytecode, object code, binary, 63 | or any other form resulting from mechanical transformation or 64 | translation of the Source form. 65 | 66 | 67 | Permission for Use and Modification Without Distribution 68 | 69 | (1) You are permitted to use the Standard Version and create and use 70 | Modified Versions for any purpose without restriction, provided that 71 | you do not Distribute the Modified Version. 72 | 73 | 74 | Permissions for Redistribution of the Standard Version 75 | 76 | (2) You may Distribute verbatim copies of the Source form of the 77 | Standard Version of this Package in any medium without restriction, 78 | either gratis or for a Distributor Fee, provided that you duplicate 79 | all of the original copyright notices and associated disclaimers. At 80 | your discretion, such verbatim copies may or may not include a 81 | Compiled form of the Package. 82 | 83 | (3) You may apply any bug fixes, portability changes, and other 84 | modifications made available from the Copyright Holder. The resulting 85 | Package will still be considered the Standard Version, and as such 86 | will be subject to the Original License. 87 | 88 | 89 | Distribution of Modified Versions of the Package as Source 90 | 91 | (4) You may Distribute your Modified Version as Source (either gratis 92 | or for a Distributor Fee, and with or without a Compiled form of the 93 | Modified Version) provided that you clearly document how it differs 94 | from the Standard Version, including, but not limited to, documenting 95 | any non-standard features, executables, or modules, and provided that 96 | you do at least ONE of the following: 97 | 98 | (a) make the Modified Version available to the Copyright Holder 99 | of the Standard Version, under the Original License, so that the 100 | Copyright Holder may include your modifications in the Standard 101 | Version. 102 | 103 | (b) ensure that installation of your Modified Version does not 104 | prevent the user installing or running the Standard Version. In 105 | addition, the Modified Version must bear a name that is different 106 | from the name of the Standard Version. 107 | 108 | (c) allow anyone who receives a copy of the Modified Version to 109 | make the Source form of the Modified Version available to others 110 | under 111 | 112 | (i) the Original License or 113 | 114 | (ii) a license that permits the licensee to freely copy, 115 | modify and redistribute the Modified Version using the same 116 | licensing terms that apply to the copy that the licensee 117 | received, and requires that the Source form of the Modified 118 | Version, and of any works derived from it, be made freely 119 | available in that license fees are prohibited but Distributor 120 | Fees are allowed. 121 | 122 | 123 | Distribution of Compiled Forms of the Standard Version 124 | or Modified Versions without the Source 125 | 126 | (5) You may Distribute Compiled forms of the Standard Version without 127 | the Source, provided that you include complete instructions on how to 128 | get the Source of the Standard Version. Such instructions must be 129 | valid at the time of your distribution. If these instructions, at any 130 | time while you are carrying out such distribution, become invalid, you 131 | must provide new instructions on demand or cease further distribution. 132 | If you provide valid instructions or cease distribution within thirty 133 | days after you become aware that the instructions are invalid, then 134 | you do not forfeit any of your rights under this license. 135 | 136 | (6) You may Distribute a Modified Version in Compiled form without 137 | the Source, provided that you comply with Section 4 with respect to 138 | the Source of the Modified Version. 139 | 140 | 141 | Aggregating or Linking the Package 142 | 143 | (7) You may aggregate the Package (either the Standard Version or 144 | Modified Version) with other packages and Distribute the resulting 145 | aggregation provided that you do not charge a licensing fee for the 146 | Package. Distributor Fees are permitted, and licensing fees for other 147 | components in the aggregation are permitted. The terms of this license 148 | apply to the use and Distribution of the Standard or Modified Versions 149 | as included in the aggregation. 150 | 151 | (8) You are permitted to link Modified and Standard Versions with 152 | other works, to embed the Package in a larger work of your own, or to 153 | build stand-alone binary or bytecode versions of applications that 154 | include the Package, and Distribute the result without restriction, 155 | provided the result does not expose a direct interface to the Package. 156 | 157 | 158 | Items That are Not Considered Part of a Modified Version 159 | 160 | (9) Works (including, but not limited to, modules and scripts) that 161 | merely extend or make use of the Package, do not, by themselves, cause 162 | the Package to be a Modified Version. In addition, such works are not 163 | considered parts of the Package itself, and are not subject to the 164 | terms of this license. 165 | 166 | 167 | General Provisions 168 | 169 | (10) Any use, modification, and distribution of the Standard or 170 | Modified Versions is governed by this Artistic License. By using, 171 | modifying or distributing the Package, you accept this license. Do not 172 | use, modify, or distribute the Package, if you do not accept this 173 | license. 174 | 175 | (11) If your Modified Version has been derived from a Modified 176 | Version made by someone other than you, you are nevertheless required 177 | to ensure that your Modified Version complies with the requirements of 178 | this license. 179 | 180 | (12) This license does not grant you the right to use any trademark, 181 | service mark, tradename, or logo of the Copyright Holder. 182 | 183 | (13) This license includes the non-exclusive, worldwide, 184 | free-of-charge patent license to make, have made, use, offer to sell, 185 | sell, import and otherwise transfer the Package with respect to any 186 | patent claims licensable by the Copyright Holder that are necessarily 187 | infringed by the Package. If you institute patent litigation 188 | (including a cross-claim or counterclaim) against any party alleging 189 | that the Package constitutes direct or contributory patent 190 | infringement, then this Artistic License to you shall terminate on the 191 | date that such litigation is filed. 192 | 193 | (14) Disclaimer of Warranty: 194 | THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS "AS 195 | IS" AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED 196 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR 197 | NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL 198 | LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL 199 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 200 | DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, EVEN IF 201 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 202 | -------------------------------------------------------------------------------- /META6.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Intl::Fluent", 3 | "description": "A localization framework implementing Mozilla’s project Fluent", 4 | "version": "0.8.1", 5 | "perl": "6.*", 6 | "authors": [ 7 | "Matthew ‘Matéu’ Stephen STUCKWISCH " 8 | ], 9 | "auth": "github:alabamenhu", 10 | "depends": [ 11 | "Intl::LanguageTag", 12 | "Intl::UserLanguage", 13 | "Intl::Number::Plural", 14 | "Intl::Format::Number" 15 | ], 16 | "build-depends": [], 17 | "test-depends": [], 18 | "provides": { 19 | "Fluent": "lib/Fluent.pm6", 20 | "Fluent::Actions": "lib/Fluent/Actions.pm6", 21 | "Fluent::Classes": "lib/Fluent/Classes.pm6", 22 | "Fluent::Functions": "lib/Fluent/Functions.pm6", 23 | "Fluent::Grammar": "lib/Fluent/Grammar.pm6" 24 | }, 25 | "resources": [], 26 | "license": "Artistic-2.0", 27 | "tags": [ 28 | "Language", 29 | "Tag", 30 | "Localization", 31 | "Locale", 32 | "International", 33 | "Internationalization", 34 | "RF" 35 | ], 36 | "api": 1, 37 | "source-url": "git://github.com/alabamenhu/bcp47.git" 38 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | A Perl 6 / Raku module that implements Mozilla's Project Fluent. This is an 4 | implementation based on the design documents, but it is not actually a port. 5 | The idea is to provide both an interface and code that is maintainable, usable, 6 | and Raku-ish. 7 | 8 | **Note: This module is currently undergoing a top-to-bottom rewrite. It should be used with caution at the moment. Features should work, but if you supply specific options for number formatting (significant figures, etc) they may be slightly off.** 9 | 10 | ## Basic Usage 11 | 12 | ```perl6 13 | use Fluent; 14 | add-localization-basepath('localization'); 15 | add-localization-languages('en', 'es'); 16 | say localized('helloworld'); # ↪︎ "Hello World!" (if system set to English) 17 | say localized 'helloworld'; # ↪︎ "¡Hola mundo!" (if system set to Spanish) 18 | ``` 19 | 20 | If you store the result of a `localized` call, you’ll get a Hashy `Str`. That 21 | means you can use it like a `Str` (because it is one), but if the message 22 | has attributes, you can access it via the normal associative ways: 23 | 24 | ```perl6 25 | my $translation = localized 'greeting'; 26 | say $translation; # ↪︎ "Hello!" 27 | say $translation; #  ↪︎ "some related text" 28 | say $translation{'bar'}; #  ↪︎ "some other related text" 29 | ``` 30 | 31 | ## Subroutines 32 | 33 | When load the module, Fluent will export a handful of useful subroutines. You 34 | don't *have* to use them, but they implement an entire localization framework 35 | that only under rare circumstances would you want or need to handle manually. 36 | 37 | * **localized**(Str *$message-id*, Str *:$domain?*, *:$language?*, *:@languages?*, :*%variables?*, *%slurp-vars*) returns **Str** 38 | The function that you will use most often. In common use, you will only need to 39 | specify the `$message-id`, and maybe the `$domain` if your project uses them. 40 | If you need to pass variables, you can use the 41 | `%variables` named parameter, or alternative, specify the variables as 42 | additional named parameters (using `%variables` is only required if the 43 | name of the variable is one of the extant named parameters. If you do not pass 44 | any languages (which should be either `Str` or `LanguageTag`), the default 45 | languages will be used. 46 | * **add-localization-basepath**(Str *$path*, Str *:$domain?*, Bool *:$resource* = False, Bool, *:$lazy* = True) 47 | Adds the given path (directories need to end in `/`!) to the list of locations 48 | where `.ftl` files can be found. If used in a module, then pass the `:resource` 49 | adverb to have it search in the module’s resource folder. Lazy loading is 50 | enabled by default. Turning it off is mainly useful if you want to preparse 51 | everything during a precompilation phase. 52 | * **add-localization-basepaths**(Str *@paths*, Str *:$domain?*, Bool *:$resource* = False, Bool, *:$lazy* = True) 53 | Same as previous, but acts on a list of basepaths. 54 | * **add-localization-language**(*$language-tag*, Str *:$domain?*) 55 | Adds the given language to the list of languages supported (this cannot be 56 | automated because `%*RESOURCES` does not allow introspection of files). If 57 | any eager (non-lazy) basepaths were previously added, their associated `.ftl` 58 | files will be loaded immediately. The `$language-tag` may be either a `Str` 59 | in valid BCP47 format or a `LanguageTag` (available in the `Intl::BCP47` 60 | package) 61 | * **add-localization-language**(*@language-tags*, Str *:$domain?*) 62 | Same as previous, but acts on a list of language tags. 63 | * **set-localization-fallback**(Callable *&fallback*) 64 | If a localization cannot be found, the callable is called with two positional 65 | parameters: (1) the message ID and (2) the domain if applicable. Fluent will 66 | pass the correct number of parameters, so if you don't use domains, feel free 67 | to just use a single `Whatever` to make your life easy. You can also pass a 68 | `Str` if you want a static message. 69 | * **reset-localization-fallback**() 70 | Resets the fall back to the default. 71 | * **load-localization**(Str *$fluent-document*, *$language-tag*, Str *$domain?*) 72 | Loads the specified Fluent data (note: *not* a filename) for the given language 73 | tag (as a BCP47 `Str` or a `LanguageTag`), optionally in the given domain. Most 74 | useful for testing, not as useful in actual production. 75 | * **with-args**(*|c*) 76 | This function is mixes in the arguments into anything. Its primary use is for 77 | the `NUMBER` and `DATETIME` functions to pass complex formatting information 78 | in an easy to read manner. 79 | 80 | # Formatting and organization 81 | 82 | The file format is described on the [Project Fluent page](https://projectfluent.org). 83 | To use localization files, just provide the root path of the files which can 84 | be in your resources folder or locally stored on a disk. If you're writing a 85 | module, structure it like this: 86 | 87 | lib/ 88 | resources/ 89 | localization/ 90 | ast.ftl 91 | en.ftl 92 | es-ES.ftl ⬅︎ These two are bad design, 93 | es-MX.ftl ⬅︎ see usage notes. 94 | zh-Hant.ftl 95 | zh-Hans.ftl 96 | t/ 97 | LICENSE 98 | META6.json 99 | README.md 100 | 101 | To let Fluent know where the files are, give it the base file path onto 102 | which it will add language codes (which may end in a `/` or not, but I find it 103 | easiest to keep them all in a folder, but you may want them prefixed elsewise). 104 | 105 | ```perl6 106 | add-localization-basepath('localization/', :resources); 107 | ``` 108 | 109 | The `:resources` adverb lets the module know to look in the `%*RESOURCES` 110 | variable for the file. If the file is on the hard drive, don't use it, 111 | and just reference the file path as you would any other. For example, in another 112 | project where we've named files 'ui_en.ftl', 'ui_es-ES.ftl', etc, we might say: 113 | 114 | ```perl6 115 | add-localization-basepath('data/l10n/ui_'); 116 | ``` 117 | 118 | You also have an additional option to group the terms into various *domains*. 119 | This may be useful if you plan to handle several different 120 | sites/services/etc at once, and each one may have different texts for the same 121 | message id. So, imagining I had an HTTP server and a website called Fruitopia 122 | all about fruits, and another called Vegitania all about vegetables, with vastly 123 | different sets of text, we could load (and access them) by using the `:domain` 124 | argument: 125 | 126 | ```perl6 127 | add-localization-basepath($root ~ 'fruitopia/text/email/', :domain('fruit') ); 128 | add-localization-basepath($root ~ 'fruitopia/text/ui/', :domain('fruit') ); 129 | add-localization-basepath($root ~ 'fruitopia/text/store/', :domain('veggie')); 130 | add-localization-basepath($root ~ 'fruitopia/text/ui/', :domain('veggie')); 131 | ``` 132 | 133 | With this set up, using `localized('sitename', :domain('fruit'))` contained in the `ui/` 134 | directory would return something like **Fruitopia** but by changing the domain 135 | `veggie` we might get **Vegitania**. But because Fruitopia doesn't have any 136 | text for a store loaded, if we called `localized('buynow', :domain('fruit'))`, 137 | the text returned would be that defined by the fallback option. 138 | 139 | To define the fallback text, you can use either a string, some combination of 140 | strings and WhateverCode (`*`), or some other callable. If you use a `Callable` 141 | you might consider using two Whatevers or positional parameters to capture 142 | the domain as well. Using the previous example, here's the text that 143 | would be returned based on different fall back text: 144 | 145 | ```perl6 146 | set-localization-fallback('[No Localization Present]'); 147 | localized('buynow', :domain('fruit')); 148 | # ↪︎ [No Localization Present] 149 | 150 | set-localization-fallback('[MessageID:' ~ * ~ ']'); 151 | localized('buynow', :domain('fruit')); 152 | # ↪︎ [MessageID:buynow] 153 | 154 | set-localization-fallback('[﹖ ' ~ * ~ ' ← ' ~ *.uc ']'); 155 | localized('buynow', :domain('fruit')); 156 | # ↪︎ [﹖ buynow ← FRUIT] 157 | 158 | set-localization-fallback( { '[$^b:$^a??]' }; 159 | localized('buynow', :domain('fruit')); 160 | # ↪︎ [fruit:buynow??] 161 | ``` 162 | 163 | Be aware that the order of positional arguments is first the Message ID, 164 | second the domain (which is '' if no domain is specified). If the arity is 165 | greater than 2 then all other parameters will be passed a blank string, although 166 | the third one *may* in the future also receive a hash of variables being passed. 167 | 168 | # Language Usage Notes 169 | 170 | If the first thing you do with Fluent is pass a base file, Fluent won't do 171 | much of anything with it. Fluent also needs to know which languages you intend 172 | to support. Because the `resources` directory in modules is not able to be 173 | queried for files available, I made the decision to have the programmer tell 174 | Fluent which languages are available. To enable a language, simply pass it or 175 | various to the `add-localization-language` (single) or `add-localization-languages` 176 | (convenience, calls `add-localization-language` for each passed language) 177 | functions which take *either* a LanguageTag *or* a Str representing a valid BCP47 language tag. For the hypothetical module listed previously, we'd say: 178 | 179 | ```perl6 180 | add-localization-languages('ast', 'en', 'es-ES', 'es-MX', 'zh-Hant', 'zh-Hans'); 181 | ``` 182 | 183 | Once both languages and file paths have been loaded, only once there is a need 184 | for a language's localization files to be read will the `.ftl` be loaded and 185 | parsed. However, if you want the files to be read into memory immediately, 186 | you can use the `:!lazy` adverb: 187 | 188 | ```perl6 189 | add-localization-basepath('foo/') :!lazy; # FTL files for all enabled languages 190 | # will be loaded immediately, and will 191 | # load immediately for any languages 192 | # added in the future 193 | ``` 194 | 195 | This option is best suited when precompilation is beneficial so that the FTL 196 | files will be loaded once. 197 | 198 | To determine the best fit language, Fluent uses the match algorithm in the 199 | `Intl::BCP47` module on a *per message/term* basis. This means you can set a 200 | base English translation in the `en.ftl` file, and override specific terms or 201 | messages in an `en-GB` or `en-NZ` file. If the user prioritizes `en-NZ`, then 202 | Fluent will first look there. If the message is not there, then it will look 203 | in `en`, failing that, it will look (if enabled) the project default language 204 | and finally, failing all other options, provide the fall back text described 205 | above. 206 | 207 | Note that this means it is a *bad idea* to only include regional language tags 208 | without a base one. In the module example, there is a tag for `es-ES` and 209 | `es-MX`. For a user requesting `es-GT`, Fluent will not find any Guatemalan 210 | Spanish files, and so then will try `es`. But it *also* won't find that! At 211 | that point, it will just go to the next best choice (or the default or, worst 212 | case, the fallback). This is a result of the RFC4647 lookup method that BCP47 213 | implements. 214 | 215 | # Perl 6 implementation details 216 | 217 | This module makes use of mixins to match features of other implementations in a 218 | clean and intuitive manner. There are two main areas where they are used: 219 | 220 | ## The Hashy Str 221 | 222 | The `localized` routine always returns a `Str`, but if the message also has 223 | attributes, they are mixed in in a `Hash`-like manner and can be accessed as if 224 | the result were a `Hash`; 225 | 226 | ```perl6 227 | my $translation = localized('greeting'); 228 | say $translation; # ↪︎ "Hello!" 229 | say $translation; #  ↪︎ "some related text" 230 | say $translation{'bar'}; #  ↪︎ "some other related text" 231 | ``` 232 | 233 | ## Who doesn't like `but`s? 234 | 235 | To take advantage of the partial arguments, you can use the function `with-args` 236 | after a `but`. Functions that take arguments can then access them in addition 237 | to the localizer’s arguments. For example: 238 | 239 | ```perl6 240 | my $weight = 5 but with-args(:3minimum-fraction-digits); 241 | localized 'kilograms', :$weight; #  ↪︎ "It weighs 5.000 kg" 242 | ``` 243 | 244 | It can be done in one fell swoop, and actually ends up a bit cleaner (IMO) than 245 | the Javascript version (with only two parentheses at the end, rather than two 246 | parentheses and two brackets): 247 | 248 | ```javascript 249 | FluentBundle.format('proportion', {amount: FluentNumber(5, {minimumFractionDigits: 3, style: 'percent'})}) 250 | ``` 251 | ```perl6 252 | localized 'proportion', :amount( 5 but with-args(:3minimum-fraction-digits, :style) ); 253 | ``` 254 | 255 | (You may even want to do a quick `&format = &with-args` so you can make your 256 | code even prettier, *with-args* was chosen to be as generic as possible.) 257 | 258 | ## camelCase vs. kebab-case 259 | 260 | Fluent's built in functions `NUMBER` and `DATETIME` take arguments in camelCase. 261 | Because those aren't as natural to Perl 6 programmers (and aren’t what Intl::CLDR 262 | uses), you can *also* use kebab-case arguments. This *only* applies to 263 | arguments supplied code-side. Any arguments from the `.ftl` *must* use 264 | camelCase to ensure compatibility with other implementations. 265 | 266 | # Version history 267 | - 0.8.1 268 | - Minor adjustments for dependency chain changes. 269 | - 0.8 “Alcovy” 270 | - Added support for the `NUMBER` function (currency formatting will be available when Intl::CLDR supports it) 271 | - Improved handling for functions in general, including the ability for programs to add their own functions. 272 | - Updated test files. 273 | - 0.7 “Cheaha” 274 | - Updated documents substantially 275 | - Fixed a bug in inline block text 276 | - Fixed major bugs in variable references and term references 277 | - Corrected term attribute usage, particularly evident in selectors 278 | - Moved the CLDR Plural logic into a separate module (Intl::CLDR) where it is better suited. 279 | - Added an experimental feature Variable Term References which is not currently part of the standard to demonstrate proof of concept 280 | - 0.6 “Blackjack” 281 | - First reasonably usable version (missing NUMBER/DATE functions) 282 | - Localization file structure added 283 | - Messages/Terms are now tested based on `Intl::BCP47`'s lookup. 284 | - API should be mostly frozen at this point. 285 | - 0.5 286 | - First semi working version 287 | 288 | # Licensing and rights information 289 | This module is released under the Artistic License 2.0. 290 | The social media image (resources/logo.png) is released under CC-by 4.0, and incorporates 291 | the pieces (https://commons.wikimedia.org/wiki/File:Adonis_Blue_Butterfly.jpg, licensed under CC0 1.0) 292 | and -------------------------------------------------------------------------------- /lib/.precomp/.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alabamenhu/Fluent/33b59eab58863c37c43ba4b114bc1ee4961824e4/lib/.precomp/.lock -------------------------------------------------------------------------------- /lib/Fluent.pm6: -------------------------------------------------------------------------------- 1 | unit module Fluent; 2 | # see https://developer.mozilla.org/en-US/docs/Mozilla/Projects/L20n 3 | #use Fluent::Grammar; 4 | #use Fluent::Actions; 5 | use Fluent::Classes; 6 | use Intl::LanguageTag; 7 | use Intl::UserLanguage; 8 | 9 | 10 | class Domain { 11 | #################### 12 | # INTERNAL CLASSES # 13 | #################### 14 | # Container holds onto the messages and terms for a given language 15 | # BasePath 16 | class Container { 17 | has Message %.messages = (); 18 | has Term %.terms = (); 19 | has @.unloaded-files = (); 20 | 21 | proto method add ($) {*} 22 | multi method add (Message $message) { %.messages{$message.id} = $message } 23 | multi method add (Term $term ) { %.Terms\ { $term.id} = $term } 24 | 25 | # Immediately loads the FTL-formatted file into the container 26 | proto method load($){*} 27 | multi method load($file) { 28 | samewith $file.slurp if $file.IO.e; 29 | warn "Could not locate localization file at ", $file.absolute; 30 | } 31 | multi method load(Str $text) is default { 32 | use Fluent::Grammar; 33 | use Fluent::Actions; 34 | my @entries = FTL.parse($text ~ "\n", :actions(FTLActions)).made; 35 | for @entries -> $entry { 36 | given $entry { 37 | when Message { %.messages{$entry.identifier} = $entry } 38 | when Term { %.terms{$entry.identifier} = $entry } 39 | when Comment { @.comments.push: $entry } 40 | } 41 | } 42 | } 43 | method add-path ($path where IO::Path|Distribution::Resource, :$lazy = True) { 44 | $lazy 45 | ?? push @.unloaded-files, $path 46 | !! self.load($path.slurp); 47 | } 48 | # Both of these methods (message and term) also handle the lazy loading. 49 | # If the message/term isn't found, then each as-yet unloaded file is 50 | # sequentially loaded and processed, and the process is stopped as soon 51 | # as a message/term is found, leave the remaining ones undone. 52 | method message ($id) { 53 | return %.messages{$id} if %.messages{$id}:exists; 54 | while my $file = @.unloaded-files.shift { 55 | self.load($file.slurp); 56 | return %.messages{$id} if %.messages{$id}:exists; 57 | } 58 | Nil 59 | } 60 | method term($id) { 61 | return %.terms{$id} if %.terms{$id}:exists; 62 | while my $file = @.unloaded-files.shift { 63 | self.load($file.slurp); 64 | return %.terms{$id} if %.terms{$id}:exists; 65 | } 66 | Nil 67 | } 68 | } 69 | class BasePath { 70 | has Str $.path = ''; 71 | has Bool $.resource = False; 72 | has Bool $.lazy = True; 73 | method file (Str() $filename) { 74 | $!resource 75 | ?? %?RESOURCES{$!path ~ $filename} 76 | !! IO::Path.new($!path ~ $filename) 77 | } 78 | } 79 | 80 | has Str $.id; # probably unnecessary 81 | has Container %.languages; 82 | has BasePath @.base-paths; 83 | 84 | method message($id, @languages) { 85 | my @candidates = lookup-language-tags(%.languages.keys, @languages); 86 | for @candidates -> $candidate { 87 | next unless my $message = %.languages{$candidate}.message($id); 88 | return $message; 89 | } 90 | Nil 91 | } 92 | method term($id, @languages) { 93 | my @candidates = lookup-language-tags(%.languages.keys, @languages); 94 | for @candidates -> $candidate { 95 | next unless my $message = %.languages{$candidate}.term($id); 96 | return $message; 97 | } 98 | Nil 99 | } 100 | 101 | method load (Str $text, $language) { 102 | %.languages{$language} = Container.new unless %.languages{$language}:exists; 103 | %.languages{$language}.load($text); 104 | } 105 | 106 | method add-basepath (Str() $path, Bool :$resource = False, :$lazy = True) { 107 | # TODO check if paths are already loaded and warn 108 | # Pass the new basepath to all existing languages and store it for 109 | # languages that added later; 110 | my $basepath = BasePath.new(:$path, :$resource); 111 | %.languages{$_}.add-path($basepath.file($_ ~ '.ftl')) for %.languages.keys; 112 | push @.base-paths, $basepath; 113 | } 114 | method add-language (Str() $language-tag) { 115 | # TODO check if langauge is already made and warn 116 | # Create a container, and populate it with all existing base paths. 117 | my $language = Container.new; 118 | $language.add-path: $_.file($language-tag ~ '.ftl') for @.base-paths; 119 | %.languages{$language-tag} = $language; 120 | } 121 | 122 | } 123 | 124 | class LocalizationManager is export { 125 | use Intl::UserLanguage; 126 | 127 | has Domain %!domains; 128 | has Domain $!default-domain = Domain.new; 129 | 130 | has &!fallback-message = ( '[' ~ * ~ '|' ~ *.uc ~ ']' ); #[DOMAIN:message] 131 | has @.default-languages = user-languages(); # Intl::UserLanguage 132 | 133 | method localized( 134 | Str $message-id, 135 | :$domain = Nil, 136 | :$language, 137 | :@languages is copy = () , 138 | :$attribute = Nil, 139 | :%variables, 140 | *%slurp-vars --> Str) { 141 | 142 | # If $language is defined it is folded into @languages as the top pick. 143 | # Defaults are used only if no languages are provided. 144 | @languages.prepend: LanguageTag.new($language) if $language; 145 | @languages = @!default-languages if @languages == 0; 146 | 147 | # The %variables is for when someone has stored values or if they need to 148 | # use one of the reserved terms 'language', 'languages', 'attribute' or 149 | # 'variables', or if they just want to pass a stored list of variables 150 | %slurp-vars{%variables.keys} = %variables.values if %variables; 151 | my %*VARIABLES = %slurp-vars; 152 | my @*LANGUAGES = @languages; 153 | # Redirects find-message and find-term to this object 154 | my $*MANAGER = self; 155 | 156 | # Get the message and return fallback if no message (Nil) found 157 | if my $message = self.find-message($message-id, $domain, :@languages) { 158 | return $message.format(:$attribute, :variables(%slurp-vars)) 159 | } else { 160 | self.fallback($message-id, $domain); 161 | } 162 | } 163 | 164 | method find-message( 165 | $id, 166 | $domain-id = Nil, # ⬇︎ dyanmic variable allows for cleaner nesting 167 | :@languages = (@*LANGUAGES // @!default-languages)) { 168 | self.domain($domain-id).message: $id, @languages; 169 | } 170 | method find-term( 171 | $id, 172 | $domain-id = Nil, # ⬇︎ dyanmic variable allows for cleaner nesting 173 | :@languages = (@*LANGUAGES // @!default-languages)) { 174 | self.domain($domain-id).term: $id, @languages; 175 | } 176 | 177 | method load(Str $text, $language-tag, $domain = "") { 178 | self.domain($domain).load($text, $language-tag); 179 | } 180 | method add-basepath(Str $path, :$resource = False, :$lazy = True, :$domain = "") { 181 | self.domain($domain).add-basepath($path, :$resource, :$lazy); 182 | } 183 | method add-basepaths(*@path where .map(*.isa: Str), :$resource = False, :$lazy = True, :$domain = "") { 184 | self.domain($domain).add-basepath($_, :$resource, :$lazy) for @path; 185 | } 186 | method add-language($language-tag where Str|LanguageTag, :$domain = "") { 187 | self.domain($domain).add-language($language-tag); 188 | } 189 | multi method add-languages(*@language-tags, :$domain = "") { 190 | self.add-language($_, :$domain) for @language-tags; 191 | } 192 | multi method add-languages(@language-tags, :$domain = "") { 193 | self.add-language($_, :$domain) for @language-tags; 194 | } 195 | 196 | method domain ($domain-id) { 197 | return $!default-domain unless $domain-id; 198 | return %!domains{$domain-id} if %!domains{$domain-id}:exists; 199 | return %!domains{$domain-id} = Domain.new; 200 | } 201 | 202 | #################### 203 | # FALLBACK METHODS # 204 | #################### 205 | proto method set-fallback(|) {*} 206 | multi method set-fallback (&msg ) is default {&!fallback-message = &msg } 207 | multi method set-fallback (Str() $msg ) {&!fallback-message = {$msg} } 208 | method reset-fallback { &!fallback-message = '[' ~ * ~ '|' ~ *.uc ~ ']' } 209 | method fallback ($msg-id is copy = Nil, $domain is copy = Nil) { 210 | # Fallback callables where {.arity > 2} are passed blank strings for the 211 | # rest of their arguments. Those are currently reserved for future use. 212 | $msg-id = $msg-id // ''; 213 | $domain = $domain // ''; 214 | given &!fallback-message.arity { 215 | &!fallback-message(|($msg-id, $domain, |('' xx $_ -2))[0..^$_]); 216 | } 217 | } 218 | } 219 | 220 | # All of the subs in this module are effectively convenience methods to access 221 | # a special LocalizationManager which allows for much easier handling of the 222 | # get-message/term methods. Advanced users can have multiple LM's, but for 99% 223 | # of users, we make them blissfully aware that this class even exists. 224 | my $default = LocalizationManager.new(); 225 | 226 | sub localized (|c) is export { $default.localized: |c } 227 | sub load-localization (|c) is export { $default.load: |c } 228 | sub set-localization-fallback (|c) is export { $default.set-fallback: |c } 229 | sub reset-localization-fallback (|c) is export { $default.reset-fallback: |c } 230 | sub add-localization-basepath (|c) is export { $default.add-basepath: |c } 231 | sub add-localization-language (|c) is export { $default.add-language: |c } 232 | sub add-localization-languages (|c) is export { $default.add-languages: |c } 233 | sub ddd is export { $default } 234 | #sub files is export { 235 | # %?RESOURCES; 236 | #} 237 | 238 | sub with-args(*@positional, *%named) is export { 239 | CodeArguments.new(:@positional, :%named) 240 | } 241 | -------------------------------------------------------------------------------- /lib/Fluent/Actions.pm6: -------------------------------------------------------------------------------- 1 | unit class FTLActions; 2 | use Fluent::Classes; 3 | 4 | method TOP ($/) { 5 | my @entries = $.map(*.made); 6 | my @result = (); 7 | my $comment; 8 | 9 | while @entries { 10 | given @entries.head { 11 | when Comment { 12 | # Comments need to be merged if they are of the same type 13 | # and immediately follow each other 14 | if $comment { 15 | if $comment.type == @entries.head.type { 16 | merge $comment: @entries.shift; 17 | } else { 18 | # Not the same, so place the previously tracked comment into the 19 | # results, and place the new one on the stack; 20 | push @result: $comment; 21 | $comment = @entries.shift; 22 | } 23 | } else { 24 | # no previously stored one 25 | $comment = @entries.shift; 26 | } 27 | } 28 | default { 29 | if $comment { 30 | # If we tracked a comment, add it here iff type 1. Otherwise it's 31 | # technically a stand alone and we can either ignore or add it to 32 | # the result pile (we do the latter for now). Eventually we may want 33 | # to all the comment objects, if we ever decide to add in line numbers, 34 | # etc for editing / debugging. 35 | $comment.type == 1 36 | ?? (@entries.head.comment = $comment.text) 37 | !! push @result: $comment; 38 | $comment = Nil; 39 | } 40 | push @result: @entries.shift; 41 | } 42 | } 43 | } 44 | make @result; 45 | } 46 | 47 | method comment-line ($/) { 48 | my $type = $0.chars; 49 | my $text = $1 ?? $1.Str !! ''; 50 | make Comment.new(:$type, :$text); 51 | } 52 | method junk ($/) { make Junk.new( :text($/.Str )) } 53 | method identifier ($/) { make Identifier.new(:text($/.Str)) } 54 | 55 | 56 | method quoted-char:sym ($/) { make $0.Str } 57 | method quoted-char:sym ($/) { make :16($0.Str).chr } 58 | method quoted-char:sym ($/) { make $.Str } 59 | 60 | 61 | method entry ($/) { 62 | my $entry = $.made // $.made // $.made; 63 | make $entry; 64 | } 65 | method message ($/) { 66 | my $identifier = $.Str; 67 | my @pre-patterns = $.made; 68 | my @patterns = @pre-patterns.shift; 69 | 70 | # this merge still isn't quite correct -- identations need to be correctly 71 | # taken into account and they aren't, but that logic is fairly complicated 72 | # and I'm not sure if it's best handled here or in Messages's creation method 73 | # Single pass processing is NOT possible. 74 | while my $foo = @pre-patterns.shift { 75 | if $foo ~~ BlockText && @patterns.tail ~~ BlockText|InlineText { 76 | @patterns.tail.merge($foo) 77 | }else{ 78 | push @patterns, $foo; 79 | } 80 | } 81 | my @attributes = $.map(*.made); 82 | make Message.new(:$identifier, :@patterns, :@attributes); 83 | } 84 | method term ($/) { 85 | my $identifier = $.Str; 86 | my @patterns = $.made; 87 | my @attributes = $.map(*.made); 88 | make Term.new(:$identifier, :@patterns, :@attributes); 89 | } 90 | 91 | method junk-line ($/ ) { make Junk.new()} 92 | method attribute ($/) { 93 | my $identifier = $.Str; 94 | my @pattern = $.made; 95 | make Attribute.new(:$identifier, :@pattern); 96 | } 97 | 98 | 99 | method pattern ($/) { 100 | make $.map(*.made); 101 | } 102 | 103 | # This should formally be $.map(*.Str).join) but since it's 104 | # just raw text, we can pass the matched text as such 105 | method pattern-element:sym ($/) { 106 | my $text = $/.Str; 107 | make InlineText.new(:$text); 108 | } 109 | 110 | method pattern-element:sym ($/) { 111 | my $text = $.Str; # guaranteed 112 | $text ~= $.Str if $; 113 | my $indent = .chars; 114 | make BlockText.new(:$text, :$indent); 115 | } 116 | 117 | method pattern-element:sym ($/) { 118 | make ($ || $).made; 119 | } 120 | method pattern-element:sym ($/) { 121 | make ($ || $).made; 122 | } 123 | method inline-expression ($/) { 124 | make $.made 125 | // $.made 126 | // $.made 127 | // $>; 128 | } 129 | 130 | method string-literal ($/) { 131 | make StringLiteral.new(:text($.map(*.made).join(''))); 132 | } 133 | method number-literal ($/){ 134 | my $sign = $0 ?? $0.Str !! "+"; 135 | my $integer = $1.Str; 136 | my $decimal = $2 ?? $2.Str !! ""; 137 | my $original = $/; 138 | make NumberLiteral.new(:$sign, :$integer, :$decimal, :$original); 139 | } 140 | 141 | # THERE ARE FOUR REFERENCE EXPRESSION TYPES 142 | # 1: function, generally built in: abc() 143 | # 2: message, only optional attributes: abc[.foo] 144 | # 3: term, optional attributes or args: -abc[.foo][(bar)] 145 | # 4: variable, very basic: $foo 146 | method reference-expression:sym ($/){ 147 | my $identifier = $.Str; 148 | my @arguments = $.made<>; 149 | make FunctionReference.new(:$identifier, :@arguments); 150 | } 151 | method reference-expression:sym ($/){ 152 | my $identifier = $; 153 | my $attribute = $ ?? $.made !! ""; 154 | make MessageReference.new(:$identifier, :$attribute); 155 | } 156 | method reference-expression:sym ($/){ 157 | my $identifier = $.Str; 158 | my $attribute = $ ?? $.made !! ""; 159 | my @arguments = $ ?? $.made !! (); 160 | make TermReference.new(:$identifier, :$attribute, :@arguments); 161 | } 162 | # Experimental, for use as an example with the issue at 163 | # https://github.com/projectfluent/fluent/issues/80 164 | method reference-expression:sym ($/){ 165 | my $identifier = $.Str; 166 | my $attribute = $ ?? $.made !! ""; 167 | my @arguments = $ ?? $.made !! (); 168 | # possible bug, doing $.made results in [(Any)], so passes the 169 | # definedor operator check, and thus $.made // () doesn't 170 | # work as expected 171 | make VariableTermReference.new(:$identifier, :$attribute, :@arguments); 172 | } 173 | 174 | method reference-expression:sym ($/) { 175 | my $identifier = $.Str; 176 | make VariableReference.new(:$identifier); 177 | } 178 | 179 | method attribute-accessor ($/){ 180 | make $.Str; 181 | } 182 | method call-arguments ($/) { make $.made; } 183 | method argument-list ($/) { make $.map: *.made; } 184 | method argument ($/) { 185 | if (?$) { 186 | make PositionalArgument.new(:argument($.made)); 187 | } else { 188 | make $.made 189 | } 190 | } 191 | method named-argument ($/) { 192 | my $identifier = $.Str; 193 | my $value = ($0 // $0).made; 194 | make NamedArgument.new(:$identifier, :$value) 195 | } 196 | method select-expression ($/){ 197 | my $selector = $.made; 198 | my %variant-list = $.made; 199 | my @others = %variant-list<>; 200 | my $default = %variant-list; 201 | my %variants; 202 | say %variant-list; 203 | for %variant-list<> -> $variant { 204 | if $variant.identifier ~~ NumberLiteral { 205 | # This allows for matching both the value (via P6's internal Num->Str formatter) 206 | # and also via String matching. Consider a number like +5, which should match 207 | # 5.0 by value and '+5' the string as well. 208 | %variants{$variant.identifier.value} = $variant; 209 | %variants{$variant.identifier.text} := %variants{$variant.identifier.value}; 210 | }else{ 211 | %variants{$variant.identifier} = $variant 212 | } 213 | } 214 | if $default.identifier ~~ NumberLiteral { 215 | say "DEFAULT IDENTIFIER IS"; 216 | # See text above about the numeric vs string match 217 | %variants{$default.identifier.value} := $default; 218 | %variants{$default.identifier.text} := $default; 219 | }else{ 220 | %variants{$default.identifier} := $default 221 | } 222 | 223 | say %variants; 224 | make Select.new(:$selector, :$default, :@others, :%variants); 225 | } 226 | method variant-list ($/){ 227 | my @variants = $.map(*.made); 228 | my $default = $.made; 229 | make {others => @variants, default => $default}; 230 | } 231 | method variant ($/) { 232 | my $identifier = $.made; 233 | my @patterns = $.made; 234 | make Variant.new(:!default, :$identifier, :@patterns); 235 | } 236 | method default-variant ($/) { 237 | my $identifier = $.made; 238 | my @patterns = $.made; 239 | make Variant.new(:default, :$identifier, :@patterns); 240 | } 241 | method variant-key ($/) { 242 | my $identifier; 243 | if $0 { 244 | $identifier = $0.made; 245 | } else { 246 | $identifier = $0.made; 247 | } 248 | make $identifier; 249 | } 250 | -------------------------------------------------------------------------------- /lib/Fluent/Classes.pm6: -------------------------------------------------------------------------------- 1 | #use Fluent::Number; 2 | use Intl::LanguageTag; 3 | use Intl::Number::Plural; 4 | 5 | sub StrHash ($s, %h --> Str) { 6 | $s but ( 7 | %h, 8 | role { 9 | method AT-KEY(|c) { self.Hash.AT-KEY(|c)} 10 | method EXISTS(|c) { self.Hash.EXISTS(|c)} 11 | } 12 | ); 13 | } 14 | 15 | # role HasLocal { has %!h handles ; }; my $s = "Hello" but HasLocal; 16 | 17 | class Message is export { 18 | has $.identifier; 19 | has @.patterns; 20 | has @.attributes; 21 | has $.comment is rw = "" ; 22 | multi method gist (::?CLASS:U:) { "[Ƒ›Message]" } 23 | multi method gist (::?CLASS:D:) { "[Ƒ›Msg:$.identifier]" } 24 | 25 | method format (:$attribute = Nil, :%variables) { 26 | my $primary = @.patterns.map(*.format(:$attribute, :%variables)).join; 27 | my %secondary = gather { 28 | take ($_.identifier => $_.format(:$attribute, :%variables)) for @.attributes; 29 | } 30 | return StrHash($primary, %secondary); 31 | } 32 | } 33 | 34 | class Term is export { 35 | has $.identifier; 36 | has @.patterns; 37 | has @.attributes; 38 | has $.comment is rw = "" ; 39 | multi method gist (::?CLASS:U:) { "[Ƒ›Term]" } 40 | multi method gist (::?CLASS:D:) { "[Ƒ›Term:$.identifier" } 41 | method format (:$attribute = Nil, :%variables = ()) { 42 | if $attribute { 43 | # Need to get a specific attribute 44 | # These should probably be stored into a hash for quicker access in 45 | # the future, as their order doesn't matter 46 | for @!attributes { 47 | return .format(:attribute(Nil), :%variables) 48 | if .identifier eq $attribute; 49 | } 50 | return "-$!identifier" ~ ".$attribute"; # could not find it; the better 51 | # default might be the message? 52 | } else { 53 | # Return the main attribute. Terms attributes are considered hidden from 54 | # code so we only need to return the primary pattern, and not any 55 | # attributes in a StrHash like is done with Messages. If the standard 56 | # changes, then modify this section to match Messages' format 57 | return @.patterns.map(*.format(:$attribute, :%variables)).join; 58 | } 59 | } 60 | } 61 | 62 | class Attribute is export { 63 | has $.identifier; 64 | has @.pattern; 65 | has $.comment is rw = "" ; 66 | multi method gist (::?CLASS:U:) { "[Ƒ›Attribute]" } 67 | multi method gist (::?CLASS:D:) { "[Ƒ›Attr:$.identifier]" } 68 | method format (:$attribute) { 69 | @.pattern.map(*.format(:$attribute)).join; 70 | } 71 | } 72 | 73 | role Pattern { 74 | method format() { ... } 75 | } 76 | role Argument { ; 77 | # method argument-value() { ... } 78 | } 79 | 80 | 81 | class BlockText does Pattern does Argument { 82 | has Str $.text is rw; 83 | multi method gist (::?CLASS:U:) { '[Ƒ›BlockText]' } 84 | multi method gist (::?CLASS:D:) { '[Ƒ›BTxt:' ~ $.text.substr(0,7) ~ '…]' } 85 | method format (:$attribute = "", :%variables = ()) { 86 | $!text; 87 | } 88 | method merge ($it) { 89 | $!text ~= "\n" ~ $it.text; 90 | } 91 | } 92 | class InlineText does Pattern does Argument { 93 | has Str $.text is rw; 94 | multi method gist (::?CLASS:U:) { '[Ƒ›InlineText]' } 95 | multi method gist (::?CLASS:D:) { '[Ƒ›ITxt:' ~ $.text.substr(0,7) ~ '…]' } 96 | method format (:$attribute, :@arguments) { 97 | $!text; 98 | } 99 | multi method merge (InlineText $it) { $.text ~= $it.text; } 100 | multi method merge (BlockText $it) { $.text ~= "\n" ~ $it.text; } 101 | } 102 | 103 | # placeable ---> select expression 104 | # placeable -+-> reference expression 105 | # +-> placeable 106 | # probably not needed any tbh 107 | class Placeable does Pattern { 108 | has $.type = 1; # select or inline 109 | method format() { ... } 110 | } 111 | 112 | class PositionalArgument does Argument { 113 | has Pattern $.argument; 114 | method argument-value(|c) { 115 | return $.argument.format(|c); 116 | } 117 | method gist (::?CLASS:D:) { 118 | '[Ƒ›PosArg:' ~ $.argument.gist ~ ']' 119 | } 120 | } 121 | 122 | class NamedArgument does Argument { 123 | has Str $.identifier; 124 | has $.value; # Literal Role? 125 | method argument-value { 126 | return $.value.format; 127 | } 128 | method gist (::?CLASS:D:) { 129 | '[Ƒ›NamedArg:' ~ $.identifier ~ ":" ~ $.value.gist ~ ']' 130 | } 131 | } 132 | 133 | 134 | # not done 135 | class FunctionReference is Placeable does Pattern does Argument { 136 | has $.identifier; 137 | has @.arguments; 138 | multi method gist (::?CLASS:U:) { '[Ƒ›FunctionReference]' } 139 | multi method gist (::?CLASS:D:) { '[Ƒ›FuncRef:' ~ $.identifier.lc ~ ']'} 140 | method format(|c) { 141 | use Fluent::Functions; 142 | 143 | my @positionals = @.arguments.grep(* ~~ PositionalArgument).map(*.argument-value(|c)); 144 | my %named; 145 | @.arguments.grep(* ~~ NamedArgument).map({ %named{.identifier} = .argument-value(|c)}); 146 | 147 | if $.identifier eq "DATE" { 148 | return '[date]'; 149 | }elsif $.identifier eq "NUMBER" { 150 | return function('NUMBER').(|@positionals, |%named); 151 | } 152 | } 153 | } 154 | 155 | class VariableReference is Placeable does Pattern does Argument { 156 | has $.identifier; 157 | multi method gist (::?CLASS:U:) { "[Ƒ›VariableReference]" } 158 | multi method gist (::?CLASS:D:) { "[Ƒ›VarRef:$.identifier]" } 159 | method format (:$attribute = "", :%variables) { 160 | try { return %variables{$.identifier}} 161 | '$' ~ $.identifier 162 | } 163 | } 164 | 165 | # deprecated because Messages shouldn't be referenced from other messages anymore 166 | class MessageReference is Placeable does Pattern does Argument { 167 | has $.identifier; 168 | has $.attribute; 169 | multi method gist (::?CLASS:D:) { "[Ƒ›MessageReference" } 170 | multi method gist (::?CLASS:D:) { "[Ƒ›MsgRef:$.identifier]" } 171 | method format { 172 | $*MANAGER.find-message($.identifier).format(:$.attribute) 173 | # $*MESSAGES{:$.attribute}.format 174 | } 175 | } 176 | 177 | class TermReference is Placeable does Pattern does Argument { 178 | has $.identifier; 179 | has $.attribute; 180 | has @.arguments; 181 | multi method gist (::?CLASS:U:) { "[Ƒ›TermReference]" } 182 | multi method gist (::?CLASS:D:) { "[Ƒ›TermRef:$.identifier]" } 183 | method format (:$attribute, :%variables) { 184 | my %new-vars; 185 | for @.arguments { 186 | %new-vars{$_.identifier} = $_.value.format(:$attribute, :%variables); 187 | } 188 | $*MANAGER.find-term($.identifier).format(:$.attribute, :variables(%new-vars)); 189 | } 190 | } 191 | 192 | # This is a test class to go with a proposed feature on github 193 | # at https://github.com/projectfluent/fluent/issues/80 194 | class VariableTermReference is Placeable does Pattern does Argument { 195 | has $.identifier; 196 | has $.attribute; 197 | has @.arguments; 198 | multi method gist (::?CLASS:U:) { "[Ƒ›VariableTermReference]" } 199 | multi method gist (::?CLASS:D:) { "[Ƒ›VarTermRef:$.identifier]" } 200 | method format (:$attribute, :%variables) { 201 | note "\e[31mƑluent: Variable Term References are \e[1m\e[91mexperimental\e[0m\e[31m and not compatible with other systems.\e[39m" unless $++; 202 | my %new-vars = (); 203 | %new-vars{$_.identifier} = $_.value.format(:$attribute, :%variables) for @.arguments; 204 | $*MANAGER 205 | .find-term(%*VARIABLES{$.identifier}) 206 | .format(:$.attribute, :variables(%new-vars)) 207 | } 208 | } 209 | 210 | 211 | class Comment is export { 212 | has $.type; 213 | has $.text is rw; 214 | 215 | multi method gist (::?CLASS:U:) { '[Ƒ›Comment]' } 216 | multi method gist (::?CLASS:D:) { 217 | '[Ƒ›' 218 | ~ do given $.type { 219 | when 1 { "¹" } 220 | when 2 { "²" } 221 | when 3 { "³" } 222 | } ~ ':' ~ $.text.substr(0,7) ~ '…]' 223 | } 224 | 225 | method kind { 226 | return do given $.type { 227 | when 1 { "Simple"} 228 | when 2 { "Group"} 229 | when 3 { "File"} 230 | } 231 | } 232 | 233 | method merge (Comment $c) { 234 | die unless $c.type == $.type; 235 | $.text ~= $c.text; 236 | } 237 | } 238 | 239 | 240 | role Literal { 241 | method format { ... } # there is a guarantee that no recursion is possible 242 | } 243 | 244 | class Identifier does Literal does Pattern { 245 | has Str $.text; 246 | multi method gist (::?CLASS:D:) { "[Ƒ›ID:$.text]" } 247 | multi method gist (::?CLASS:U:) { '[Ƒ›Identifier]' } 248 | method Str { $.text } 249 | method format { $.text } 250 | } 251 | 252 | 253 | class StringLiteral does Literal does Pattern { 254 | has Str $.text; 255 | multi method gist (::?CLASS:D:) { '[Ƒ›StrLit:' ~ $.text ~ '”' } 256 | multi method gist (::?CLASS:U:) { '[Ƒ›StringLiteral]' } 257 | method format { return $.text } 258 | } 259 | 260 | 261 | class NumberLiteral does Literal does Pattern { 262 | has Cool $.plusminus = 1; 263 | has Str $.integer; 264 | has Str $.decimal; 265 | has Str $.text; # what it was derived from; 266 | has Numeric $.value; 267 | 268 | multi method gist (::?CLASS:U:) { '[Ƒ›NumberLiteral' } 269 | multi method gist (::?CLASS:D:) { return '[Ƒ›NumLit:' ~ ($.plusminus == 1 ?? '+' !! '-') ~ $.integer ~ '.' ~ $.decimal ~ ']' } 270 | 271 | # Probably 90% of this is garbage and needs to be cleaned up. 272 | # The number literal is effectively matched as a string 273 | method new (:$sign, :$integer, :$decimal) { 274 | my $plusminus = $sign eq "-" ?? -1 !! 1; 275 | my $value = ((($integer // '0') ~ ('.' ~ $decimal if $decimal ne '')) * $plusminus).Numeric; 276 | my $text = $sign ~ $integer ~ ("." ~ $decimal if ?$decimal); 277 | self.bless(:$plusminus, :$integer, :$decimal, :$text, :$value); 278 | } 279 | method format { return $.text } 280 | } 281 | 282 | class Variant { 283 | has Bool $.default; 284 | has $.identifier; 285 | has @.patterns; 286 | multi method gist (::?CLASS:U:) { '[Ƒ›Variant]' } 287 | multi method gist (::?CLASS:D:) { 288 | '[Ƒ›' ~ ('Def' if $.default) ~ 'Vrnt:' 289 | ~ ($.identifier ~~ NumberLiteral ?? $.identifier.text !! $.identifier) ~ "]" 290 | } 291 | method format (:$attribute) { 292 | @.patterns.map(*.format(:$attribute)).join; 293 | } 294 | } 295 | 296 | class Select is Placeable does Pattern { 297 | has $.selector; 298 | has $.default; 299 | has @.others; # this should be redone in a hash, binding the default to the hash TODO 300 | has %.variants; 301 | 302 | multi method gist (::?CLASS:U:) { '[Ƒ›Select]' } 303 | multi method gist (::?CLASS:D:) { '[Ƒ›Sel:' ~ (@.others.elems + 1) ~ ']' } 304 | method format (:$attribute = "", :%variables = ()) { ## todo check string vs number 305 | use Intl::Number::Plural; 306 | my $selector = $.selector.format(:$attribute, :%variables); 307 | 308 | # Check first for exact match 309 | .format(:$attribute, :%variables).return with %.variants{$selector}; 310 | 311 | if is-numeric $selector -> $number { 312 | # [1] Check if the number form exists 313 | # (for example, input of +5.0 can match [5] in this block) 314 | # [2] Check for plural forms 315 | .format(:$attribute, :%variables).return with %.variants{$number}; 316 | my $plural = plural-count($selector, :language(@*LANGUAGES.head)); 317 | .format(:$attribute, :%variables).return with %.variants{$plural}; 318 | } 319 | 320 | # When all else fails, default 321 | $.default.format(:$attribute, :%variables); 322 | } 323 | 324 | sub is-numeric ($x) { 325 | try { 326 | CATCH { return False } 327 | return $x.Numeric but True; 328 | } 329 | } 330 | } 331 | 332 | class CodeArguments { 333 | has @.positional; 334 | has %.named; 335 | } 336 | 337 | 338 | class Junk is export { 339 | has Str $.text; 340 | } 341 | -------------------------------------------------------------------------------- /lib/Fluent/Functions.pm6: -------------------------------------------------------------------------------- 1 | unit module Functions; 2 | 3 | my %functions; 4 | 5 | sub function($name) is export { 6 | return %functions{$name} if %functions{$name}:exists; 7 | } 8 | sub add-function($name,&code) { 9 | %functions{$name} = &code; 10 | } 11 | 12 | ############################# 13 | # FLUENT BUILT IN FUNCTIONS # 14 | ############################# 15 | 16 | my &number = sub ($number, *%options) { 17 | use Intl::Format::Number; 18 | use Intl::Number::Plural; 19 | my $language = @*LANGUAGES.head; 20 | 21 | # TODO : handle currency (not currently available in Intl:CLDR) 22 | my $style = %options