diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cd8eb86 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..33d9780 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/PULL_REQUEST_TEMPLATE.md export-ignore +/ISSUE_TEMPLATE.md export-ignore +/phpcs.xml.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/docs export-ignore diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..502dbe7 --- /dev/null +++ b/.github/workflows/php-cs-fixer.yml @@ -0,0 +1,23 @@ +name: Check & fix styling + +on: [push] + +jobs: + php-cs-fixer: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Run PHP CS Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --config=.php-cs-fixer.dist.php --allow-risky=yes + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..d5adb74 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,30 @@ +on: push +name: CI +jobs: + phpunit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['7.0', '7.1', '7.2', '7.3', '7.4'] + + name: PHP ${{ matrix.php-version }} + + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: none + + - name: composer install + run: | + composer install + - name: Run PHPUnit + run: | + set -e && composer test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d8abcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.idea +/vendor +/.phpunit.cache +/tests/*.json +composer.lock +.php_cs.cache +.phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..7c35c8b --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,39 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6c9f609 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to `fbt` will be documented in this file. + +Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## v3.0 - 2022-02-18 + +### Added +- PHP Internationalization Framework for PHP 7. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac1378d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is an open source project, and as such, the maintainers use their free time to build and maintain it. +The code is freely available and can be used, forked and modified. + +Please be considerate towards maintainers when raising issues or presenting pull requests. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skill sets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## How to submit changes? + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. +- Use the [pull request template](PULL_REQUEST_TEMPLATE.md) + +## How to report a bug? + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. +- Use the [issue template](ISSUE_TEMPLATE.md) + +## Requirements + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. - Style will get automatically fixed. +- **Add tests backing up your change!** - You can run the test by running `vendor/bin/phpunit` +- **Document any change in behaviour** - Documentation is located in the `/docs/` folder +- **One feature per Pull Request** +- **Add meaningful commit messages** diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..5b48c57 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Detailed description + +Provide a detailed description of the change or addition you are proposing. + +Make it clear if the issue is a bug, an enhancement or just a question. + +## Context + +Why is this change important to you? How would you use it? + +How can it benefit other users? + +## Possible implementation + +Not obligatory, but suggest an idea for implementing addition or change. + +## Your environment + +Include as many relevant details about the environment you experienced the bug in and how to reproduce it. + +* Version used (e.g. PHP 5.6, HHVM 3): +* Operating system and version (e.g. Ubuntu 16.04, Windows 7): +* Link to your project: +* ... +* ... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1566974 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) Richard Dobroň +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a5b78f1 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,51 @@ +### Requirements + +Please take note of our contributing guidelines: [CONTRIBUTING.md](CONTRIBUTING.md) +Filling out the template is required. Any pull request that does not include enough information to be reviewed in a timely manner may be closed at the maintainers' discretion. + +Mark the following tasks as done: + +* [ ] Checked the codebase to ensure that your feature doesn't already exist. +* [ ] Checked the pull requests to ensure that another person hasn't already submitted the feature or fix. +* [ ] Adjusted the Documentation. +* [ ] Added tests to ensure against regression. + +### Description of the Change + + + +### Why Should This Be Added? + + + +### Benefits + + + +### Possible Drawbacks + + + +### Verification Process + + + +### Applicable Issues + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1903313 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +

+ FBT +

+ +FBT is an internationalization framework for PHP designed to be not just **powerful** and **flexible**, but also **simple** and **intuitive**. It helps with the following: +* Organizing your source text for translation +* Composing grammatically correct translatable UI +* Eliminating verbose boilerplate for generating UI + +**This library is based on the JavaScript implementation of Facebook's [FBT][link-facebook-fbt].** + +## Requirements +* PHP 7.0 or higher +* [Composer](https://getcomposer.org) is required for installation + +## Installing + +```shell +$ composer require richarddobron/fbt +``` + +## Getting started + +[Integrating into your app](docs/getting_started.md) + +## Version Guidance + +| Version | Released | Status | Repo | PHP Version | +|---------|------------|--------|------------------|-------------| +| 3.x | 2022-02-18 | Latest | [v3][fbt-3-repo] | >= 7.0 | + +## Official integrations + +The following integrations are fully supported and maintained: + +- [Laravel](https://github.com/richardDobron/laravel-fbt) + +## How FBT works +FBT works by transforming your `` and `fbt(...)` constructs via +[Simple HTML DOM Parser][simplehtmldom]. This library serve to extract strings from source and +lookup translated payloads generated while execution. FBT creates tables +of all possible variations for the given fbt phrase and accesses this +at runtime. + +## Full documentation +https://github.com/richardDobron/fbt/tree/main/docs + + +## TODO + +- [ ] Add driver-agnostic support for multiple database systems. +- [ ] Add integrations for Symfony, Laravel, CakePHP, Zend Framework, Nette, ... +- ... + +## License +FBT is MIT licensed, as found in the [LICENSE](LICENSE) file. + +[fbt-3-repo]: https://github.com/richarddobron/fbt +[link-facebook-fbt]: https://github.com/facebook/fbt +[simplehtmldom]: https://sourceforge.net/projects/simplehtmldom/files/simplehtmldom/1.9.1/ diff --git a/bin/fbt b/bin/fbt new file mode 100644 index 0000000..d9c259e --- /dev/null +++ b/bin/fbt @@ -0,0 +1,30 @@ +#!/usr/bin/php +registerCommand('translate', function (Minicli\Command\CommandCall $app) { + $generateTranslationsService = new \fbt\Services\TranslationsGeneratorService(); + $generateTranslationsService->exportTranslations( + $app->getParam('--path'), + $app->getParam('--translations'), + $app->hasFlag('stdin') ? file_get_contents("php://stdin") : null, + $app->hasFlag('pretty') + ); +}); + +$app->runCommand($argv); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c27c5b7 --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "richarddobron/fbt", + "description": "A PHP Internationalization Framework for PHP.", + "keywords": ["php", "i18n", "framework", "internationalization", "translations"], + "require": { + "php": "^7.0", + "ext-json": "*", + "ext-dom": "*", + "ext-iconv": "*", + "minicli/minicli": "1.0.4" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "Richard Dobroň" + }, + { + "name": "Meta Platforms, Inc. and affiliates." + } + ], + "autoload": { + "files": [ + "src/fbt/Util/SimpleHtmlDom/index.php", + "src/fbt/helpers.php" + ], + "psr-0": { + "fbt\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "tests\\": "tests" + } + }, + "scripts": { + "post-merge": "composer install", + "test": "vendor/bin/phpunit --colors=always" + }, + "scripts-descriptions": { + "test": "Run all tests." + }, + "bin": [ + "bin/fbt" + ] +} diff --git a/docs/api_intro.md b/docs/api_intro.md new file mode 100644 index 0000000..2ab2118 --- /dev/null +++ b/docs/api_intro.md @@ -0,0 +1,53 @@ +# The FBT API + +The fbt framework has two (mostly) equivalent APIs: A HTML-style `` tag API and a "vanilla" or "functional" `fbt(...)` API that more closely resembles standard PHP. In general, you can compose your translatable text in either format. As the following example illustrates, the child of the `` tag shows up as the first argument to `fbt` and any attributes show up in the optional third argument parameter. The `desc` (text description) argument is the exception to this rule because it is a *required* parameter and attribute in `fbt(...)` and `` respectively. + +Let's start with a simple example: + +## HTML `` API +**NOTE: You can use this method only if you use the `fbtTransform` + `endFbtTransform` functions or by using `FbtTransform::transform(...)`** +``` + + Hello, World! + +``` +### Required attributes +* `desc`: description of text to be translated + +### Optional attributes +* **author** `string`: Text author +* **project** `string`: Project to which the text belongs +* **preserveWhitespace** `bool`: (Default: `false`) + - FBT normally consolidates whitespace down to one space (`' '`). + - Turn this off by setting this to `true` +* **subject** `IntlVariations::GENDER_*`: Pass an [implicit subject](implicit_params.md) gender to a partially formed text +* **common** `bool`: Use a "common" string repository +* **doNotExtract** `bool`: Informs [collection](collection.md) to skip this string (useful for tests/mocks) + +-------------------------------------------------------------------------------- + +## "Vanilla" `fbt(...)` API + +```php +fbt('Hello, World', 'a simple example', ['project' => "foo"]) +``` +#### Required arguments +1. Text to translate +2. Description of text to be translated + +#### Optional parameters +3. Options object - same optional arguments as the `` [attributes above](api_intro.md#optional-attributes) + +-------------------------------------------------------------------------------- +## Docblock defaults +Defaults for the above optional attributes may be provided in the +docblock with the `@fbt` pragma. It uses a straight `json_decode` to +interpret this, so you'll have to make sure your object is parseable. (i.e. keys should be wrapped in `"double quotes"`) + +E.g. +```php +` will automatically wrap any non-fbt children in the top-level +`` as though they were written with an `` with a +`name` attribute containing the child's text. It will pull any child +text into the parameter name, including those of recursive structures. + + +```html + + Go on an + + awesome vacation + + +``` + +When extracted for translation, the result of the `\fbt\Transform\FbtTransform\FbtTransform::toArray()` is: + +```php +[ + "phrases" => [ + 2 => [ + "hashToText" => [ + "576c64dce7dc0eb30803b1c2feb21722": "Go on an {=awesome vacation}" + ], + "desc": "auto-wrap example", + ..., + ], + 1 => [ + "hashToText" => [ + "7de5f69602b0c289965183f9ffbf2496": "{=awesome} vacation" + ], + "desc": "In the phrase: \"Go on an {=awesome vacation}\"", + ..., + ], + 0 => [ + "hashToText" => [ + "6bbb015218a9c99babf7213c1fa764d8": "awesome" + ], + "desc": "In the phrase: \"Go on an {=awesome} vacation\"", + ..., + ] + ], + "childParentMappings" => [ + 0 => 1, + 1 => 2 + ] +} +``` + +Notice the description for "vacation" is auto-generated with an `"In +the phrase: ..."` prefix. Additionally, we use a convention of an `=` +prefix in the interpolation `{=awesome vacation}` to signal to the +translator that this exact word or phrase goes in the associated outer +sentence. + +Furthermore, we provide a mapping `[ => ]` in +the collection output `childParentMappings`. At Facebook, we use +these to display all relevant inner and outer strings when translating +any given piece of text. We recommend you do the same in whatever +translation framework you use. Context is crucial for accurate +translations. diff --git a/docs/best_practices.md b/docs/best_practices.md new file mode 100644 index 0000000..ca4ae6c --- /dev/null +++ b/docs/best_practices.md @@ -0,0 +1,151 @@ +# Platform Internationalization Best Practices + +Here are some rough guidelines and lessons learned by the internationalization team at Facebook. These are in no specific order of importance. + +## Be Descriptive +~~The general rule we use is text under 20 characters needs to have a description.~~ A word like "Poke" can vary if it is used as a noun or a verb. Facebook Translations works by creating a hash value from the text and description of the phrase. That means that even a slight change to the original text or description will cause your string to be counted as a completely new one. So err on the side of starting off with a complete description you won't have to clarify later. + +Note that many translators prefer to use the bulk translation interface, so they will not see your text in the context of your application -- that means your descriptions need to give translators all the information they need to make correct translation decisions. + +For example, do this: + +```php +fbt("Name:", "Label for name of photo album") +``` + +Instead of this: + +```php +fbt("Name:", "") +``` + +In some languages, the word for *name* is different depending on whether it's the name of a person, a place, or an object. A description here allows a translator to choose the correct word for this label. + +Descriptions should usually indicate context as well as meaning (but see the next point). This is especially important for things like link text that are presented as part of a larger grammatical structure like a sentence. + +So do this: + +```php +fbt("{name}'s photos", "In, 'X's photos are ready to view.'") +``` + +But not this: + +```php +fbt("{name}'s photos", "") +``` + +In languages where nouns change depending on whether they're used as the subject or object of a sentence, this description will allow translators to use the correct form. + +# Reuse Common Elements +You should reuse common text and descriptions rather than repeating the same text over and over with different descriptions; it's less work for translators and will tend to result in higher-quality translations. This is sometimes slightly at odds with using specific descriptions; use your judgment about where to draw the line. + +So this: + +```php +fbt("Cancel", "Button/link: cancel an action") +``` + +Is usually better than this: + +```php +fbt("Cancel", "Button label: cancel sending a message to event owner") +``` + +# Avoid Translating Markup +If you have two sentences and a `
` in between, split them up into two translatable phrases. Otherwise translators will be able to mess with your markup and the results may not be what you expect. + +However, if you really want to use a `
`, we recommend doing it this way: +```html + + first line
+ second line + last line. +
+``` + +# Use CSS instead of Markup +Use CSS rather than markup to confine text to particular parts of the page. (See also the next item.) For example, if you have the text "Next Page" and you want each word on a separate line, put it in a with a maximum width rather than putting a tag in between the two words. Don't split the text into separately translatable units since it will prevent translators from changing word order if needed. + +**Don't** do this: + +```php +fbt("Next
Page", "...") +``` +Because a translator may ignore your formatting. + +And **don't** do this: +```php +fbt("Next", "...") . '
' . fbt("Page", "..."); +``` +If a language needs the word for "Page" to come before the word for "Next", it is impossible to translate correctly. + +Rather, do **this**: +```html +
' . fbt("Next Page", "...") . '
+``` +With appropriate CSS, the browser will word-wrap the string appropriately. + +# Avoid Layouts Relying on Precise Sizing +Try not to use layouts that depend on the precise onscreen sizes of pieces of text in the original language. For any piece of text, in some languages it is likely to be shorter and in some it will be longer (sometimes significantly so in either direction.) If you have sized your user interface elements such that your text just barely fits, your application will probably not work well in a language with longer words. + +# Avoid Long Pieces of Text +Large chunks of text like multiple paragraphs should be split up among multiple tags for ease of translation. Similarly, a single long paragraph should be broken up into several smaller paragraphs. This allows translation voting to more precisely pinpoint problems. + +# Assume Word Order Will Change +Assume that a translator will have to change the word order of every sentence. In particular, don't try to assemble sentences from smaller separately-translatable fragments, because even if you provide excellent descriptions, it's likely you will make it impossible for a translator to come up with a grammatically correct translation. Instead, expand all the possible cases out into separate translatable sentences and choose a complete sentence in your code. + +Here's a simple example to **avoid**: +```html +You are eating at home. +You are eating at a restaurant. +``` + +Here the code is printing the beginning of the sentence, which doesn't change in English, then choosing one of two possible endings. This is impossible to translate correctly to Chinese, where the phrases for "at home" and "at a restaurant" need to come before the word for "eating". + +In this case, use separate phrases: +```html +You are eating at home. +You are eating at a restaurant. +``` + +Here the code chooses one of two complete sentences. The translator can adjust the word order of both sentences as needed, and these can be correctly translated into every language. + +Along the lines of the previous item, if you have a phrase like "You have {number} photos." where you use the word "photo" when the number is 1, expand this out into separate complete sentences line, "You have one photo." and "You have {number} photos.", like this: +```html +You have one photo. +``` + +# Avoid Tiny Fonts +Font sizes under 10 pixels can be difficult to read in some languages, especially Chinese and Japanese. + +# Don't Hardcode Punctuation +Different languages use different punctuation symbols; for example, Chinese has two different comma characters that are used in different contexts. In general if you allow translators to translate complete sentences (including periods and commas) this won't be as big an issue for you. + +So you **should** include the punctuation within the fbt tags: + +```html +You have mail. +``` + +**Don't** exclude it from the tags: + +```html +You have mail. +``` +Japanese translators, among others, will want to use their language's end-of-sentence character, which is not an English-style period. + +Similarly, you **should** do this: +```html +Favorite color: +``` +And **not** do this: +```html +Favorite color: +``` +Including the colon as part of the translatable string means translators can substitute another punctuation mark if applicable, or can insert whitespace between the text and the colon (as is done in French, for example.) + +# Using Icons Instead of Images with Text +Using icons rather than images with prerendered text can sometimes save you the trouble of having to generate your graphics in different languages. But be aware that some symbols are culture-specific and may not mean the same thing to people in different countries -- for example, a hand with a raised thumb indicates "good" in some cultures but is an obscene gesture in others. An icon whose meaning is obscure is actually worse than using untranslated text, since the latter can at least be looked up in a dictionary as a last resort. + +Source: http://wiki.developers.facebook.com/index.php/Platform_Internationalization_Best_Practices diff --git a/docs/collection.md b/docs/collection.md new file mode 100644 index 0000000..8a5c09d --- /dev/null +++ b/docs/collection.md @@ -0,0 +1,49 @@ +# Extracting FBTs + +Unlike Facebook fbt, we collect & translate strings during script execution. + +Upon successful execution, the output of the `/your/path/to/fbt/.source_strings.json` will be in the following format: + +```php +[ + "phrases": [ + [ + "hashToText": [ + : , + ... + ], + "type": "text" | "table", + "desc": , + "project": , + "jsfbt": string | ['t' => , 'm' => ], + ] + ], + "childParentMappings" => [ + : + ] +} +``` + +`phrases` here represents all the *source* information we need to +process and produce an `fbt::_(...)` callsite's final payload. When +combined with corresponding translations to each `hashToText` entry we +can produce the translated payloads `fbt::_()` expects. + +When it comes to moving from source text to translations, what is most +pertinent is the `hashToText` payload containing all relevant texts +with their identifying hash. You can choose `md5` or `tiger` hash module. It defaults to md5. + +### A note on hashes + +In the FBT framework, there are 2 main places we uses hashes for +identification: **text** and **fbt callsite**. The `hashToText` mapping +above represents the hash of the **text** and its **description**. This is used +when *building* the translated payloads. + +The hash of the callsite (defaulting to `jenkins` hash) is used to +look up the payload in +[`FbtTranslations`](https://github.com/richardDobron/fbt/blob/main/src/fbt/Runtime/FbtTranslations.php). +This is basically the hash of the object you see in `jsfbt`. + +See [Translating FBTs](translating.md) for getting your translations in +the right format. diff --git a/docs/commmon.md b/docs/commmon.md new file mode 100644 index 0000000..fb2de16 --- /dev/null +++ b/docs/commmon.md @@ -0,0 +1,69 @@ +# Common FBT strings + +The `fbt` framework provides a way to define common simple strings in one shared location. The expected format is as a text to description map. + +E.g. + +```json5 +// OurCommonStrings.json +{ + "Photo": "Still image ...", + "Video": "Moving pictures ...", + ... +} +``` + +or + +```php +// OurCommonStrings.php + 'Button to post a comment', +]); +``` + +## Runtime API +To use the strings at runtime, there is the `fbt::c(...)` function call or the `...` JSX API. + +***NOTE: The transform will throw if it encounters a common string *not* in the map provided.*** + +E.g. + +```php + +``` + +or + +```html + +``` + +Both examples above function as if the engineer had also included the description with the text. + +```js + Photo +``` + +All of these instances would produce the same identifying hash at collection time, and thus coalesce into the same translation. diff --git a/docs/enums.md b/docs/enums.md new file mode 100644 index 0000000..764cd63 --- /dev/null +++ b/docs/enums.md @@ -0,0 +1,63 @@ +# Enumerations + +Enumerations eliminate a lot of UI code duplication while enabling accurate translations. `` and `fbt::enum` both provide the ability to add your ad-hoc enumerations. + +## Adhoc enums +Adhoc enums can be provided inline to the `enum-range` attribute or as the second parameter to `fbt::enum`. +### Enum map +``` + + Buy a new + ! + + +fbt( + 'Buy a new ' . + fbt::enum($enumVal, [ + 'CAR' => 'car', + 'HOUSE' => 'house', + 'BOAT' => 'boat', + 'HOUSEBOAT' => 'houseboat', + ]), + 'buy prompt', +); +``` + +### Shorthand array (keys = values) +The shorthand array adhoc enum functions as though you had a `[value => value]` map. +``` + + Buy a new + ! + + +fbt( + 'Buy a new ' . fbt::enum($enumVal, ['car', 'house', 'boat', 'houseboat']) . '!', + 'buy prompt', +); +``` + +All the above examples [extract](collection.md) the same 4 separate strings for translation in JSON like: + +```json +{ + "phrases": [ + { + "hashToText": { + "b463748f978f242787f5f225a7762aeb": "Buy a new car!", + "1255ecb7aa0a34b8755d4f068c9b9c41": "Buy a new house!", + "7c01d5d74f6e3c8eda0b166a366b937e": "Buy a new boat!", + "7a7776e292838b6fe8c4a7dfd58117cd": "Buy a new houseboat!" + }, + ..., + "desc": "buy prompt", + ... + }, +``` diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 0000000..972d775 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,165 @@ +# Integrating into your app + +We recommend you read the [best practices](best_practices.md) for advice on how to best prepare your applications. We strongly encourage you to do so. + +## 📦 Installing + +```shell +$ composer require richarddobron/fbt +``` +These steps are required: + +1. Publish config file: + - _We recommend setting the **author**, **project** and **path** options._ +```php +\fbt\FbtConfig::set('author', 'your name'); +\fbt\FbtConfig::set('project', 'project'); +\fbt\FbtConfig::set('path', '/path/to/storage'); +``` + +2. Run migrations: + +```php +$ php artisan migrate +``` + +## 🔧 Configuration + +### Options + +The following options can be defined: + +* **project** `string`: (Default: `website app`) Project to which the text belongs +* **author** `string`: Text author +* **collectFbt** `bool`: (Default: `true`) Collect fbt instances from the source and store them to a JSON file. +* **preserveWhitespace** `bool`: (Default: `false`) + - FBT normally consolidates whitespace down to one space (`' '`). + - Turn this off by setting this to `true` +* **viewerContext** `string`: (Default: `\fbt\Runtime\Shared\IntlViewerContext::class`) +* **locale** `string`: (Default: `en_US`) User locale. +* **hash_module** `string`: (Default: `md5`) Hash module. +* **md5_digest** `string`: (Default: `hex`) MD5 digest. +* **fbtCommon** `string`: (Default: `[]`) common string's, e.g. `[['text' => 'desc'], ...]` +* **fbtCommonPath** `string`: (Default: `null`) Path to the common string's module. +* **path** `string`: Cache storage path for generated translations & source strings. + +Below are the less important parameters. + +* **driver** `string`: (Default: `json`) Currently, only JSON storage is supported. + + +## 🙋 IntlInterface +Optional implementation of IntlInterface on UserDTO. + +Example code: + +```php +locale; + } + + public static function getGender(): int + { + if ($this->gender === 'male') { + return IntlVariations::GENDER_MALE; + } + + if ($this->gender === 'female') { + return IntlVariations::GENDER_FEMALE; + } + + return IntlVariations::GENDER_UNKNOWN; + } +} +``` + +After implementation, set `viewerContext`: + +```php +$loggedUserDto = ...; + +\fbt\FbtConfig::get('viewerContext', $loggedUserDto) +``` + +## 🚀 Command +This command creates translation payloads stored in JSON file. +```shell +php bin/fbt translate +``` +Read more about [translating](translating.md). + +## 📘 API + +- [fbt(...);](api_intro.md) +- [fbt::param(...);](params.md) +- [fbt::enum(...);](enums.md) +- [fbt::name(...);](params.md) +- [fbt::plural(...);](plurals.md) +- [fbt::pronoun(...);](pronouns.md) +- [fbt::sameParam(...);](params.md) +- [fbt::c(...);](commmon.md) + +```php +echo fbt('You just friended ' . \fbt\fbt::name('name', 'Sarah', 2 /* gender */), 'names'); +``` + +## 🎨 Example Usage + +### fbtTransform() & endFbtTransform() +**fbtTransform()**: _This function will turn output buffering on. While output buffering is active no output is sent from the script (other than headers), instead the output is stored in an internal buffer._ + +**endFbtTransform()**: _This function will send the contents of the topmost output buffer (if any) and turn this output buffer off._ + +```php + + ... + + Go on an + + awesome vacation + + + ... + + +// result: Go on an awesome vacation +``` + +### fbt() + +```php +fbt( + [ + 'Go on an ', + \fbt\createElement('a', \fbt\createElement('span', 'awesome'), ['href' => '#']), + ' vacation', + ], + 'It\'s simple', + ['project' => "foo"] +) + +// result: Go on an awesome vacation +``` + +```php +fbt('You just friended ' . \fbt\fbt::name('name', 'Sarah', 2 /* gender */), 'names') + +// result: You just friended Sarah +``` + +```php +fbt('A simple string', 'It\'s simple', ['project' => "foo"]) + +// result: A simple string +``` diff --git a/docs/implicit_params.md b/docs/implicit_params.md new file mode 100644 index 0000000..f0e99f0 --- /dev/null +++ b/docs/implicit_params.md @@ -0,0 +1,33 @@ +# Implicit parameters + +## Viewer Gender +### The hidden `__viewing_user__` token + +If a token of `__viewing_user__` is provided, it is expected to have the +corresponding [`type`](translation.md) of `IntlVariations::GENDER_*`. When +provided, at [translation](translation.md) time, `JSFbtBuilder` will +create a special key in its table payload which signals to the runtime +to check the gender of `IntlVariations::GENDER` in order to variate on +gender. + +## Subject +### The hidden `__subject__` token + +Similar to [viewer gender](implicit_params.md#viewer-gender), this is an +implicit variation based on gender. Whether the variation is provided +is determined at the [translation](translating.md) level. A translator +may choose to variate on `__subject__` or not. + +Unlike viewer gender, subject requires that the `fbt` callsite provide it via the [optional argument](api_intro#optional-attributes) + +E.g + +``` +"> + translated your string. + +``` +The above will variate correctly, provided there are separate translations for the `__subject__` token. diff --git a/docs/locales.md b/docs/locales.md new file mode 100644 index 0000000..bf3d22e --- /dev/null +++ b/docs/locales.md @@ -0,0 +1,185 @@ +# Supported Locales + +Instead of using the ISO 639 language codes employed across the internet, Facebook has devised their own system of labeling languages. Some are the same as the ISO standard, but others are not. There also seems to be support lacking for Accept-Language: HTTP request headers. + +Each code is made up of two parts as follows: + +[language]_[region] + +| Locale | Language name | +|--------|-----------------------| +| af_ZA | Afrikaans | +| ak_GH | Ákán | +| am_ET | አማርኛ | +| ar_AR | ‏العربية‏ | +| ay_BO | Aymar aru | +| as_IN | অসমীয়া | +| az_AZ | Azərbaycan dili | +| be_BY | Беларуская | +| bg_BG | Български | +| bn_IN | বাংলা | +| bp_IN | भोजपुरी | +| br_FR | Brezhoneg | +| bs_BA | Bosanski | +| bv_DE | Bairisch | +| ca_ES | Català | +| cb_IQ | ‏کوردیی ناوەندی‏ | +| ck_US | Cherokee | +| co_FR | Corsu | +| cs_CZ | Čeština | +| cx_PH | Bisaya | +| cy_GB | Cymraeg | +| da_DK | Dansk | +| de_DE | Deutsch | +| eh_IN | Hinglish | +| el_GR | Ελληνικά | +| em_ZM | Chibemba | +| en_GB | English (UK) | +| en_IN | English (India) | +| en_OP | English (Opposite) | +| en_PI | English (Pirate) | +| en_UD | English (Upside Down) | +| en_US | English (US) | +| eo_EO | Esperanto | +| es_CL | Español (Chile) | +| es_CO | Español (Colombia) | +| es_ES | Español (España) | +| es_LA | Español | +| es_MX | Español (México) | +| es_VE | Español (Venezuela) | +| et_EE | Eesti | +| eu_ES | Euskara | +| fa_IR | ‏فارسی‏ | +| ff_NG | Fula | +| fi_FI | Suomi | +| fo_FO | Føroyskt | +| fr_CA | Français (Canada) | +| fr_FR | Français (France) | +| fv_NG | Fula | +| fy_NL | Frysk | +| ga_IE | Gaeilge | +| gl_ES | Galego | +| gn_PY | Guarani | +| gu_IN | ગુજરાતી | +| gx_GR | Ἑλληνική ἀρχαία | +| ha_NG | Hausa | +| he_IL | ‏עברית‏ | +| hi_FB | Hindi (Modern) | +| hi_IN | हिन्दी | +| hr_HR | Hrvatski | +| ht_HT | Kreyòl Ayisyen | +| hu_HU | Magyar | +| hy_AM | Հայերեն | +| id_ID | Bahasa Indonesia | +| ig_NG | Ásụ̀sụ́ Ìgbò | +| ik_US | Iñupiatun | +| is_IS | Íslenska | +| it_IT | Italiano | +| iu_CA | ᐃᓄᒃᑎᑐᑦ | +| ja_JP | 日本語 | +| ja_KS | 日本語(関西) | +| jv_ID | Basa Jawa | +| ka_GE | ქართული | +| kk_KZ | Қазақша | +| km_KH | ភាសាខ្មែរ | +| kn_IN | ಕನ್ನಡ | +| ko_KR | 한국어 | +| ks_IN | كٲشُر, कॉशुर | +| ku_TR | Kurdî (Kurmancî) | +| ky_KG | кыргызча | +| la_VA | lingua latina | +| lg_UG | Oluganda | +| li_NL | Lèmbörgs | +| ln_CD | lingála | +| lo_LA | ພາສາລາວ | +| lt_LT | Lietuvių | +| lv_LV | Latviešu | +| mg_MG | Malagasy | +| mi_NZ | Māori | +| mk_MK | Македонски | +| ml_IN | മലയാളം | +| mn_MN | Монгол | +| mr_IN | मराठी | +| ms_MY | Bahasa Melayu | +| mt_MT | Malti | +| my_MM | မြန်မာဘာသာ | +| nb_NO | Norsk (bokmål) | +| nd_ZW | Ndebele | +| ne_NP | नेपाली | +| nl_BE | Vlaams | +| nl_NL | Nederlands | +| nn_NO | Norsk (nynorsk) | +| nr_ZA | Transvaal Ndebele | +| ns_ZA | Pedi | +| ny_MW | Nyanja | +| om_ET | Afaan Oromoo | +| or_IN | ଓଡ଼ିଆ | +| pa_IN | ਪੰਜਾਬੀ | +| pl_PL | Polski | +| ps_AF | ‏پښتو‏ | +| pt_BR | Português (Brasil) | +| pt_PT | Português (Portugal) | +| qb_DE | hornjoserbšćina | +| qc_GT | Quiché | +| qe_US | Yupʼik | +| qk_DZ | ⵜⴰⵇⴱⴰⵢⵍⵉⵜ | +| qr_GR | armãneashti | +| qs_DE | dolnoserbšćina | +| qt_US | Lingít | +| qu_PE | Runasimi | +| qv_IT | łengoa vèneta | +| qz_MM | Zawgyi | +| rm_CH | Rumantsch | +| ro_RO | Română | +| ru_RU | Русский | +| rw_RW | Ikinyarwanda | +| sa_IN | संस्कृतम् | +| sc_IT | Sardu | +| se_NO | Sámegillii | +| si_LK | සිංහල | +| sk_SK | Slovenčina | +| sl_SI | Slovenščina | +| sn_ZW | Shona | +| so_SO | Af-Soomaali | +| sq_AL | Shqip | +| sr_RS | Српски | +| ss_SZ | siSwati | +| st_ZA | Sesotho | +| su_ID | Basa Sunda | +| sv_SE | Svenska | +| sw_KE | Kiswahili | +| sy_SY | ‏ܣܘܪܝܝܐ‏ | +| sz_PL | ślōnskŏ gŏdka | +| ta_IN | தமிழ் | +| te_IN | తెలుగు | +| tg_TJ | Тоҷикӣ | +| th_TH | ภาษาไทย | +| tk_TM | Türkmençe | +| tl_PH | Filipino | +| tl_ST | tlhIngan-Hol | +| tn_BW | Setswana | +| tr_TR | Türkçe | +| ts_ZA | Xitsonga | +| tt_RU | Татарча | +| tz_MA | ⵜⴰⵎⴰⵣⵉⵖⵜ | +| uk_UA | Українська | +| ur_PK | ‏اردو‏ | +| uz_UZ | O'zbek | +| ve_ZA | Tshivenḓa | +| vi_VN | Tiếng Việt | +| wo_SN | Wolof | +| xh_ZA | isiXhosa | +| yi_DE | ייִדיש | +| yo_NG | Èdè Yorùbá | +| zh_CN | 中文(简体) | +| zh_HK | 中文(香港) | +| zh_TW | 中文(台灣) | +| zu_ZA | isiZulu | +| zz_TR | Zaza | + +Resources: +- https://iso639-3.sil.org/code/vec +- https://fbdevwiki.com/wiki/Locales +- https://github.com/umpirsky/locale-list +- https://www.loc.gov/standards/iso639-2/php/code_list.php +- https://www.facebook.com/translations/FacebookLocales.xml diff --git a/docs/params.md b/docs/params.md new file mode 100644 index 0000000..9b0f634 --- /dev/null +++ b/docs/params.md @@ -0,0 +1,82 @@ +# Parameters and interpolation + +**⚠️ NOTE: Content of `fbt:param` and `fbt:name` will not be escaped!**
+ +Interpolation of dynamic text and other markup is accomplished in the FBT framework via `` or `fbt::param`: + +```html + + Hello, + getName()?>. + +``` + +```php +fbt('Hello, ' . fbt::param('name', $person->getName()), 'param example') +``` + +These both [extract](collection.md) to the same following text: + +``` +"Hello, {name}" +``` + +Tokens are delimited with the braces above and translations are expected to keep the same total token *count* and same token *names* for any given `fbt` callsite. + +### Required attributes +* **name** `string`: Name of the token + +### Optional attributes +* **gender** `IntlVariations::GENDER_*`: + * Pass the gender of the parameter for correctly variated text. +* **number** `number|true`: + * Passing a value of type `number` into the `number` option uses that +value as the input for which we determine the [CLDR plural value](http://cldr.unicode.org/index/cldr-spec/plural-rules). + * You can pass `true` to simply use the parameter value (the same value that replaces the token). + +-------------------------------------------------------------------------------- + +## fbt::name +`` is just a special form of `fbt:param` that `requires` that you pass in the gender for the interpolated variable. +``` + + Hello, + getName()?>. + +``` + +Here, gender must be one of the 3 supported gender values in `IntlVariations`: + +``` +IntlVariations::GENDER_MALE: 1; +IntlVariations::GENDER_FEMALE: 2; +IntlVariations::GENDER_UNKNOWN: 3; +``` +-------------------------------------------------------------------------------- +### Duplicate tokens +Tokens with the same name, but different values are prohibited in FBT. +If you want the same interpolation to show up, you must use +`fbt:same-param` or `fbt::sameParam`. This construct only takes a name +and no value, as the value to the first instance is re-used for the +second token. + +``` + + + + + shared a link. Tell + + you liked it. + + +fbt( + fbt::name( + 'name', + '' . $name . '', + $gender + ) . + ' shared a link. Tell ' . fbt::sameParam('name') . ' you liked it.', + 'param example' +) +``` diff --git a/docs/plurals.md b/docs/plurals.md new file mode 100644 index 0000000..6945d3c --- /dev/null +++ b/docs/plurals.md @@ -0,0 +1,66 @@ +# Plurals + +`fbt:plural` provides you with a shorthand way for plural variations. +``` + + You have + + a like + + on your + + photo + . + +``` +OR +```php +fbt( + 'You have ' . + fbt::plural('a like', getLikeCount(), [ + 'name' => 'number of likes', + 'showCount' => 'ifMany', + 'many' => 'likes', + ]) . + ' on your ' . + fbt::plural('photo', getPhotoCount()) . '.', + 'plural example', +); +``` + +Both the above examples generate the following during [collection](collection). +``` +"phrases": [ + { + "hashToText": { + "90d6ec6e0a0483edd5e9754592a4ac61": "You have {number of likes} likes on your photos.", + "158a5d707da85b56353cdfc05c92f4e9": "You have {number of likes} likes on your photo.", + "421273e69049f26d76c70fb33c6a9aea": "You have a like on your photos.", + "279c992f92809657b1240d1c955615a3": "You have a like on your photo." + }, + "type": "table", + "desc": "plural example", + ... + } +] +``` +#### Required arguments: +* **singular phrase** `string`: HTML child in `` and argument 1 in `fbt::plural` +* **count** `number`: `count` in `` and argument 2 in `fbt::plural` + +#### Optional arguments: +* **many** `string`: Represents the plural form of the string in English. Default is `$singular . 's'` +* **showCount** `"yes"|"no"|"ifMany"`: Whether to show the `{number}` in the string. +*Note that the singular phrase never has a token, but inlines to `1`. This is to account for languages like Hebrew for which showing the actual number isn't appropriate* + + * **"no"**: (*DEFAULT*) Don't show the count + * **"ifMany"**: Show the count only in plural case + * **"yes"**: Show the count in all cases +* **name** `string`: Name of the token where count shows up. (*Default*: `"number"`) +* **value** `mixed`: For overriding the displayed `number` diff --git a/docs/pronouns.md b/docs/pronouns.md new file mode 100644 index 0000000..ca7dd93 --- /dev/null +++ b/docs/pronouns.md @@ -0,0 +1,102 @@ +# Pronouns + +`fbt:pronoun` and `fbt::pronoun` both take a required `FbtConstants::PRONOUN_USAGE` enum and a [`Gender::GENDER_CONST`](https://github.com/richardDobron/fbt/blob/main/src/fbt/Runtime/Gender.php) enum: +```php +class FbtConstants +{ + const PRONOUN_USAGE = [ + "OBJECT" => 0, + "POSSESSIVE" => 1, + "REFLEXIVE" => 2, + "SUBJECT" => 3 + ]; +} + +class Gender +{ + const GENDER_CONST = [ + 'NOT_A_PERSON' => 0, + 'FEMALE_SINGULAR' => 1, + 'MALE_SINGULAR' => 2, + 'FEMALE_SINGULAR_GUESS' => 3, + 'MALE_SINGULAR_GUESS' => 4, + 'MIXED_SINGULAR' => 5, + 'MIXED_PLURAL' => 5, + 'NEUTER_SINGULAR' => 6, + 'UNKNOWN_SINGULAR' => 7, + 'FEMALE_PLURAL' => 8, + 'MALE_PLURAL' => 9, + 'NEUTER_PLURAL' => 10, + 'UNKNOWN_PLURAL' => 11, + ]; +} +``` + +**⚠️ NOTE: This is not the same gender as used in `fbt:param`, `fbt:name`, or `subject`!**
+The `IntlVariations` used in those cases only has `GENDER_MALE`, `GENDER_FEMALE`, and `GENDER_UNKNOWN`. + + +## Pronoun example: + +``` + + getName()?> + shared + + photo with you. + +``` + +### Optional attributes +* **capitalize** `bool`: Whether to capitalize the pronoun in the source string. +* **human** `bool`: Whether to elide the NOT_A_PERSON option in the text variations generated. + +The example above generates: +``` +{ + "hashToText": { + "23fa7e4d6a4686bb6ff609c00726cf33": "{name} shared her photo with you.", + "dd86ffccd845f2767c691f8d48f69e25": "{name} shared his photo with you.", + "2584ed80718ca4138cd95adcf492de53": "{name} shared their photo with you." + }, + ..., + "type": "table", + "desc": "pronoun example", + "jsfbt": { + "t": { + "1": "{name} shared her photo with you.", + "2": "{name} shared his photo with you.", + "*": "{name} shared their photo with you." + }, + "m": [ + null + ] + } +} +``` + +## Combinations +Conceptually, pronouns work as though there was an `enum` supplied for the given `type`. +Below is the table of possible values for their various types. +*Note how `reflexive` and `object` have 4 types* + + subject: he/she/they + possessive: his/her/their + reflexive: himself/herself/themselves/themself + object: him/her/them/this + + V Name Subject Possessive Reflexive Object + ============================================================= + 0 NOT_A_PERSON they their themself this + 1 FEMALE_SINGULAR she her herself her + 2 MALE_SINGULAR he his himself him + 3 FEMALE_SINGULAR_GUESS she her herself her + 4 MALE_SINGULAR_GUESS he his himself him + 5 MIXED_SINGULAR they their themselves them + 5 MIXED_PLURAL they their themselves them + 6 NEUTER_SINGULAR they their themself them + 7 UNKNOWN_SINGULAR they their themself them + 8 FEMALE_PLURAL they their themselves them + 9 MALE_PLURAL they their themselves them + 10 NEUTER_PLURAL they their themselves them + 11 UNKNOWN_PLURAL they their themselves them diff --git a/docs/standards.md b/docs/standards.md new file mode 100644 index 0000000..1209158 --- /dev/null +++ b/docs/standards.md @@ -0,0 +1,7 @@ +# i18n standards + +## Locales +Facebook uses a `xx_XX` format for representing locales like: `en_US`, `jp_JP`, etc. We're actively working on separating our `language` + `country` combinations internally, and where we go from there as far as standards go is unknown. BUT if you'd like to help support `bcp-47` standards or similar, you are very welcome to contribute! + +## CLDR +We generate all our number variation data found in our `IntlNumberTypes` internals from [CLDR (Unicode Common Locale Data Repository)](http://cldr.unicode.org/). diff --git a/docs/transform.md b/docs/transform.md new file mode 100644 index 0000000..f665610 --- /dev/null +++ b/docs/transform.md @@ -0,0 +1,9 @@ +# Transforms + +The fbt comes with 2 transforms. + +## FbtTransform +The first is the `FbtTransform`. Internally, it first transforms `` instances into their `fbt(...)` equivalent. After which, it turns all `fbt(...)` calls into `fbt::_(...)` calls with an intermediary payload as the first argument, and the runtime arguments to be passed in. + +## FbtRuntimeTransform +This transform takes the intermediary payload and turns it into the object that the `fbt::_(...)` runtime expects. diff --git a/docs/translating.md b/docs/translating.md new file mode 100644 index 0000000..e91c485 --- /dev/null +++ b/docs/translating.md @@ -0,0 +1,83 @@ +# Translating + +There are two ways to translate phrases: +1. Manually translate generated JSON file. +2. Use the app editor [Swiftyper Translations](https://github.com/swiftyper-sk/fbt-sync). + +## Command: +```shell +php bin/fbt translate --path=/path/to/storage --stdin < translation_input.json +# or +php bin/fbt translate --path=/path/to/storage --translations=/path/to/translations/*.json +``` +### Options: +| name | default | description | +|----------------------------------|---------|-------------------------------------------------------------------------------------------------------| +| --path | *none* | Path to export translation output (Cache storage path - must be same as in configuration) | +| --pretty | no | Pretty print the translation output | +| --translations=`[path]` | *none* | The translation files containing translations.
E.g. `--translations=/path/to/translations/*.json` | +| --stdin < translation_input.json | *none* | Instead of reading translation files and source file separately, read monolithic JSON file from STDIN | + +In addition to the below example, the `translation_input.json` +provided in our [tests](https://github.com/richardDobron/fbt/blob/main/tests/translations/stdin-data/translation_input.json) +is a good reference on the "schema" used for the translations. + +```json +{ + "phrases": [ + "hashToText": { + : , + ... + }, + "jsfbt": string|{t:
, m:} + ], + ... + "translationGroups": [{ + "fb-locale": "xx_XX", + "translations": { + : { + "tokens": [, ..., ], + "types": [, ..., ] + "translations": [{ + "translation": , + "variations": [variationValue1,...,variationValueN] + }, + ..., + ] + } + } + }] +} +``` + +The `` and `` correspond in the above example. +That is `translations[]` is the translation entry for +`phrases.hashToText[]`. + +Here `tokens`, `types` and `variations` are all associative arrays. That is, in +the above example, `types[i]` represents the variation type (or mask) of +`tokens[i]` and `variations[i]` is the variation value of `token[i]` for the +given translation entry. + +## Variation types +Variation types can be one of +``` +IntlVariations::BITMASK_NUMBER: 28 +IntlVariations::BITMASK_GENDER: 3 +``` +This signifies what the given token can variate on. Token types of type `GENDER` can be: +``` +IntlVariations::GENDER_MALE: 1 +IntlVariations::GENDER_FEMALE: 2 +IntlVariations::GENDER_UNKNOWN: 3 +``` +while token types of `NUMBER` can be: +``` + +IntlVariations::NUMBER_ONE: 4 +IntlVariations::NUMBER_TWO: 8 +IntlVariations::NUMBER_MANY: 12 +IntlVariations::NUMBER_ZERO: 16 +IntlVariations::NUMBER_FEW: 20 +IntlVariations::NUMBER_OTHER: 24 +``` diff --git a/docs/utilities.md b/docs/utilities.md new file mode 100644 index 0000000..98974a1 --- /dev/null +++ b/docs/utilities.md @@ -0,0 +1,21 @@ +# intlNumUtils and intlSummarizeNumber + +There are a few utilities in both `intlNumUtils` and +`intlSummarizeNumber` that are documented in the source. + +In fact `fbt::param` and `fbt::plural` default to displaying numbers +using `intlNumUtils::formatNumberWithThousandDelimiters`. +You can override this behavior in `fbt:param` by setting the +[number option](params.md#optional-attributes) and using your own +string in the replacement. + +You can override this in `fbt::plural` [by providing the `value` +option](plurals.md#optional-arguments). + +# createElement + +We use this function internally to generate HTML for FBT. + +```php +\fbt\createElement('div', 'content', ['id' => 'container']); +``` diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..cf993ce Binary files /dev/null and b/icon.png differ diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..f1fea00 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,14 @@ + + + The coding standard of fbt package + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..9ae09d3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + src/ + + + + + ./tests/ + + + diff --git a/src/fbt/Exceptions/FbtException.php b/src/fbt/Exceptions/FbtException.php new file mode 100644 index 0000000..499ec5d --- /dev/null +++ b/src/fbt/Exceptions/FbtException.php @@ -0,0 +1,13 @@ + 'website app', + + /* + * Default text author name. + */ + 'author' => null, + + /* + * Logging of string impressions. + */ + 'logger' => false, + + /* + * Collect fbt instances from the source and store them in a database. + */ + 'collectFbt' => true, + + /* + * Viewer Context class name. + */ + 'viewerContext' => \fbt\Lib\IntlViewerContext::class, + + /* + * User locale. + */ + 'locale' => 'en_US', + + /* + * Hash module. + * md5 / tiger + */ + 'hash_module' => 'md5', + + /* + * Hash digest for md5 hash. + * hex / base64 + */ + 'md5_digest' => 'hex', + + /* + * Cache storage path for generated translations & source strings. + */ + 'path' => null, + + /* + * Common string's, e.g. [['text' => 'desc'], ...]. + */ + 'fbtCommon' => [], + + /* + * Path to the common string's module. + */ + 'fbtCommonPath' => null, + + /* + * Driver for storage. + */ + 'driver' => 'json', + + /* + * Debug. + */ + 'debug' => false, + ]; + + /** + * @param string $key + * + * @return mixed + * @throws FbtInvalidConfigurationException + */ + public static function get(string $key) + { + if (! array_key_exists($key, self::$config)) { + throw new FbtInvalidConfigurationException('Invalid config key ' . $key); + } + + return static::$config[$key]; + } + + /** + * @param string $key + * @param mixed $value + * + * @return void + * @throws FbtInvalidConfigurationException + */ + public static function set(string $key, $value) + { + if (! array_key_exists($key, self::$config)) { + throw new FbtInvalidConfigurationException('Invalid config key ' . $key); + } + + static::$config[$key] = $value; + } + + /** + * @return void + * @throws FbtInvalidConfigurationException + */ + public static function setMultiple(array $config) + { + foreach ($config as $key => $value) { + self::set($key, $value); + } + } +} diff --git a/src/fbt/Lib/FbtQTOverrides.php b/src/fbt/Lib/FbtQTOverrides.php new file mode 100644 index 0000000..85e7a30 --- /dev/null +++ b/src/fbt/Lib/FbtQTOverrides.php @@ -0,0 +1,9 @@ + \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + ]; + + const LANG_TO_NUMBER_TYPE = [ + "bm" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "bo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "dz" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "id" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ig" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ii" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "in" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ja" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "jbo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "jv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "jw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "kde" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "kea" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "km" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ko" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "lkt" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "lo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ms" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "my" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "nqo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "root" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "sah" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "ses" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "sg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "th" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "to" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "vi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "wo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "yo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "yue" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "zh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class, + "am" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "as" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "bn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "fa" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "gu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "hi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "kn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "mr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "zu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType02::class, + "ff" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType03::class, + "fr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType03::class, + "hy" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType03::class, + "kab" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType03::class, + "pt" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType04::class, + "ast" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "ca" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "de" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "en" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "et" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "fi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "fy" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "gl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "ia" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "io" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "it" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "ji" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "nl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "sc" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "scn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "sv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "sw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "ur" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "yi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType05::class, + "si" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType06::class, + "ak" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "bh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "guw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "ln" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "mg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "nso" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "pa" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "ti" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "wa" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType07::class, + "tzm" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType08::class, + "af" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "asa" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "az" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "bem" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "bez" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "bg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "brx" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ce" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "cgg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "chr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ckb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "dv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ee" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "el" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "eo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "es" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "eu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "fo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "fur" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "gsw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ha" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "haw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "hu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "jgo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "jmc" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ka" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "kaj" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "kcg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "kk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "kkj" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "kl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ks" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ksb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ku" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ky" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "lb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "lg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "mas" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "mgo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ml" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "mn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nah" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nd" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ne" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nnh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "no" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ny" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "nyn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "om" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "or" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "os" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "pap" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ps" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "rm" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "rof" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "rwk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "saq" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "sd" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "sdh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "seh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "sn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "so" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "sq" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ss" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ssy" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "st" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "syr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ta" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "te" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "teo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "tig" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "tk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "tn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "tr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ts" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ug" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "uz" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "ve" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "vo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "vun" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "wae" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "xh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "xog" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType09::class, + "da" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType10::class, + "is" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType11::class, + "mk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType12::class, + "fil" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType13::class, + "tl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType13::class, + "lv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType14::class, + "prg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType14::class, + "lag" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType15::class, + "ksh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType16::class, + "iu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "kw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "naq" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "se" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "sma" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "smi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "smj" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "smn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "sms" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType17::class, + "shi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType18::class, + "mo" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType19::class, + "ro" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType19::class, + "bs" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType20::class, + "hr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType20::class, + "sh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType20::class, + "sr" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType20::class, + "gd" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType21::class, + "sl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType22::class, + "dsb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType23::class, + "hsb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType23::class, + "he" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType24::class, + "iw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType24::class, + "cs" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType25::class, + "sk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType25::class, + "pl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType26::class, + "be" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType27::class, + "lt" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType28::class, + "mt" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType29::class, + "ru" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType30::class, + "uk" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType30::class, + "br" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType31::class, + "ga" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType32::class, + "gv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType33::class, + "ar" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType34::class, + "ars" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType34::class, + "cy" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType35::class, + "ceb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType36::class, + "fuv" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType36::class, + "su" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType36::class, + "gn" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "fb" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "la" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "li" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "tlh" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "grc" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "rw" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "zza" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "co" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "ht" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "quc" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType37::class, + "mi" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType38::class, + "tg" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType38::class, + "tt" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType39::class, + "sa" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType40::class, + "qu" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType41::class, + "ay" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType42::class, + "szl" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType43::class, + "bho" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType44::class, + "ik" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType45::class, + "rup" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType46::class, + "tob" => \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType47::class, + ]; + + public static function getNumberModuleForLang(string $lang = null): IntlNumberConsistency + { + $cldr = self::LANG_TO_NUMBER_TYPE[$lang] ?? \fbt\Transform\FbtTransform\Translate\CLDR\IntlCLDRNumberType01::class; + + return new $cldr(); + } + + public static function getNumberModuleForLocale(string $locale = null): IntlNumberConsistency + { + if (array_key_exists($locale, self::LOCALE_TO_NUMBER_TYPE)) { + $cldr = self::LOCALE_TO_NUMBER_TYPE[$locale]; + + return new $cldr(); + } + + return self::getNumberModuleForLang(FBLocaleToLang::get($locale)); + } + + public static function getLanguage(string $language = null): IntlNumberConsistency + { + return self::getNumberModuleForLang($language); + } + + public static function getLocale(string $locale = null): IntlNumberConsistency + { + return self::getNumberModuleForLocale($locale); + } + + public static function get(string $locale = null): IntlNumberConsistency + { + return self::getLocale($locale); + } +} diff --git a/src/fbt/Lib/IntlViewerContext.php b/src/fbt/Lib/IntlViewerContext.php new file mode 100644 index 0000000..8cde8cd --- /dev/null +++ b/src/fbt/Lib/IntlViewerContext.php @@ -0,0 +1,42 @@ + '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 0, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ]; + + const CONFIGS = [ + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => ',', + "numberDelimiter" => "\u{00a0}", + "minDigitsForThousandsSeparator" => 5, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => ',', + "numberDelimiter" => "\u{00a0}", + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => ',', + "numberDelimiter" => '.', + "minDigitsForThousandsSeparator" => 5, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => ',', + "numberDelimiter" => '.', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => "\u{066b}", + "numberDelimiter" => "\u{066c}", + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => [ + "digits" => "\u{0660}\u{0661}\u{0662}\u{0663}\u{0664}\u{0665}\u{0666}\u{0667}\u{0668}\u{0669}", + ], + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 2, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 2, + ], + "numberingSystemData" => [ + "digits" => "\u{0966}\u{0967}\u{0968}\u{0969}\u{096a}\u{096b}\u{096c}\u{096d}\u{096e}\u{096f}", + ], + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => [ + "digits" => "\u{0966}\u{0967}\u{0968}\u{0969}\u{096a}\u{096b}\u{096c}\u{096d}\u{096e}\u{096f}", + ], + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => "\u{2019}", + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => null, + ], + [ + "decimalSeparator" => "\u{066b}", + "numberDelimiter" => "\u{066c}", + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => [ + "digits" => "\u{06f0}\u{06f1}\u{06f2}\u{06f3}\u{06f4}\u{06f5}\u{06f6}\u{06f7}\u{06f8}\u{06f9}", + ], + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 3, + ], + "numberingSystemData" => [ + "digits" => "\u{1040}\u{1041}\u{1042}\u{1043}\u{1044}\u{1045}\u{1046}\u{1047}\u{1048}\u{1049}", + ], + ], + [ + "decimalSeparator" => '.', + "numberDelimiter" => ',', + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 2, + ], + "numberingSystemData" => [ + "digits" => "\u{09e6}\u{09e7}\u{09e8}\u{09e9}\u{09ea}\u{09eb}\u{09ec}\u{09ed}\u{09ee}\u{09ef}", + ], + ], + [ + "decimalSeparator" => "\u{066b}", + "numberDelimiter" => "\u{066c}", + "minDigitsForThousandsSeparator" => 4, + "standardDecimalPatternInfo" => [ + "primaryGroupSize" => 3, + "secondaryGroupSize" => 2, + ], + "numberingSystemData" => [ + "digits" => "\u{06f0}\u{06f1}\u{06f2}\u{06f3}\u{06f4}\u{06f5}\u{06f6}\u{06f7}\u{06f8}\u{06f9}", + ], + ], + ]; + + const LOCALE_TO_INDEX = [ + "en_US" => 0, + "ca_ES" => 1, + "cs_CZ" => 2, + "cx_PH" => 0, + "cy_GB" => 0, + "da_DK" => 3, + "de_DE" => 4, + "eu_ES" => 4, + "en_PI" => 0, + "en_UD" => 0, + "en_OP" => 0, + "ck_US" => 0, + "es_LA" => 4, + "es_CL" => 4, + "es_CO" => 4, + "es_ES" => 1, + "es_MX" => 0, + "es_VE" => 4, + "gn_PY" => 4, + "fb_AA" => 0, + "fb_AC" => 0, + "fb_HA" => 0, + "fb_AR" => 5, + "fb_HX" => 0, + "fb_LS" => 0, + "fb_LL" => 0, + "fb_RL" => 0, + "fb_ZH" => 0, + "fi_FI" => 2, + "fr_FR" => 2, + "gl_ES" => 4, + "ht_HT" => 0, + "hu_HU" => 1, + "it_IT" => 3, + "ja_JP" => 0, + "ko_KR" => 0, + "nb_NO" => 2, + "nn_NO" => 2, + "nl_NL" => 4, + "fy_NL" => 4, + "pl_PL" => 1, + "pt_BR" => 4, + "pt_PT" => 1, + "ro_RO" => 4, + "ru_RU" => 2, + "sk_SK" => 2, + "sl_SI" => 4, + "sv_SE" => 2, + "th_TH" => 0, + "tr_TR" => 4, + "ku_TR" => 0, + "zh_CN" => 0, + "zh_HK" => 0, + "zh_TW" => 0, + "fb_LT" => 0, + "af_ZA" => 2, + "sq_AL" => 2, + "hy_AM" => 2, + "az_AZ" => 4, + "be_BY" => 1, + "bn_IN" => 6, + "bs_BA" => 4, + "bg_BG" => 1, + "hr_HR" => 3, + "nl_BE" => 4, + "en_GB" => 0, + "en_IN" => 6, + "eo_EO" => 2, + "et_EE" => 1, + "fo_FO" => 4, + "fr_CA" => 2, + "ka_GE" => 1, + "el_GR" => 4, + "gu_IN" => 6, + "hi_IN" => 6, + "is_IS" => 4, + "id_ID" => 4, + "ga_IE" => 0, + "jv_ID" => 0, + "kn_IN" => 0, + "kk_KZ" => 2, + "ky_KG" => 2, + "la_VA" => 0, + "lv_LV" => 1, + "li_NL" => 0, + "lt_LT" => 2, + "mi_NZ" => 0, + "mk_MK" => 4, + "mg_MG" => 0, + "ms_MY" => 0, + "mt_MT" => 0, + "mr_IN" => 7, + "mn_MN" => 0, + "ne_NP" => 8, + "pa_IN" => 6, + "rm_CH" => 9, + "sa_IN" => 0, + "sr_RS" => 4, + "so_SO" => 0, + "sw_KE" => 0, + "tl_PH" => 0, + "ta_IN" => 6, + "tt_RU" => 2, + "te_IN" => 6, + "ml_IN" => 6, + "uk_UA" => 2, + "uz_UZ" => 2, + "vi_VN" => 4, + "xh_ZA" => 0, + "zu_ZA" => 0, + "km_KH" => 4, + "tg_TJ" => 0, + "ar_AR" => 5, + "he_IL" => 0, + "ur_PK" => 0, + "fa_IR" => 10, + "sy_SY" => 0, + "yi_DE" => 0, + "qc_GT" => 0, + "qu_PE" => 0, + "ay_BO" => 0, + "se_NO" => 2, + "ps_AF" => 10, + "tl_ST" => 0, + "gx_GR" => 0, + "my_MM" => 11, + "qz_MM" => 11, + "or_IN" => 6, + "si_LK" => 0, + "hi_FB" => 6, + "eh_IN" => 0, + "rw_RW" => 4, + "ak_GH" => 0, + "nd_ZW" => 0, + "sn_ZW" => 0, + "cb_IQ" => 5, + "ha_NG" => 0, + "yo_NG" => 0, + "ja_KS" => 0, + "lg_UG" => 0, + "br_FR" => 2, + "zz_TR" => 0, + "tz_MA" => 2, + "co_FR" => 0, + "ig_NG" => 0, + "as_IN" => 12, + "am_ET" => 0, + "lo_LA" => 4, + "ny_MW" => 0, + "wo_SN" => 4, + "ff_NG" => 2, + "sc_IT" => 0, + "ln_CD" => 4, + "tk_TM" => 2, + "sz_PL" => 0, + "bp_IN" => 0, + "ns_ZA" => 0, + "tn_BW" => 0, + "st_ZA" => 0, + "ts_ZA" => 0, + "ss_SZ" => 0, + "ks_IN" => 13, + "ve_ZA" => 0, + "nr_ZA" => 0, + "ik_US" => 0, + "fv_NG" => 0, + "su_ID" => 0, + "om_ET" => 0, + "em_ZM" => 0, + "qr_GR" => 0, + "iu_CA" => 0, + "qk_DZ" => 0, + "qv_IT" => 0, + "qs_DE" => 0, + "qb_DE" => 0, + "qe_US" => 0, + "bv_DE" => 0, + "qt_US" => 0, + ]; + + /** + * @param string|null $localeTag + * @return array + */ + public static function get($localeTag): array + { + $key = $localeTag ?? self::DEFAULT_LOCALE; + $idx = self::LOCALE_TO_INDEX[$key] ?? null; + + return isset($idx) ? self::CONFIGS[$idx] : self::DEFAULT_CONFIG; + } +} diff --git a/src/fbt/Runtime/FbtRuntimeTypes.php b/src/fbt/Runtime/FbtRuntimeTypes.php new file mode 100644 index 0000000..38984fb --- /dev/null +++ b/src/fbt/Runtime/FbtRuntimeTypes.php @@ -0,0 +1,18 @@ + 0, + 'gender' => 1, + ]; + + const VALID_PRONOUN_USAGES_TYPE = [ + 'object' => 0, + 'possessive' => 1, + 'reflexive' => 2, + 'subject' => 3, + ]; +} diff --git a/src/fbt/Runtime/FbtTable.php b/src/fbt/Runtime/FbtTable.php new file mode 100644 index 0000000..2e5f5d3 --- /dev/null +++ b/src/fbt/Runtime/FbtTable.php @@ -0,0 +1,101 @@ +, ] to be processed by fbt::_ + */ + const ARG = [ + "INDEX" => 0, + "SUBSTITUTION" => 1, + ]; + + /** + * Performs a depth-first search on our table, attempting to access + * each table entry. The first entry found is the one we want, as we + * set defaults after preferred indices. For example: + * + * @param mixed $table - { + * // viewer gender + * '*': { + * // {num} plural + * '*': { + * // user-defined enum + * LIKE: '{num} people liked your update', + * COMMENT: '{num} people commented on your update', + * POST: '{num} people posted on a wall', + * }, + * SINGULAR: { + * LIKE: '{num} person liked your update', + * // ... + * }, + * DUAL: { ... } + * }, + * FEMALE: { + * // {num} plural + * '*': { ... }, + * SINGULAR: { ... }, + * DUAL: { ... } + * }, + * MALE: { ... } + * } + * + * Notice that LIKE and COMMENT here both have 'your' in them, whereas + * POST doesn't. The fallback ('*') translation for POST will be the same + * in both the male and female version, so that entry won't exist under + * table[FEMALE]['*'] or table[MALE]['*']. + * + * Similarly, PLURAL is a number variation that never appears in the table as it + * is the default/fallback. + * + * For example, if we have a female viewer, and a PLURAL number and a POST enum + * value, in the above example, we'll first attempt to get: + * table[FEMALE][PLURAL][POST]. undefined. Back Up, attempting to get + * table[FEMALE]['*'][POST]. undefined also. since it's the same as the '*' + * table['*'][PLURAL][POST]. ALSO undefined. Deduped to '*' + * table['*']['*'][POST]. There it is. + * + * @param array $args - fbt runtime arguments + * @param int $argsIndex - argument index we're currently visiting + * + * @return string|array|null + * + * @throws \fbt\Exceptions\FbtException + */ + public static function access($table, array $args, int $argsIndex) + { + // js~php diff: + + // Either we've reached the end of our arguments at a valid entry, in which + // case table is now a string (leaf) or we've accessed a key that didn't exist + // in the table, in which case we return null + if ($argsIndex >= count($args)) { + return $table; + } elseif ($table == null) { + return null; + } + + $pattern = null; + $arg = $args[$argsIndex]; + $tableIndex = $arg[self::ARG['INDEX']]; + + // Do we have a variation? Attempt table access in variation order + if (is_array($tableIndex)) { + foreach ($tableIndex as $index) { + $subTable = $table[$index] ?? null; + $pattern = self::access($subTable, $args, $argsIndex + 1); + if ($pattern !== null) { + break; + } + } + } else { + $table = $tableIndex !== null ? $table[$tableIndex] ?? null : $table; + $pattern = self::access($table, $args, $argsIndex + 1); + } + + return $pattern; + } +} diff --git a/src/fbt/Runtime/FbtTranslations.php b/src/fbt/Runtime/FbtTranslations.php new file mode 100644 index 0000000..2c7f65d --- /dev/null +++ b/src/fbt/Runtime/FbtTranslations.php @@ -0,0 +1,71 @@ + 0, + 'FEMALE_SINGULAR' => 1, + 'MALE_SINGULAR' => 2, + 'FEMALE_SINGULAR_GUESS' => 3, + 'MALE_SINGULAR_GUESS' => 4, + 'MIXED_SINGULAR' => 5, + 'MIXED_PLURAL' => 5, + 'NEUTER_SINGULAR' => 6, + 'UNKNOWN_SINGULAR' => 7, + 'FEMALE_PLURAL' => 8, + 'MALE_PLURAL' => 9, + 'NEUTER_PLURAL' => 10, + 'UNKNOWN_PLURAL' => 11, + ]; + + const DATA = [ + self::GENDER_CONST['NOT_A_PERSON'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => true, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themself', + "object" => 'this', + "string" => 'unknown', + ], + self::GENDER_CONST['UNKNOWN_SINGULAR'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => true, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themself', + "object" => 'them', + "string" => 'unknown singular', + ], + self::GENDER_CONST['FEMALE_SINGULAR'] => [ + "is_male" => false, + "is_female" => true, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'she', + "possessive" => 'her', + "reflexive" => 'herself', + "object" => 'her', + "string" => 'female singular', + ], + self::GENDER_CONST['FEMALE_SINGULAR_GUESS'] => [ + "is_male" => false, + "is_female" => true, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => true, + "is_unknown" => false, + "subject" => 'she', + "possessive" => 'her', + "reflexive" => 'herself', + "object" => 'her', + "string" => 'female singular', + ], + self::GENDER_CONST['MALE_SINGULAR'] => [ + "is_male" => true, + "is_female" => false, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'he', + "possessive" => 'his', + "reflexive" => 'himself', + "object" => 'him', + "string" => 'male singular', + ], + self::GENDER_CONST['MALE_SINGULAR_GUESS'] => [ + "is_male" => true, + "is_female" => false, + "is_neuter" => false, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => true, + "is_unknown" => false, + "subject" => 'he', + "possessive" => 'his', + "reflexive" => 'himself', + "object" => 'him', + "string" => 'male singular', + ], + self::GENDER_CONST['NEUTER_SINGULAR'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => true, + "is_plural" => false, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themself', + "object" => 'them', + "string" => 'neuter singular', + ], + self::GENDER_CONST['MIXED_PLURAL'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => false, + "is_plural" => true, + "is_mixed" => true, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themselves', + "object" => 'them', + "string" => 'mixed plural', + ], + self::GENDER_CONST['FEMALE_PLURAL'] => [ + "is_male" => false, + "is_female" => true, + "is_neuter" => false, + "is_plural" => true, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themselves', + "object" => 'them', + "string" => 'female plural', + ], + self::GENDER_CONST['MALE_PLURAL'] => [ + "is_male" => true, + "is_female" => false, + "is_neuter" => false, + "is_plural" => true, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themselves', + "object" => 'them', + "string" => 'male plural', + ], + self::GENDER_CONST['NEUTER_PLURAL'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => true, + "is_plural" => true, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => false, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themselves', + "object" => 'them', + "string" => 'neuter plural', + ], + self::GENDER_CONST['UNKNOWN_PLURAL'] => [ + "is_male" => false, + "is_female" => false, + "is_neuter" => false, + "is_plural" => true, + "is_mixed" => false, + "is_guess" => false, + "is_unknown" => true, + "subject" => 'they', + "possessive" => 'their', + "reflexive" => 'themselves', + "object" => 'them', + "string" => 'unknown plural', + ], + ]; + + public static function getData($gender, $usage) + { + $data = self::DATA; + + return $data[$gender] + ? $data[$gender][$usage] + : $data[self::GENDER_CONST['NOT_A_PERSON']][$usage]; + } +} diff --git a/src/fbt/Runtime/Shared/FbtHooks.php b/src/fbt/Runtime/Shared/FbtHooks.php new file mode 100644 index 0000000..7437865 --- /dev/null +++ b/src/fbt/Runtime/Shared/FbtHooks.php @@ -0,0 +1,228 @@ +getLocale(); + } + + /** + * @param string|null $inlineMode + * @return string + */ + public static function inlineMode($inlineMode = null): string + { + if (func_num_args() === 1) { + self::$inlineMode = $inlineMode; + } + + return self::$inlineMode ?? 'NO_INLINE'; + } + + public static function getIntlViewerContext(): IntlViewerContextInterface + { + $viewerContext = FbtConfig::get('viewerContext'); + + if (is_string($viewerContext) && class_exists($viewerContext)) { + $viewerContext = new $viewerContext(); + } + + return $viewerContext ?? new IntlViewerContext(); + } + + /** + * @param string $patternHash + * @return void + */ + public static function onTranslationOverride(string $patternHash) + { + if (isset(self::$actions[__FUNCTION__])) { + self::$actions[__FUNCTION__](...func_get_args()); + } + } + + /** + * @return void + * @throws \Throwable + */ + public static function onTerminating() + { + if (isset(self::$actions[__FUNCTION__])) { + self::$actions[__FUNCTION__](); + + return; + } + + register_shutdown_function(function () { + FbtHooks::storePhrases(); + FbtHooks::storeImpressions(); + }); + } + + public static function canInline(array $backtrace): bool + { + if (isset(self::$actions[__FUNCTION__])) { + return self::$actions[__FUNCTION__](...func_get_args()); + } + + return true; + } + + /** + * @param array $phrase + * @param null|int $parentId + * @return null|int + */ + public static function savePhrase(array $phrase, $parentId = null) + { + if (isset(self::$actions[__FUNCTION__])) { + return self::$actions[__FUNCTION__](...func_get_args()); + } + + $phraseSource = [ + 'type' => $phrase['type'], + 'jsfbt' => $phrase['jsfbt'], + ]; + + $hash = md5(json_encode($phraseSource) . $phrase['desc']); + + self::$sourceStrings['phrases'][] = $phrase; + self::$sourceHashes[$hash] = count(self::$sourceStrings['phrases']) - 1; + + if (! empty($parentId)) { + self::$sourceStrings['childParentMappings'][self::$sourceHashes[$hash]] = $parentId; + } + + return self::$sourceHashes[$hash]; + } + + /** + * @throws \Throwable + */ + public static function storePhrases() + { + $fbtDir = FbtConfig::get('path') . '/'; + $file = $fbtDir . '.source_strings.json'; + + if (file_exists($file)) { + self::$sourceStrings = json_decode(file_get_contents($file), true); + $phrases = array_merge(...array_column(self::$sourceStrings['phrases'] ?? [], 'hashToText')); + foreach (array_keys($phrases) as $hash) { + self::$storedHashes[$hash] = true; + } + } + + if (isset(self::$actions[__FUNCTION__])) { + self::$actions[__FUNCTION__](...func_get_args()); + + return; + } + + $sourceStrings = FbtTransform::toArray(); + $parentIds = []; + + foreach ($sourceStrings['phrases'] as $index => $phrase) { + if (isset(self::$storedHashes[array_keys($phrase['hashToText'])[0]])) { + continue; + } + + $parentKey = $sourceStrings['childParentMappings'][$index] ?? null; + + $parentIds[$index] = self::savePhrase($phrase, $parentIds[$parentKey] ?? null); + } + + if (self::$sourceHashes) { + file_put_contents($file, json_encode(self::$sourceStrings), LOCK_EX); + } + + FbtTransform::$childToParent = []; + FbtTransform::$phrases = []; + self::$sourceHashes = []; + } + + /** + * @return void + */ + public static function storeImpressions() + { + if (isset(self::$actions[__FUNCTION__])) { + self::$actions[__FUNCTION__](...func_get_args()); + } + } + + public static function loadTranslationGroups(): array + { + if (isset(self::$actions[__FUNCTION__])) { + return self::$actions[__FUNCTION__](...func_get_args()); + } + + return []; + } + + /** + * @param string $tag + * @param callable $action + * @return void + */ + public static function register(string $tag, callable $action) + { + self::$actions[$tag] = $action; + } + + /** + * @param string $tag + * @return void + */ + public static function unregister(string $tag) + { + if (array_key_exists($tag, self::$actions)) { + unset(self::$actions[$tag]); + } + } +} diff --git a/src/fbt/Runtime/Shared/FbtResult.php b/src/fbt/Runtime/Shared/FbtResult.php new file mode 100644 index 0000000..7095d69 --- /dev/null +++ b/src/fbt/Runtime/Shared/FbtResult.php @@ -0,0 +1,56 @@ +content = $contents; + } + + public function __toString() + { + if ($this->_stringValue !== null) { + return $this->_stringValue; + } + + $stringValue = ""; + $contents = $this->flattenToArray($this->content); + foreach ($contents as $content) { + if (is_string($content) || $content instanceof FbtResult) { + $stringValue .= (string)$content; + } else { + $this->onStringSerializationError($content); + } + } + + $this->_stringValue = $stringValue; + + return $stringValue; + } + + private function flattenToArray(array $contents = []): array + { + $result = []; + + foreach ($contents as $content) { + if (is_array($content)) { + $result = array_merge($result, $this->flattenToArray($content)); + } else { + if ($content instanceof FbtResult) { + $result = array_merge($result, $content->flattenToArray($content->content)); + } else { + $result[] = $content; + } + } + } + + return $result; + } +} diff --git a/src/fbt/Runtime/Shared/FbtTableAccessor.php b/src/fbt/Runtime/Shared/FbtTableAccessor.php new file mode 100644 index 0000000..167d170 --- /dev/null +++ b/src/fbt/Runtime/Shared/FbtTableAccessor.php @@ -0,0 +1,41 @@ + $className, + 'data-intl-hash' => $hash, + 'data-intl-locale' => FbtHooks::locale(), + // 'data-intl-translation' => $translation, + // 'data-intl-trid' => '', + ]); + } + } + + return new FbtResult($content); +} + +class InlineFbtResult +{ + public $contents; + public $inlineMode; + public $translation; + /** @var null|string */ + public $hash; + + public function __construct( + array $contents, + string $inlineMode, + string $translation, + $hash + ) { + $this->hash = $hash; + $this->translation = $translation; + $this->inlineMode = $inlineMode; + $this->contents = $contents; + } + + public function __toString() + { + return em($this->contents, $this->inlineMode, $this->translation, $this->hash); + } +} diff --git a/src/fbt/Runtime/Shared/IntlPunctuation.php b/src/fbt/Runtime/Shared/IntlPunctuation.php new file mode 100644 index 0000000..1ef8538 --- /dev/null +++ b/src/fbt/Runtime/Shared/IntlPunctuation.php @@ -0,0 +1,71 @@ +getVariation($number); + + invariant( + $numType & IntlVariations::INTL_VARIATION_MASK['NUMBER'], + 'Invalid number provided', + $numType, + gettype($numType) + ); + + return $number == 1 ? [self::EXACTLY_ONE, $numType, "*"] : [$numType, "*"]; + } + + /** + * Wrapper to validate gender. + * + * @throws \fbt\Exceptions\FbtException + */ + public static function getGenderVariations($gender): array + { + invariant( + $gender & IntlVariations::INTL_VARIATION_MASK['GENDER'], + 'Invalid gender provided: %s (%s)', + $gender, + gettype($gender) + ); + + return [$gender, "*"]; + } +} diff --git a/src/fbt/Runtime/Shared/fbs.php b/src/fbt/Runtime/Shared/fbs.php new file mode 100644 index 0000000..6976489 --- /dev/null +++ b/src/fbt/Runtime/Shared/fbs.php @@ -0,0 +1,7 @@ + "You have a cat in a photo album named {title}", + * "plural" => "You have cats in a photo album named {title}" + * ] + * -or- + * [ + * "singular" => ["You have a cat in a photo album named {title}", ], + * "plural" => ["You have cats in a photo album named {title}", ] + * ] + * + * or table can simply be a pattern string: + * "You have a cat in a photo album named {title}" + * -or- + * ["You have a cat in a photo album named {title}", ] + * + * @param null|array $inputArgs - arguments from which to pull substitutions + * Example: [["singular", null], [null, ['title' => "felines!"]]] + * + * @param array $options - options for runtime + * translation dictionary access. hk stands for hash key which is used to look + * up translated payload in React Native. ehk stands for enum hash key which + * contains a structured enums to hash keys map which will later be traversed + * to look up enum-less translated payload. + * + * @param bool $reporting + * + * @return FbtResult|InlineFbtResult + * @throws FbtException + * @throws \fbt\Exceptions\FbtInvalidConfigurationException + */ + public function _($inputTable, $inputArgs, array $options = [], bool $reporting = true) + { + // Adapt the input payload to the translated table and arguments we expect + // + // WWW: The payload is ready, as-is, and is pre-translated UNLESS we detect + // the magic BINAST string which needs to be stripped if it exists. + // + // RN: we look up our translated table via the hash key (options.hk) and + // flattened enum hash key (options.ehk), which partially resolves the + // translation for the enums (should they exist). + // + // OSS: The table is the English payload, and, by default, we lookup the + // translated payload via FbtTranslations + list($pattern, $args) = FbtTranslations::getTranslatedInput($inputTable, $inputArgs, $options) ?? [$inputTable, $inputArgs, FbtTranslations::DEFAULT_SRC_LOCALE]; + + // [fbt_impressions] + // If this is a string literal (no tokens to substitute) then 'args' is empty + // and the logic will skip the table traversal. + + // [table traversal] + // At this point we assume that table is a hash (possibly nested) that we + // need to traverse in order to pick the correct string, based on the + // args that follow. + $allSubstitutions = []; + + if (! empty($pattern['__vcg'])) { + $args = $args ?? []; + $gender = FbtHooks::getIntlViewerContext()->getGender(); + $variation = IntlVariationResolverImpl::getGenderVariations($gender); + array_unshift($args, FbtTableAccessor::getGenderResult($variation, null, $gender)); + } + + if ($args) { + if (! is_string($pattern)) { + // On mobile, table can be accessed at the native layer when fetching + // translations. If pattern is not a string here, table has not been accessed + $pattern = FbtTable::access($pattern, $args, 0); + } + $allSubstitutions = array_merge(...array_map(function ($arg) { + return $arg[FbtTable::ARG['SUBSTITUTION']] ?? []; + }, $args)); + invariant($pattern !== null, 'Table access failed'); + } + + $patternHash = null; + if (is_array($pattern)) { + // [fbt_impressions] + // When logging of string impressions is enabled, the string and its hash + // are packaged in an array. We want to log the hash + $patternString = $pattern[0]; + $patternHash = $pattern[1]; + // Append '1_' for appid's prepended to our i18n hash + // (see intl_get_application_id) + $stringID = '1_' . $patternHash; + if (! empty(FbtQTOverrides::$overrides[$stringID])) { + $patternString = FbtQTOverrides::$overrides[$stringID]; + FbtHooks::onTranslationOverride($patternHash); + } + FbtHooks::logImpression($patternHash); + } elseif (is_string($pattern)) { + $patternString = $pattern; + } else { + throw new FbtException( + 'Table access did not result in string: ' . + ($pattern === null ? 'null' : json_encode($pattern)) . + ', Type: ' . + gettype($pattern) + ); + } + + $cachedFbt = self::$_cachedFbtResults[$patternString] ?? null; + $hasSubstitutions = FbtUtils::hasKeys($allSubstitutions); + if ($cachedFbt && ! $hasSubstitutions) { + return $cachedFbt; + } else { + $fbtContent = FbtUtils::substituteTokens($patternString, $allSubstitutions); + $result = $this->_wrapContent($fbtContent, $patternString, $patternHash, $reporting); + if (! $hasSubstitutions) { + self::$_cachedFbtResults[$patternString] = $result; + } + + return $result; + } + } + + /** + * fbt::enum() takes an enum value and returns a tuple in the format: + * [value, null] + * @param $value - Example: "id1" + * @param $range - Example: ["id1" => "groups", "id2" => "videos", ...] + * + * @throws \fbt\Exceptions\FbtException + */ + public static function _enum(string $value, array $range): array + { + if (FbtConfig::get('debug')) { + invariant(isset($range[$value]), 'invalid value: %s', $value); + } + + return FbtTableAccessor::getEnumResult($value); + } + + /** + * fbt::name() takes a `label`, `value`, and `gender` and + * returns a tuple in the format: + * [gender, {label: "replaces {label} in pattern string"}] + * @param string $label - Example: "label" + * @param mixed $value + * - E.g. 'replaces {label} in pattern' + * @param int $gender - Example: "IntlVariations::GENDER_FEMALE" + * + * @return array + * + * @throws FbtException + */ + public static function _name(string $label, $value, int $gender): array + { + $variation = IntlVariationResolverImpl::getGenderVariations($gender); + $substitution = []; + $substitution[$label] = $value; + + return FbtTableAccessor::getGenderResult($variation, $substitution, $gender); + } + + /** + * fbt::_subject() takes a gender value and returns a tuple in the format: + * [variation, null] + * @param int $value - Example: "16777216" + * + * @throws \fbt\Exceptions\FbtException + */ + public static function _subject(int $value): array + { + return FbtTableAccessor::getGenderResult( + IntlVariationResolverImpl::getGenderVariations($value), + null, + $value + ); + } + + /** + * fbt::param() takes a `label` and `value` returns a tuple in the format: + * [?variation, {label: "replaces {label} in pattern string"}] + * @param string $label - Example: "label" + * @param mixed $value + * - E.g. 'replaces {label} in pattern' + * @param array $variations - Variation type and variation value (if explicitly provided) + * E.g. + * number: `[0]`, `[0, $count]`, or `[0, foo::someNumber() + 1]` + * gender: `[1, $someGender]` + * + * @return array + * @throws FbtException + */ + public static function _param(string $label, $value, array $variations = []): array + { + $substitution = [$label => $value]; + if ($variations) { + if ($variations[0] === FbtRuntimeTypes::PARAM_VARIATION_TYPE['number']) { + $number = count($variations) > 1 ? $variations[1] : $value; + invariant(is_numeric($number), 'fbt::param expected number'); + + $variation = IntlVariationResolverImpl::getNumberVariations($number); // this will throw if `number` is invalid + if (is_numeric($value)) { + $substitution[$label] = + intlNumUtils::formatNumberWithThousandDelimiters($value); + } + + return FbtTableAccessor::getNumberResult($variation, $substitution, $number); + } elseif ($variations[0] === FbtRuntimeTypes::PARAM_VARIATION_TYPE['gender']) { + $gender = $variations[1]; + invariant($gender != null, 'expected gender value'); + + return FbtTableAccessor::getGenderResult( + IntlVariationResolverImpl::getGenderVariations($gender), + $substitution, + $gender + ); + } else { + invariant(false, 'Unknown invariant mask'); + } + } + + return FbtTableAccessor::getSubstitution($substitution); + } + + /** + * fbt::_plural() takes a `count` and 2 optional params: `label` and `value`. + * It returns a tuple in the format: + * [?variation, {label: "replaces {label} in pattern string"}] + * @param float $count - Example: 2 + * @param string|null $label + * - E.g. 'replaces {number} in pattern' + * @param mixed|null $value + * - The value to use (instead of count) for replacing {label} + * + * @return array + * @throws FbtException + */ + public static function _plural(float $count, $label = null, $value = null): array + { + $variation = IntlVariationResolverImpl::getNumberVariations($count); + $substitution = []; + if ($label) { + if (is_numeric($value)) { + $substitution[$label] = intlNumUtils::formatNumberWithThousandDelimiters($value); + } else { + $substitution[$label] = $value ?? intlNumUtils::formatNumberWithThousandDelimiters($count); + } + } + + return FbtTableAccessor::getNumberResult($variation, $substitution, $count); + } + + /** + * fbt::pronoun() takes a 'usage' string and a Gender::GENDER_CONST value and returns a tuple in the format: + * [variations, null] + * @param $usage - Example: FbtConstants::PRONOUN_USAGE['OBJECT']. + * @param $gender - Example: Gender::GENDER_CONST['MALE_SINGULAR'] + * @param $options - Example: [ 'human' => 1 ] + * + * @throws \fbt\Exceptions\FbtException + */ + public static function _pronoun(string $usage, int $gender, array $options = []): array + { + invariant( + $gender !== Gender::GENDER_CONST['NOT_A_PERSON'] || ! $options || empty($options['human']), + 'Gender cannot be Gender::GENDER_CONST[\'NOT_A_PERSON\'] if you set "human" to true' + ); + $genderKey = JSFbtBuilder::getPronounGenderKey($usage, $gender); + + return FbtTableAccessor::getPronounResult($genderKey); + } + + /** + * @param string|array $fbtContent + * @param string $patternString + * @param string|null $patternHash + * @param bool $reporting + * + * @return FbtResult|InlineFbtResult + */ + private function _wrapContent($fbtContent, string $patternString, $patternHash, bool $reporting = true) + { + $contents = is_string($fbtContent) ? [$fbtContent] : $fbtContent; + + $inlineMode = FbtHooks::inlineMode(); + + if ($reporting && $inlineMode && $inlineMode !== 'NO_INLINE') { + return new InlineFbtResult( + $contents, + $inlineMode, + $patternString, + $patternHash + ); + } + + return new FbtResult($contents); + } +} diff --git a/src/fbt/Runtime/Shared/intlNumUtils.php b/src/fbt/Runtime/Shared/intlNumUtils.php new file mode 100644 index 0000000..ac03530 --- /dev/null +++ b/src/fbt/Runtime/Shared/intlNumUtils.php @@ -0,0 +1,429 @@ + DEFAULT_GROUPING_SIZE, + 'secondaryGroupSize' => DEFAULT_GROUPING_SIZE, + ], $numberingSystemData = null) + { + $primaryGroupingSize = $standardPatternInfo['primaryGroupSize'] ?? DEFAULT_GROUPING_SIZE; + $secondaryGroupingSize = $standardPatternInfo['secondaryGroupSize'] ?? $primaryGroupingSize; + + $digits = $numberingSystemData['digits'] ?? null; + + if (is_float($value) && is_nan($value)) { + $v = 0; + } elseif ($decimals == null) { + $v = (string)$value; + } elseif (is_string($value)) { + $v = truncateLongNumber($value, $decimals); + } else { + $v = _roundNumber($value, $decimals); + } + + $valueParts = explode('.', $v); + $wholeNumber = $valueParts[0]; + $decimal = $valueParts[1] ?? null; + + if (abs(mb_strlen(strval(intval($wholeNumber)))) >= $minDigitsForThousandDelimiter) { + $replaceWith = '$1' . $thousandDelimiter . '$2$3'; + $primaryPattern = '(\\d)(\\d{' . ($primaryGroupingSize - 0) . '})($|\\D)'; + $replaced = preg_replace(_buildRegex($primaryPattern), $replaceWith, $wholeNumber); + if ($replaced != $wholeNumber) { + $wholeNumber = $replaced; + $secondaryPatternString = '(\\d)(\\d{' . ($secondaryGroupingSize - 0) . '})(' . escapeRegex($thousandDelimiter) . ')'; + $secondaryPattern = _buildRegex($secondaryPatternString); + while (($replaced = preg_replace($secondaryPattern, $replaceWith, $wholeNumber)) != $wholeNumber) { + $wholeNumber = $replaced; + } + } + } + if ($digits !== null) { + $wholeNumber = _replaceWithNativeDigits($wholeNumber, $digits); + $decimal = $decimal && _replaceWithNativeDigits($decimal, $digits); + } + + $result = $wholeNumber; + if ($decimal) { + $result .= $decimalDelimiter . $decimal; + } + + return $result; + } + + /** + * Format a number for string output. + * + * This will format a given number according to the user's locale. + * Thousand delimiters will be added. Use `formatNumber` if you don't + * want them to be added. + * + * You may optionally specify the number of decimal places that should + * be displayed. For instance, pass `0` to round to the nearest + * integer, `2` to round to nearest cent when displaying currency, etc. + */ + public static function formatNumberWithThousandDelimiters($value, $decimals = null) + { + $NumberFormatConfig = NumberFormatConsts::get(FbtHooks::locale()); + + return self::formatNumberRaw($value, $decimals, $NumberFormatConfig["numberDelimiter"], $NumberFormatConfig["decimalSeparator"], $NumberFormatConfig["minDigitsForThousandsSeparator"], $NumberFormatConfig["standardDecimalPatternInfo"], $NumberFormatConfig["numberingSystemData"]); + } + + /** + * Format a number for string output. + * + * This will format a given number according to the specified significant + * figures. + * + * Also, specify the number of decimal places that should + * be displayed. For instance, pass `0` to round to the nearest + * integer, `2` to round to nearest cent when displaying currency, etc. + * + * Example: + * > formatNumberWithLimitedSigFig(123456789, 0, 2) + * "120,000,000" + * > formatNumberWithLimitedSigFig(1.23456789, 2, 2) + * "1.20" + */ + public static function formatNumberWithLimitedSigFig($value, $decimals, $numSigFigs) + { + // First make the number sufficiently integer-like. + $power = _getNumberOfPowersOfTen($value); + $inflatedValue = $value; + if ($power < $numSigFigs) { + $inflatedValue = $value * pow(10, -$power + $numSigFigs); + } + // Now that we have a large enough integer, round to cut off some digits. + $roundTo = pow(10, _getNumberOfPowersOfTen($inflatedValue) - $numSigFigs + 1); + $truncatedValue = round($inflatedValue / $roundTo) * $roundTo; + // Bring it back to whatever the number's magnitude was before. + if ($power < $numSigFigs) { + $truncatedValue /= pow(10, -$power + $numSigFigs); + // Determine numer of decimals based on sig figs + if ($decimals == null) { + return self::formatNumberWithThousandDelimiters($truncatedValue, $numSigFigs - $power - 1); + } + } + + // Decimals + return self::formatNumberWithThousandDelimiters($truncatedValue, $decimals); + } + + public static function parseNumber($text) + { + $NumberFormatConfig = NumberFormatConsts::get(FbtHooks::locale()); + + return self::parseNumberRaw($text, $NumberFormatConfig['decimalSeparator '] ?? '.', $NumberFormatConfig['numberDelimiter']); + } + + /** + * Parse a number. + * + * If the number is preceded or followed by a currency symbol or other + * letters, they will be ignored. + * + * A decimal delimiter should be passed to respect the user's locale. + * + * Calling this function directly is discouraged, unless you know + * exactly what you're doing. Consider using `parseNumber` below. + */ + public static function parseNumberRaw($text, $decimalDelimiter, $numberDelimiter = '') + { + // Replace numerals based on current locale data + $digitsMap = _getNativeDigitsMap(); + $_text = $text; + if ($digitsMap) { + $_text = trim(implode('', array_map(function ($character) { + return $digitsMap[$character] ?? $character; + }, explode('', $text)))); + } + + $_text = preg_replace("/^[^\d]*\-/", "\u{0002}", $_text); // preserve negative sign + $_text = preg_replace(matchCurrenciesWithDots(), '', $_text); // remove some currencies + + $decimalExp = escapeRegex($decimalDelimiter); + $numberExp = escapeRegex($numberDelimiter); + + $isThereADecimalSeparatorInBetween = _buildRegex('^[^\\d]*\\d.*' . $decimalExp . '.*\\d[^\\d]*$'); + if (! preg_match($isThereADecimalSeparatorInBetween, $_text)) { + $isValidWithDecimalBeforeHand = _buildRegex('(^[^\\d]*)' . $decimalExp . '(\\d*[^\\d]*$)'); + if (preg_match($isValidWithDecimalBeforeHand, $_text)) { + $_text = preg_replace($isValidWithDecimalBeforeHand, "$1\u{0001}$2", $_text); + + return _parseCodifiedNumber($_text); + } + $isValidWithoutDecimal = _buildRegex('^[^\\d]*[\\d ' . escapeRegex($numberExp) . ']*[^\\d]*$'); + if (! preg_match($isValidWithoutDecimal, $_text)) { + $_text = ''; + } + + return _parseCodifiedNumber($_text); + } + $isValid = _buildRegex('(^[^\\d]*[\\d ' . $numberExp . ']*)' . $decimalExp . '(\\d*[^\\d]*$)'); + $_text = preg_match($isValid, $_text) ? preg_replace($isValid, "$1\u{0001}$2", $_text) : ''; + + return _parseCodifiedNumber($_text); + } + + public static function truncateLongNumber($number, $decimals = null) + { + $pos = strpos($number, '.'); + $dividend = $pos ? $number : substr($number, 0, $pos); + $remainder = $pos ? '' : substr($number, $pos + 1); + + return $decimals !== null ? $dividend . '.' . addZeros(substr($remainder, 0, $decimals), $decimals - mb_strlen($remainder)) : $dividend; + } + + /** + * Converts a float into a prettified string. e.g. 1000.5 => "1,000.5" + * + * @deprecated Use `intlNumber::formatNumberWithThousandDelimiters(num)` + * instead. It automatically handles decimal and thousand delimiters and + * gets edge cases for Norwegian and Spanish right. + * + */ + public static function getFloatString($num, $thousandDelimiter, $decimalDelimiter) + { + $str = (string)$num; + $pieces = explode('.', $str); + + $intPart = self::getIntegerString($pieces[0], $thousandDelimiter); + if (mb_strlen($pieces) === 1) { + return $intPart; + } + + return $intPart . $decimalDelimiter . $pieces[1]; + } + + /** + * Converts an integer into a prettified string. e.g. 1000 => "1,000" + * + * @deprecated Use `intlNumber::formatNumberWithThousandDelimiters(num, 0)` + * instead. It automatically handles decimal thousand delimiters and gets + * edge cases for Norwegian and Spanish right. + * + */ + public static function getIntegerString($num, $thousandDelimiter) + { + $delim = $thousandDelimiter; + if ($delim === '') { + //if (__DEV__) { + // throw new \Exception('thousandDelimiter cannot be empty string!'); + //} + $delim = ','; + } + + $str = (string)$num; + $regex = "/(\d+)(\d{3})/"; + while (preg_match($regex, $str)) { + $str = preg_replace($regex, '$1' . $delim . '$2', $str); + } + + return $str; + } +} diff --git a/src/fbt/Runtime/fbtElement.php b/src/fbt/Runtime/fbtElement.php new file mode 100644 index 0000000..24bb3dd --- /dev/null +++ b/src/fbt/Runtime/fbtElement.php @@ -0,0 +1,41 @@ +children = $children; + $this->tag = $tag; + $this->content = $content; + $this->attributes = $attributes; + } + + public function __toString() + { + $attributes = array_diff_key($this->attributes, FbtUtils::FBT_CORE_ATTRIBUTES); + $content = implode('', array_map(function (self $child) { + return (string)$child; + }, $this->children)) ?: $this->content; + + if ($this->tag === 'text') { + return $content; + } + + return createElement($this->tag, $content, $attributes); + } +} diff --git a/src/fbt/Runtime/fbtNamespace.php b/src/fbt/Runtime/fbtNamespace.php new file mode 100644 index 0000000..00b46bb --- /dev/null +++ b/src/fbt/Runtime/fbtNamespace.php @@ -0,0 +1,127 @@ +text = $text; + $this->desc = $desc; + $this->moduleName = $moduleName; + + $this->_getOptions($options); + } + + public static function __callStatic($method, $args) + { + return self::{$method}(...$args); + } + + protected static function plural(Node $node, array $args) + { + return new fbtNode('plural', $node, $args); + } + + /** + * @param Node $node + * @param array $args + * @param string|fbtElement|fbtNamespace $value + * + * @return fbtNode + */ + protected static function param(Node $node, array $args, $value = '') + { + return new fbtNode('param', $node, $args, $value); + } + + protected static function enum(Node $node, array $args) + { + return new fbtNode('enum', $node, $args); + } + + protected static function pronoun(Node $node, array $args) + { + return new fbtNode('pronoun', $node, $args); + } + + /** + * @param Node $node + * @param array $args + * @param string|fbtElement|fbtNamespace $value + * + * @return fbtNode + */ + protected static function name(Node $node, array $args, $value = '') + { + return new fbtNode('name', $node, $args, $value); + } + + protected static function sameParam(Node $node, array $args) + { + return new fbtNode('sameParam', $node, $args); + } + + /** + * @throws \fbt\Exceptions\FbtException + * @throws \fbt\Exceptions\FbtParserException + */ + public function __toString(): string + { + $this->_collectFbtCalls(); + + $isTable = $this->_isTableNeeded(); + + $texts = $this->_getTexts($this->variations, $isTable); + + $desc = $this->_getDescription(); + + // js~php diff: + $textPackager = new TextPackager(FbtConfig::get('hash_module')); + + $phrase = $textPackager->pack([$this->_getPhrase($texts, $desc, $isTable)])[0]; + + if (FbtConfig::get('collectFbt') && empty($phrase['doNotExtract'])) { + FbtTransform::$phrases[] = $phrase; + + if (! empty($phrase['implicitFbt'])) { + FbtTransform::addEnclosingString(count(FbtTransform::$phrases) - 1, count(FbtTransform::$phrases)); + } + } + + $table = $phrase['type'] === FbtConstants::FBT_TYPE['TABLE'] + ? $phrase['jsfbt']['t'] + : $phrase['jsfbt']; + + $modules = [ + FbtConstants::MODULE_NAME['FBT'] => Shared\fbt::class, + FbtConstants::MODULE_NAME['FBS'] => Shared\fbs::class, + ]; + $reporting = ! isset($phrase['reporting']) || ! empty($phrase['reporting']); + + return (string)call_user_func_array( + [new $modules[$this->moduleName](), '_'], + [$table, $this->runtimeArgs, FbtRuntimeTransform::transform($phrase), $reporting] + ); + } +} diff --git a/src/fbt/Runtime/fbtNode.php b/src/fbt/Runtime/fbtNode.php new file mode 100644 index 0000000..87b1e8a --- /dev/null +++ b/src/fbt/Runtime/fbtNode.php @@ -0,0 +1,31 @@ +value = $value; + $this->args = $args; + $this->node = $node; + $this->name = $name; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/fbt/Services/TranslationsGeneratorService.php b/src/fbt/Services/TranslationsGeneratorService.php new file mode 100644 index 0000000..443abef --- /dev/null +++ b/src/fbt/Services/TranslationsGeneratorService.php @@ -0,0 +1,185 @@ +: { + * : , + * ... + * }, + * ... + * } + * + */ +class TranslationsGeneratorService +{ + /* @var array */ + private $translations = []; + + /** + * @param string $path + * @param string|null $translationsPath + * @param bool $pretty + * + * @return void + */ + private function prepareTranslations(string $path, $translationsPath, bool $pretty) + { + $fbtDir = $path . '/'; + + if (! is_dir($fbtDir)) { + mkdir($fbtDir, 0755, true); + } + + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + if ($pretty) { + $flags |= JSON_PRETTY_PRINT; + } + + if ($translationsPath) { + $files = glob($translationsPath); + + foreach ($files as $file) { + $translations = json_decode(file_get_contents($file), true); + $this->translations += $translations; + } + + return; + } + + $this->translations = FbtHooks::loadTranslationGroups(); + + file_put_contents($fbtDir . '/.translations.json', json_encode($this->translations, $flags)); + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + private function processTranslations(array $fbtSites, array $group): array + { + $config = TranslationConfig::fromFBLocale($group['fb-locale']); + $translations = FbtUtils::objMap($group['translations'], function ($translation) { + return TranslationData::fromJSON($translation); + }); + $translatedPhrases = array_map(function ($fbtSite) use ($translations, $config) { + // fbt diff: We are including a hash for reporting and logging. + return (new TranslationBuilder($translations, $config, $fbtSite, true))->build(); + }, $fbtSites); + + return [ + 'fb-locale' => $group['fb-locale'], + 'translatedPhrases' => $translatedPhrases, + ]; + } + + private function processGroups($phrases, $translatedGroups): array + { + $localeToHashToFbt = []; + + foreach ($translatedGroups as $group) { + $localeToHashToFbt[$group['fb-locale']] = []; + foreach ($phrases as $idx => $phrase) { + $translatedFbt = $group['translatedPhrases'][$idx]; + $payload = $phrase['type'] === 'text' ? $phrase['jsfbt'] : $phrase['jsfbt']['t']; + $hash = fbtHash::fbtHashKey($payload, $phrase['desc']); + $localeToHashToFbt[$group['fb-locale']][$hash] = $translatedFbt; + } + } + + return $localeToHashToFbt; + } + + /** + * @param string $path + * @param string|null $translationsPath + * @param string|null $stdin + * @param bool $pretty + * + * @throws \fbt\Exceptions\FbtException + */ + public function exportTranslations(string $path, $translationsPath, $stdin, bool $pretty) + { + if (empty($stdin)) { + $this->prepareTranslations($path, $translationsPath, $pretty); + + $file = $path . '/.source_strings.json'; + $sourceStrings = json_decode(file_get_contents($file), true); + } else { + $sourceStrings = json_decode($stdin, true); + $this->translations = $sourceStrings['translationGroups']; + } + + $phrases = $sourceStrings['phrases']; + $fbtSites = array_map('\fbt\Transform\FbtTransform\Translate\FbtSite::fromScan', $phrases); + $translatedGroups = array_map(function ($group) use ($fbtSites) { + return self::processTranslations($fbtSites, $group); + }, $this->translations); + + $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + if ($pretty) { + $flags |= JSON_PRETTY_PRINT; + } + + file_put_contents($path . '/translatedFbts.json', json_encode($this->processGroups($phrases, $translatedGroups), $flags)); + } +} diff --git a/src/fbt/Transform/FbtHash.php b/src/fbt/Transform/FbtHash.php new file mode 100644 index 0000000..d11307a --- /dev/null +++ b/src/fbt/Transform/FbtHash.php @@ -0,0 +1,86 @@ + $hk, + ]; + } +} diff --git a/src/fbt/Transform/FbtTransform/FbtAutoWrap.php b/src/fbt/Transform/FbtTransform/FbtAutoWrap.php new file mode 100644 index 0000000..3bcec30 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtAutoWrap.php @@ -0,0 +1,214 @@ + 'implicit', + 'EXPLICIT' => 'explicit', + 'NULL' => 'null', + ]; + + /** + * Given a node that is a child of an node and the phrase that the node + * is within, the implicit node becomes the child of a new node. + * + * WARNING: this method has side-effects because it alters the given `node` object + * You shouldn't try to run this multiple times on the same `node`. + * + * @throws \fbt\Exceptions\FbtParserException + */ + public static function wrapImplicitFBTParam(string $moduleName, Node $node): Node + { + // js~php diff: + $node->implicitDesc = $node->getAttribute('implicitDesc'); + $node->implicitFbt = 'true'; + $node->paramName = trim(FbtUtils::normalizeSpaces(self::collectRawString($moduleName, $node))); + self::createDescAttribute($node); + + return $node; + /* + * $fbtNode = clone $node; + * $fbtNode->attr = [ + * 'implicitDesc' => $node->getAttribute('implicitDesc'), + * 'implicitFbt' => 'true', + * 'paramName' => trim(FbtUtils::normalizeSpaces(self::collectRawString($moduleName, $node))), + * ]; + * self::createDescAttribute($fbtNode); + * $fbtNode->tag = $moduleName; + * $fbtNode->children = [$node]; + * return $fbtNode; + */ + } + + /** + * Given a node, this function creates a JSXIdentifier with the the node's + * implicit description as the description. + * @return void + */ + public static function createDescAttribute(Node $node) + { + $descString = 'In the phrase: "' . $node->getAttribute('implicitDesc') . '"'; + + $node->setAttribute('desc', $descString); + } + + /** + * Returns either the string contained with a JSXText node. + */ + public static function getLeafNodeString(Node $node): string + { + return $node->isText() + && $node->parent->tag !== 'fbt:param' // js~php diff + ? FbtUtils::normalizeSpaces($node->innertext()) : ''; + } + + /** + * Collects the raw strings below a given node. Explicit fbt param nodes + * amend their 'name' attribute wrapped with [ ] only if they are the + * child of the base node. + * @param $child - False initially, true when the function + * recursively calls itself with children nodes so only explicit + * children are wrapped and not the base node. + * + * @throws \fbt\Exceptions\FbtParserException + */ + public static function collectRawString($moduleName, Node $node, bool $child = false): string + { + if (! $node->nodes) { + return self::getLeafNodeString($node); + } elseif (self::getParamType($moduleName, $node) === self::FBT_PARAM_TYPE['EXPLICIT'] && $child) { + return '[' . self::getExplicitParamName($node) . ']'; + } else { + $filteredChildren = FbtUtils::filterEmptyNodes($node->nodes); + $string = implode('', array_map(function ($_child) use ($moduleName) { + return self::collectRawString($moduleName, $_child, true); + }, $filteredChildren)); + + return FbtUtils::normalizeSpaces(trim($string)); + } + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + public static function getExplicitParamName(Node $node): string + { + return FbtUtils::getAttributeByNameOrThrow($node, 'name'); + } + + /** + * Given a parent node, calls createDescriptionsWithStack with an + * empty stack to be filled + * + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + public static function createImplicitDescriptions(string $moduleName, Node $node) + { + self::createDescriptionsWithStack($moduleName, $node, []); + } + + /** + * Creates the description for all children nodes that are implicitly + * nodes by creating the queue that is the path from the parent + * fbt node to each node. + * + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + public static function createDescriptionsWithStack(string $moduleName, Node $node, array $stack) + { + $stack[] = $node; + + if ($node->children()) { + $filteredChildren = FbtUtils::filterEmptyNodes($node->nodes); + foreach ($filteredChildren as $child) { + if ($child->isElement() && FbtUtils::validateNamespacedFbtElement($moduleName, $node) === 'implicitParamMarker') { + $child->setAttribute('implicitDesc', self::collectTokenStringFromStack($moduleName, $stack, 0)); + } + + self::createDescriptionsWithStack($moduleName, $child, $stack); + } + } + + array_pop($stack); + } + + /** + * Collects the token string from the stack by tokenizing the children of the + * target implicit param, as well as other implicit or explicit + * nodes that do not contain the current implicit node. + * The stack looks like: + * [topLevelNode, ancestor1, ..., immediateParent, targetNode] + * + * @throws \fbt\Exceptions\FbtParserException + */ + public static function collectTokenStringFromStack(string $moduleName, array $nodeStack, int $index): string + { + if ($index >= count($nodeStack)) { + return ''; + } + + $tokenString = ''; + $currentNode = $nodeStack[$index]; + $nextNode = $nodeStack[$index + 1] ?? null; + $filteredChildren = FbtUtils::filterEmptyNodes($currentNode->nodes); + + foreach ($filteredChildren as $child) { + if ($child === $nextNode) { + // If node is on our ancestor path, descend recursively to + // construct the string + $tokenString .= self::collectTokenStringFromStack($moduleName, $nodeStack, $index + 1); + } else { + $suffix = self::collectRawString($moduleName, $child); + + if ($child === $currentNode || self::isImplicitOrExplicitParam($moduleName, $child)) { + $suffix = self::tokenizeString($suffix); + } + + $tokenString .= $suffix; + } + } + + return trim($tokenString); + } + + /** + * Given a string, returns the same string wrapped with a token marker. + */ + public static function tokenizeString(string $s): string + { + return '{=' . $s . '}'; + } + + public static function isImplicitOrExplicitParam(string $moduleName, Node $node): bool + { + return self::getParamType($moduleName, $node) !== self::FBT_PARAM_TYPE['NULL']; + } + + /** + * Returns if the node is implicitly or explicitly a + */ + public static function getParamType(string $moduleName, Node $node): string + { + if (! $node->isElement()) { + return self::FBT_PARAM_TYPE['NULL']; + } + + $nodeFBTElementType = FbtUtils::validateNamespacedFbtElement($moduleName, $node); + + switch ($nodeFBTElementType) { + case 'implicitParamMarker': + return self::FBT_PARAM_TYPE['IMPLICIT']; + case 'param': + case 'FbtParam': + return self::FBT_PARAM_TYPE['EXPLICIT']; + default: + return self::FBT_PARAM_TYPE['NULL']; + } + } +} diff --git a/src/fbt/Transform/FbtTransform/FbtCommon.php b/src/fbt/Transform/FbtTransform/FbtCommon.php new file mode 100644 index 0000000..9e59a7f --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtCommon.php @@ -0,0 +1,55 @@ +getMessage()); + } + invariant(is_array($fbtCommonData), 'File content (' . $opts['fbtCommonPath'] . ') must be an array.'); + self::$textToDesc = array_merge(self::$textToDesc, $fbtCommonData); + } + } + + /** + * @param string $text + * @return string|null + */ + public static function getDesc(string $text) + { + return self::$textToDesc[$text] ?? null; + } + + public static function getUnknownCommonStringErrorMessage(string $moduleName, string $text): string + { + return "Unknown string \"$text\" for <$moduleName common=\"true\">"; + } +} diff --git a/src/fbt/Transform/FbtTransform/FbtConstants.php b/src/fbt/Transform/FbtTransform/FbtConstants.php new file mode 100644 index 0000000..41396b5 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtConstants.php @@ -0,0 +1,116 @@ + 0, + "possessive" => 1, + "reflexive" => 2, + "subject" => 3, + ]; + + const PRONOUN_USAGE = [ + "OBJECT" => 0, + "POSSESSIVE" => 1, + "REFLEXIVE" => 2, + "SUBJECT" => 3, + ]; + + const PLURAL_REQUIRED_ATTRIBUTES = [ + 'count' => true, + ]; + + const SHOW_COUNT = [ + 'yes' => true, + 'no' => true, + 'ifMany' => true, + ]; + + const PLURAL_OPTIONS = [ + 'value' => true, // optional value to replace token (rather than count) + 'showCount' => self::SHOW_COUNT, + 'name' => true, // token + 'many' => true, + ]; + + public static function validPluralOptions(): array + { + return array_merge( + [], + self::PLURAL_OPTIONS, + self::PLURAL_REQUIRED_ATTRIBUTES + ); + } + + const VALID_PRONOUN_OPTIONS = [ // js~php diff + 'human' => ['true' => true, 'false' => true], + 'capitalize' => ['true' => true, 'false' => true], + ]; + + /** + * Valid options allowed in the fbt(...) calls. + */ + const VALID_FBT_OPTIONS = [ + 'project' => true, + 'author' => true, + 'preserveWhitespace' => true, + 'subject' => true, + 'common' => true, + 'doNotExtract' => true, + 'reporting' => true, // fbt diff + ]; + + const FBT_BOOLEAN_OPTIONS = [ + 'preserveWhitespace' => true, + 'doNotExtract' => true, + ]; + + const FBT_CALL_MUST_HAVE_AT_LEAST_ONE_OF_THESE_ATTRIBUTES = ['desc', 'common']; + + const FBT_REQUIRED_ATTRIBUTES = [ + 'desc' => true, + ]; + + const PRONOUN_REQUIRED_ATTRIBUTES = [ + 'type' => true, + 'gender' => true, + ]; + + const PLURAL_PARAM_TOKEN = 'number'; + + const REQUIRED_PARAM_OPTIONS = [ + 'name' => true, + ]; + + public static function validParamOptions(): array + { + return array_merge( + [ + 'number' => true, + 'gender' => true, + ], + self::REQUIRED_PARAM_OPTIONS + ); + } + + const FBT_TYPE = [ + 'TABLE' => 'table', + 'TEXT' => 'text', + ]; + + const MODULE_NAME = [ + 'FBT' => 'fbt', + // 'REACT_FBT' => 'Fbt', + 'FBS' => 'fbs', + ]; +} diff --git a/src/fbt/Transform/FbtTransform/FbtNodeChecker.php b/src/fbt/Transform/FbtTransform/FbtNodeChecker.php new file mode 100644 index 0000000..e7893d0 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtNodeChecker.php @@ -0,0 +1,121 @@ +moduleName = $moduleName; + } + + public static function fbtChecker(): self + { + return new FbtNodeChecker(FbtConstants::MODULE_NAME['FBT']); + } + + public static function fbsChecker(): self + { + return new FbtNodeChecker(FbtConstants::MODULE_NAME['FBS']); + } + + public function isNameOfModule(string $name): bool + { + return $this->moduleName === FbtConstants::MODULE_NAME['FBT'] + ? FbtNodeChecker::isFbtName($name) + : FbtNodeChecker::isFbsName($name); + } + + /** + * @param Node $node + * @return FbtNodeChecker|null + */ + public static function forFbt(Node $node) + { + if (self::fbtChecker()->isElement($node)) { + return self::fbtChecker(); + } elseif (self::fbsChecker()->isElement($node)) { + return self::fbsChecker(); + } + + return null; + } + + public function isElement(Node $node): bool + { + if (! $node->isElement()) { + return false; + } + + return $this->isNameOfModule($node->tag); + } + + public function isNamespacedElement(Node $node): bool + { + if (! $node->isElement()) { + return false; + } + + return $node->isNamespacedElement() && $this->isNameOfModule(explode(':', $node->tag)[0]); + } + + /** + * Ensure that, given an JSXElement, we don't have any nested element. + * And also checks that all "parameter" child elements follow the same namespace. + * E.g. + * Inside , don't allow . + * Inside , don't allow . + * + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + public function assertNoNestedFbts(Node $node) + { + $moduleName = $this->moduleName; + + foreach ($node->children as $child) { + if ($child->isElement() && + (self::fbtChecker()->isElement($child) || self::fbsChecker()->isElement($child))) { + $nestedJSXElementName = $child->tag; + $rootJSXElementName = $node->tag; + + throw FbtUtils::errorAt( + $child, + "Don't put <$nestedJSXElementName> directly within <$rootJSXElementName>. " . + "This is redundant. The text is already translated so you don't need " . + "to translate it again" + ); + } else { + $otherChecker = $moduleName === FbtConstants::MODULE_NAME['FBT'] + ? self::fbsChecker() + : self::fbtChecker(); + + if ($otherChecker->isNamespacedElement($child)) { + $jsxNamespacedName = $child->tag; + + throw FbtUtils::errorAt( + $child, + "Don't mix and HTML namespaces. " . + "Found a <$jsxNamespacedName> " . + "directly within a <$moduleName>" + ); + } + } + } + } + + public static function isFbtName(string $name): bool + { + return $name === FbtConstants::MODULE_NAME['FBT']; + } + + public static function isFbsName(string $name): bool + { + return $name === FbtConstants::MODULE_NAME['FBS']; + } +} diff --git a/src/fbt/Transform/FbtTransform/FbtTransform.php b/src/fbt/Transform/FbtTransform/FbtTransform.php new file mode 100644 index 0000000..ec0ac0b --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtTransform.php @@ -0,0 +1,131 @@ + FbtConfig::get('fbtCommon'), + 'fbtCommonPath' => FbtConfig::get('fbtCommonPath'), + ]); + + FbtHooks::onTerminating(); + } + + $dom = NodeParser::parse($html, false, true, DEFAULT_TARGET_CHARSET, false); + $dom->set_callback([self::class, '_fbtTraverse']); + + return $dom->save(); + } + + /** + * Transform to fbt() calls. + * @return void + */ + public static function _fbtTraverse(Node $node) + { + $root = HTMLFbtProcessor::create($node); + + if (! $root) { + return; + } + + if (! $node->getAttribute('project')) { + $node->setAttribute('project', self::$defaultOptions['project']); + } + + if (! $node->getAttribute('author')) { + $node->setAttribute('author', self::$defaultOptions['author']); + } + + $node->outertext = (string)$root->convertToFbtFunctionCallNode(); + } + + /** + * @throws \fbt\Exceptions\FbtParserException + * @throws \Exception + */ + private static function initDefaultOptions() + { + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $entrypoint = end($backtrace); + + if ($entrypoint['file'] && file_exists($entrypoint['file'])) { + $comments = array_filter( + token_get_all(file_get_contents($entrypoint['file'])), + function ($entry) { + return $entry[0] === T_DOC_COMMENT; + } + ); + + if ($comments) { + $comment = array_shift($comments); + preg_match('/@fbt ({.+?})/', $comment[1], $fbtDocblockOptions); + + if (isset($fbtDocblockOptions[1])) { + self::$defaultOptions = json_decode($fbtDocblockOptions[1], true); + foreach (self::$defaultOptions as $key => $value) { + FbtUtils::checkOption($key, FbtConstants::VALID_FBT_OPTIONS, $value); + } + } + } + } + + if (empty(self::$defaultOptions['project'])) { + self::$defaultOptions['project'] = FbtConfig::get('project'); + } + + if (empty(self::$defaultOptions['author'])) { + self::$defaultOptions['author'] = FbtConfig::get('author'); + } + } + + public static function addEnclosingString($childIdx, $parentIdx) + { + self::$childToParent[$childIdx] = $parentIdx; + } + + public static function toArray(): array + { + return [ + 'phrases' => array_reverse(self::$phrases, true), + 'childParentMappings' => self::$childToParent, + ]; + } +} diff --git a/src/fbt/Transform/FbtTransform/FbtUtils.php b/src/fbt/Transform/FbtTransform/FbtUtils.php new file mode 100644 index 0000000..6755385 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/FbtUtils.php @@ -0,0 +1,470 @@ + true, + 'implicitFbt' => true, + 'paramName' => true, + 'desc' => true, + ]; + + public static function normalizeSpaces(string $value, array $options = []): string + { + if (! empty($options['preserveWhitespace'])) { + return $value; + } + + return preg_replace("/\s+/m", " ", $value); + } + + /** + * Validates allowed children inside . + * Currently allowed: + * , + * , + * , + * And returns a name of a corresponding handler. + * If a child is not valid, it is flagged as an Implicit Parameter and is + * automatically wrapped with + * + * @param $node - The node that contains the name of any parent node. For + * example, for a JSXElement, the containing name is the openingElement's name. + */ + public static function validateNamespacedFbtElement($moduleName, Node $node): string + { + $valid = false; + $handlerName = null; + + // Actual namespaced version, e.g. + if ($node->isNamespacedElement()) { + list($namespace, $handlerName) = explode(":", $node->tag); + if ($namespace === $moduleName) { + $valid = + $handlerName === 'enum' || + $handlerName === 'param' || + $handlerName === 'plural' || + $handlerName === 'pronoun' || + $handlerName === 'name' || + $handlerName === 'same-param'; + } + } + + if (! $valid) { + $handlerName = 'implicitParamMarker'; + } + + if ($handlerName === 'same-param' || $handlerName === 'sameparam') { + $handlerName = 'sameParam'; + } + + return $handlerName; + } + + const SHORT_BOOL_CANDIDATES = [ + 'common' => 'common', + 'doNotExtract' => 'doNotExtract', + 'number' => 'number', + 'preserveWhitespace' => 'preserveWhitespace', + 'reporting' => 'reporting', // fbt diff + ]; + + private static function canBeShortBoolAttr($name): bool + { + return in_array($name, self::SHORT_BOOL_CANDIDATES); + } + + /** + * @return void + * @throws FbtParserException + */ + public static function setUniqueToken(Node $node, string $moduleName, string $name, array &$paramSet) + { + if (isset($paramSet[$name])) { + throw self::errorAt( + $node, + "There's already a token called \"$name\" in this $moduleName call. " . + "Use $moduleName.sameParam if you want to reuse the same token name or " . + "give this token a different name" + ); + } + + $paramSet[$name] = true; + } + + /** + * @param string $option + * @param array $validOptions + * @param string|bool|null $value + * + * @return string + * @throws FbtParserException + */ + public static function checkOption( + string $option, + array $validOptions, + $value + ): string { + $validOptions = array_merge($validOptions, self::FBT_CORE_ATTRIBUTES); + $validValues = $validOptions[$option] ?? null; + + if ($value === true) { // js~php diff + $value = 'true'; + } elseif ($value === false) { + $value = 'false'; + } + + if (! array_key_exists($option, $validOptions) || empty($validValues)) { + throw new FbtParserException( + "Invalid option \"$option\". " . + "Only allowed: " . implode(', ', array_keys($validOptions)) . " " + ); + } elseif ($validValues !== true) { + $valueStr = $value; + if (! isset($validValues[$valueStr])) { + throw new FbtParserException( + "Invalid value, \"$valueStr\" for \"$option\". " . + "Only allowed: " . implode(', ', array_keys($validValues)) + ); + } + } + + return $option; + } + + public static function checkOptions(array $options, array $validOptions): array + { + foreach ($options as $name => $value) { + self::checkOption($name, $validOptions, $value); + } + + return $options; + } + + public static function collectOptions($moduleName, $options, $validOptions): array + { + $key2value = []; + if ($options == null) { + return $key2value; + } + + $options = self::checkOptions($options, $validOptions); + + foreach ($options as $name => $value) { + // Append only default valid options excluding "extraOptions", + // which are used only by specific runtimes. + if (isset($validOptions[$name])) { + $key2value[$name] = $value; + } + } + + return $key2value; + } + + /** + * Build options list form corresponding attributes. + * + * @throws \fbt\Exceptions\FbtParserException + */ + public static function getOptionsFromAttributes(Node $attributesNode, array $validOptions = [], array $ignoredAttrs = []): array + { + $options = []; + + foreach ($attributesNode->getAllAttributes() as $name => $value) { + // Required attributes are passed as a separate argument in the fbt(...) + // call, because they're required. They're not passed as options. + // Ignored attributes are simply stripped from the function call entirely + // and ignored. By default, we ignore all "private" attributes with a + // leading '__' like '__source' and '__self' as added by certain + // babel/react plugins + if (isset($ignoredAttrs[$name]) || strpos($name, '__') === 0) { + continue; + } + + if (self::canBeShortBoolAttr($name) && $value === null) { + $value = true; + } elseif (in_array($value, ['true', 'false'])) { + $value = $value === 'true'; + } + + $options[self::checkOption($name, $validOptions, $value)] = $value; + } + + return $options; + } + + public static function errorAt(Node $node, string $msg): FbtParserException + { + $_node = clone $node; + $_node->getDOM()->remove_callback(); + + $errorMsg = "$msg\n---\n" . $_node->outertext() . "\n---"; + + return new FbtParserException($errorMsg); + } + + /** + * Check that the value of the given option name is a boolean literal + * and return its value + * + * @param array $options + * @param string $name + * @param Node|null $node + * + * @return bool + * @throws FbtParserException + */ + public static function getOptionBooleanValue(array $options, string $name, $node = null): bool + { + if (! isset($options[$name])) { + return false; + } + + $value = $options[$name]; + if (is_bool($value)) { + return $value; + } + + if ($node) { + throw self::errorAt( + $node, + "Value for option \"$name\" must be Boolean literal 'true' or 'false'." + ); + } + + throw new FbtParserException( + "Value for option \"$name\" must be Boolean literal 'true' or 'false'." + ); + } + + /** + * @param $moduleName + * @param $variationName + * @param $variationInfo + * @param Node $node + * + * @return int|null + * @throws FbtParserException + */ + public static function getVariationValue($moduleName, $variationName, $variationInfo, Node $node) + { + // Numbers allow only `true` or expression. + if ( + $variationName === 'number' && + is_bool($variationInfo) + ) { + if ($variationInfo !== true) { + throw self::errorAt( + $node, + "$moduleName.param's number option should be an expression or 'true'" + ); + } + // For number="true" we don't pass additional value. + return null; + } + + return $variationInfo; + } + + /** + * Utility for getting the first attribute by name from a list of attributes. + * + * @param Node $node + * @param string $name + * + * @return string|null + * @throws FbtParserException + */ + public static function getAttributeByNameOrThrow(Node $node, string $name) + { + if (! isset($node->{$name})) { + throw new FbtParserException("Unable to find attribute \"$name\"."); + } + + return $node->getAttribute($name); + } + + /** + * @param Node $node + * @param string $name + * + * @return string|null + */ + public static function getAttributeByName(Node $node, string $name) + { + return $node->{$name}; + } + + /** + * @param mixed $range + * @return array + * + * @throws FbtParserException + */ + public static function extractEnumRange($range): array + { + if (! is_string($range)) { + throw new FbtParserException("fbt enum range values must be string, got " . getType($range)); + } + + $rangeArg = json_decode(html_entity_decode(html_entity_decode($range))); + + $rangeProps = []; + if (is_array($rangeArg)) { + foreach ($rangeArg as $value) { + $rangeProps[$value] = $value; + } + } elseif (is_object($rangeArg)) { + $rangeProps = $rangeArg; + } else { + throw new \Exception("fbt enum range value must be array or object, got " . getType($rangeArg)); + } + + + return (array)$rangeProps; + } + + public static function objMap(array $object, callable $fn): array + { + $toMap = []; + + foreach ($object as $k => $value) { + $toMap[$k] = $fn($value, $k); + } + + return $toMap; + } + + /** + * Does this object have keys? + * + * Note: this breaks on any actual "class" object with prototype + * members + * + * The micro-optimized equivalent of `count(array_keys($o)) > 0` but + * without the throw-away array + */ + public static function hasKeys($o): bool + { + foreach ($o as $k => $v) { + return true; + } + + return false; + } + + /** + * Filter whitespace-only nodes from a list of nodes. + */ + public static function filterEmptyNodes(array $nodes): array + { + // js~php diff + + $firstKey = array_keys($nodes)[0] ?? null; + $lastKey = array_keys($nodes)[count($nodes) - 1] ?? null; + $filteredNodes = array_filter($nodes, function (Node $node, $key) use ($firstKey, $lastKey) { + if ($node->isText() && preg_match("/^\s+$/", $node->innertext())) { + $node->innertext = ( + $key === $firstKey || $key === $lastKey + ? '' + : ' ' + ); + + return $node->innertext; + } + + return ! $node->isComment(); + }, ARRAY_FILTER_USE_BOTH); + + return array_values($filteredNodes); + } + + /** + * Does the token substitution fbt() but without the string lookup. + * Used for in-place substitutions in translation mode. + * + * @throws \fbt\Exceptions\FbtException + */ + public static function substituteTokens($template, $_args) + { + $args = $_args; + + if (! $args) { + return $template; + } + + invariant( + is_array($args), + 'The 2nd argument must be an object (not a string) for tx(%s, ...)', + $template + ); + + // Splice in the arguments while keeping rich object ones separate. + $objectPieces = []; + $argNames = []; + $stringPieces = explode("\x17", preg_replace_callback("/{([^}]+)}/", function ($matches) use ($args, &$argNames, &$objectPieces) { + $match = $matches[0]; + $parameter = $matches[1]; + $punctuation = $matches[2] ?? ''; + + $argument = $args[$parameter] ?? null; + + if ($argument && is_array($argument)) { + $objectPieces[] = $argument; + $argNames[] = $parameter; // End of Transmission Block sentinel marker + + return '\x17' . $punctuation; + } elseif ($argument === null) { + return ''; + } + + return ( + $argument . (IntlPunctuation::endsInPunct($argument) ? '' : $punctuation) + ); + }, $template)); + + if (count($stringPieces) === 1) { + return $stringPieces[0]; + } + + // Zip together the lists of pieces. + $pieces = [$stringPieces[0]]; + + foreach ($objectPieces as $i => $piece) { + $pieces[] = $piece; + $pieces[] = $stringPieces[$i + 1]; + } + + return $pieces; + } + + // js~php diff: + + /** + * @param array|Node $nodes + * @return array + */ + public static function makeFbtElementArrayFromNode($nodes): array + { + $tree = []; + + if ($nodes instanceof Node) { + $nodes = [$nodes]; + } + + foreach ($nodes as $node) { + $children = self::makeFbtElementArrayFromNode($node->children()); + $tree[] = new fbtElement($node->tag, $node->innertext, $node->getAllAttributes(), $children); + } + + return $tree; + } +} diff --git a/src/fbt/Transform/FbtTransform/JSFbtBuilder.php b/src/fbt/Transform/FbtTransform/JSFbtBuilder.php new file mode 100644 index 0000000..eaeadcd --- /dev/null +++ b/src/fbt/Transform/FbtTransform/JSFbtBuilder.php @@ -0,0 +1,345 @@ + 'many', + IntlVariations::EXACTLY_ONE => 'singular', + ]; + + public function __construct(bool $reactNativeMode) + { + $this->usedEnums = []; + $this->usedPronouns = []; + $this->usedPlurals = []; + $this->reactNativeMode = $reactNativeMode; + } + + /** + * @param $type + * @param $texts + * @param bool $reactNativeMode + * @return array|string + * + * @throws FbtException + */ + public static function build($type, $texts, $reactNativeMode = false) + { + $builder = new JSFbtBuilder($reactNativeMode); + if ($type === FbtConstants::FBT_TYPE['TEXT']) { + invariant(count($texts) === 1, 'Text type is a singleton array'); + + return FbtUtils::normalizeSpaces($texts[0]); + } else { + invariant( + $type === FbtConstants::FBT_TYPE['TABLE'], + 'We only expect two types of fbt phrases' + ); + + return [ + 't' => $builder->buildTable($texts), + 'm' => $builder->buildMetadata($texts), + ]; + } + } + + public function buildMetadata($texts): array + { + $metadata = []; + $enums = []; + foreach ($texts as $item) { + if (is_string($item)) { + continue; + } + + switch ($item['type']) { + case 'gender': + case 'number': + $metadata[] = [ + 'token' => $item['token'], + 'type' => $item['type'] === 'number' + ? IntlVariations::INTL_FBT_VARIATION_TYPE['NUMBER'] + : IntlVariations::INTL_FBT_VARIATION_TYPE['GENDER'], + ]; + + break; + + case 'plural': + if ($item['showCount'] !== 'no') { + $metadata[] = [ + 'token' => $item['name'], + 'type' => IntlVariations::INTL_FBT_VARIATION_TYPE['NUMBER'], + 'singular' => true, + ]; + } else { + $metadata[] = $this->reactNativeMode + ? [ + 'type' => IntlVariations::INTL_FBT_VARIATION_TYPE['NUMBER'], + ] + : null; + } + + break; + + case 'subject': + $metadata[] = [ + 'token' => IntlVariations::SUBJECT, + 'type' => IntlVariations::INTL_FBT_VARIATION_TYPE['GENDER'], + ]; + + break; + + // We ensure we have placeholders in our metadata because enums and + // pronouns don't have metadata and will add "levels" to our resulting + // table. In the example in the docblock of buildTable(), we'd expect + // array({range: ...}, array('token' => 'count', 'type' => ...)) + case 'enum': + // Only add an enum if it adds a level. Duplicated enum values do not + // add levels. + if (! array_key_exists($item['value'], $enums)) { + $enums[$item['value']] = true; + $metadataEntry = null; + if ($this->reactNativeMode) { + // Enum range will later be used to extract enums from the payload + // for React Native + $metadataEntry = ['range' => array_keys($item['range'])]; + } + $metadata[] = $metadataEntry; + } + + break; + + case 'pronoun': + $metadata[] = $this->reactNativeMode + ? [ + 'type' => IntlVariations::INTL_FBT_VARIATION_TYPE['PRONOUN'], + ] + : null; + + break; + + default: + $metadata[] = null; + + break; + } + } + + return $metadata; + } + + /** + * Build a tree and set of all the strings - A (potentially multi-level) + * dictionary of keys of various FBT components (enum, plural, etc) to their + * string leaves or the next level of the tree. + * + * Example (probably a bad example of when to use an enum): + * + * buildTable([ + * 'Click ', + * { + * 'type': 'enum', + * 'values': ['here', 'there', 'anywhere'] + * }, + * ' to see ', + * { + * 'type': 'number', + * 'token': 'count', + * 'type': FbtVariationType::NUMBER, + * }, + * 'things' + * ]) + * + * Returns: + * + * { + * 'here': {'*': 'Click here to see {count} things'} + * 'there': {'*': 'Click there to see {count} things'} + * ... + * } + * + * @throws \fbt\Exceptions\FbtException + */ + public function buildTable($texts) + { + return $this->_buildTable('', $texts, 0); + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + private function _buildTable($prefix, $texts, $idx) + { + if ($idx === count($texts)) { + return FbtUtils::normalizeSpaces($prefix); + } + + $item = $texts[$idx]; + + if (is_string($item)) { + return $this->_buildTable($prefix . $item, $texts, $idx + 1); + } + + $textSegments = []; + + switch ($item['type']) { + case 'subject': + $textSegments['*'] = ''; + + break; + case 'gender': + case 'number': + $textSegments['*'] = '{' . $item['token'] . '}'; + + break; + + case 'plural': + $pluralCount = $item['count']; + + if (array_key_exists($pluralCount, $this->usedPlurals)) { + // Constrain our plural value ('many'/'singular') BUT still add a + // single level. We don't currently prune runtime args like we do + // with enums, but we ought to... + $key = $this->usedPlurals[$pluralCount]; + $val = $item[self::PLURAL_KEY_TO_TYPE[$key]]; + + return [ + $key => $this->_buildTable($prefix . $val, $texts, $idx + 1), + ]; + } + + $table = FbtUtils::objMap(self::PLURAL_KEY_TO_TYPE, function ($type, $key) use ($pluralCount, $prefix, $item, $texts, $idx) { + $this->usedPlurals[$pluralCount] = $key; + + return $this->_buildTable($prefix . $item[$type], $texts, $idx + 1); + }); + + unset($this->usedPlurals[$pluralCount]); + + return $table; + + case 'pronoun': + $genderSrc = $item['gender']; + $isUsed = in_array($genderSrc, $this->usedPronouns); + $genders = $isUsed ? $this->usedPronouns[$genderSrc] : Gender::GENDER_CONST; + $resTable = []; + foreach (array_keys($genders) as $key) { + $gender = Gender::GENDER_CONST[$key]; + + if ($gender === Gender::GENDER_CONST['NOT_A_PERSON'] && ! empty($item['human'])) { + return; + } + + if (! $isUsed) { + $this->usedPronouns[$genderSrc] = [ + $key => $gender, + ]; + } + + $genderKey = self::getPronounGenderKey($item['usage'], $gender); + $pivotKey = $genderKey === Gender::GENDER_CONST['UNKNOWN_PLURAL'] ? '*' : $genderKey; + $word = Gender::getData($genderKey, $item['usage']); + $capWord = ! empty($item['capitalize']) ? mb_strtoupper($word[0]) . mb_substr($word, 1) : $word; + $resTable[$pivotKey] = $this->_buildTable($prefix . $capWord, $texts, $idx + 1); + } + + if (! $isUsed) { + unset($this->usedPronouns['genderSrc']); + } + + // js~php diff + // @see https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order + uksort($resTable, function ($a, $b) { + return is_int($b) - is_int($a) ?: strnatcmp($a, $b); + }); + + return $resTable; + + case 'enum': + // If this is a duplicate enum, use the stored value. Otherwise, + // create a level in our table. + $enumArg = $item['value']; + if (array_key_exists($enumArg, $this->usedEnums)) { + $enumKey = $this->usedEnums[$enumArg]; + + if (! array_key_exists($enumKey, $item['range'])) { + throw new FbtException($enumKey . ' not found in ' . json_encode($item['range']) . '. Attempting to re-use incompatible enums'); + } + + $val = $item['range'][$enumKey]; + + return $this->_buildTable($prefix . $val, $texts, $idx + 1); + } + + $result = FbtUtils::objMap($item['range'], function ($val, $key) use ($enumArg, $prefix, $texts, $idx) { + $this->usedEnums[$enumArg] = $key; + + return $this->_buildTable($prefix . $val, $texts, $idx + 1); + }); + + unset($this->usedEnums[$enumArg]); + + return $result; + default: + break; + } + + return FbtUtils::objMap($textSegments, function ($v) use ($prefix, $texts, $idx) { + return $this->_buildTable($prefix . $v, $texts, $idx + 1); + }); + } + + /** + * @param string $usage + * @param int $gender + * + * @return int|null + * @throws FbtException + */ + public static function getPronounGenderKey(string $usage, int $gender) + { + switch ($gender) { + case Gender::GENDER_CONST['NOT_A_PERSON']: + return $usage === 'object' || $usage === 'reflexive' ? Gender::GENDER_CONST['NOT_A_PERSON'] : Gender::GENDER_CONST['UNKNOWN_PLURAL']; + + case Gender::GENDER_CONST['FEMALE_SINGULAR']: + case Gender::GENDER_CONST['FEMALE_SINGULAR_GUESS']: + return Gender::GENDER_CONST['FEMALE_SINGULAR']; + + case Gender::GENDER_CONST['MALE_SINGULAR']: + case Gender::GENDER_CONST['MALE_SINGULAR_GUESS']: + return Gender::GENDER_CONST['MALE_SINGULAR']; + + case Gender::GENDER_CONST['MIXED_SINGULAR']: // And MIXED_PLURAL; they have the same integer values. + case Gender::GENDER_CONST['FEMALE_PLURAL']: + case Gender::GENDER_CONST['MALE_PLURAL']: + case Gender::GENDER_CONST['NEUTER_PLURAL']: + case Gender::GENDER_CONST['UNKNOWN_PLURAL']: + return Gender::GENDER_CONST['UNKNOWN_PLURAL']; + + case Gender::GENDER_CONST['NEUTER_SINGULAR']: + case Gender::GENDER_CONST['UNKNOWN_SINGULAR']: + return $usage === 'reflexive' ? Gender::GENDER_CONST['NOT_A_PERSON'] : Gender::GENDER_CONST['UNKNOWN_PLURAL']; + } + + invariant(false, 'Unknown GENDER_CONST value.'); + + return null; + } +} diff --git a/src/fbt/Transform/FbtTransform/Processors/FbtFunctionCallProcessor.php b/src/fbt/Transform/FbtTransform/Processors/FbtFunctionCallProcessor.php new file mode 100644 index 0000000..b237bdc --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Processors/FbtFunctionCallProcessor.php @@ -0,0 +1,425 @@ + 0, + 'gender' => 1, + ]; + + /** + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + protected function _getOptions(array $options) + { + $this->options = $options; + + foreach (FbtConstants::FBT_BOOLEAN_OPTIONS as $key) { + if (isset($this->options[$key])) { + $this->options[$key] = FbtUtils::getOptionBooleanValue($this->options, $key); + } + } + } + + public static function callFbt($name, $args) + { + return call_user_func_array([fbt::class, '_' . $name], $args); + } + + /** + * @param fbtNode[] $texts + * + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + protected function traverse(array $texts) + { + $moduleName = $this->moduleName; + + foreach ($texts as $construct) { + $node = $construct->node; + $constructName = $construct->name; + $args = $construct->args; + @list($arg0, $arg1, $arg2) = $args; + + if ($constructName === 'param' || $constructName === 'sameParam') { + // Collect params only if it's original one (not "sameParam"). + // Variation case. Replace: + // ['number' => true] -> ['type' => "number", 'token' => ] + // ['gender' => ] -> ['type' => "gender", 'token' => ] + if (count($construct->args) === 3) { + $paramName = $arg0; + // TODO(T69419475): detect variation type by property name instead + // of expecting it to be the first object property + $key = array_keys($arg2)[0]; + $variationInfo = $arg2[$key]; + $variationName = $key ?? $arg2[$key]; + $this->variations[$paramName] = [ + 'type' => $variationName, + 'token' => $paramName, + ]; + $variationValues = [self::VARIATION[$variationName]]; + $variationValue = FbtUtils::getVariationValue( + $this->moduleName, + $variationName, + $variationInfo, + $node + ); + if ($variationValue) { + $variationValues[] = $variationValue; + } + + $args[2] = $variationValues; + } + + if ($constructName === 'param') { + $this->runtimeArgs[] = self::callFbt('param', $args); + + FbtUtils::setUniqueToken($node, $this->moduleName, $arg0, $this->paramSet); + } + + if (count($construct->args) === 3) { + continue; + } + + $construct->value = '{' . $arg0 . '}'; + } elseif ($constructName === 'enum') { + $this->hasTable = true; // `enum` is a reserved word, so it should be quoted. + + $rawValue = $arg0; + $usedVal = $this->usedEnums[$rawValue] ?? null; + + if (! $usedVal) { + $this->usedEnums[$rawValue] = true; + $this->runtimeArgs[] = self::callFbt('enum', $construct->args); + } + } elseif ($constructName === 'plural') { + $this->hasTable = true; + $count = $arg1; + $options = FbtUtils::collectOptions($this->moduleName, $arg2, FbtConstants::validPluralOptions()); + $pluralArgs = [$count]; + + if (! empty($options['showCount']) && $options['showCount'] !== 'no') { + $name = $options['name'] ?? FbtConstants::PLURAL_PARAM_TOKEN; + FbtUtils::setUniqueToken($node, $this->moduleName, $name, $this->paramSet); + $pluralArgs[] = $name; + + if (! empty($options['value'])) { + $pluralArgs[] = $options['value']; + } + } + + $this->runtimeArgs[] = self::callFbt('plural', $pluralArgs); + } elseif ($constructName === 'pronoun') { + // Usage: fbt::pronoun(usage, gender [, options]) + // - enum string usage + // e.g. 'object', 'possessive', 'reflexive', 'subject' + // - enum int gender + // e.g. Gender::GENDER_CONST['MALE_SINGULAR'], FEMALE_SINGULAR, etc. + + $this->hasTable = true; + + if (count($args) < 2 || 3 < count($args)) { + throw FbtUtils::errorAt($node, "Expected '(usage, gender [, options])' arguments to $moduleName.pronoun"); + } + + $usageExpr = $arg0; + + $validPronounUsages = FbtConstants::VALID_PRONOUN_USAGES; + if (! isset($validPronounUsages[$usageExpr])) { + throw FbtUtils::errorAt($node, "First argument to " . $this->moduleName . ":pronoun must be one of [" . implode(', ', array_keys($validPronounUsages)) . '], got ' . $usageExpr); + } + + $numericUsageExpr = FbtConstants::VALID_PRONOUN_USAGES[$usageExpr]; + $genderExpr = $arg1; + $pronounArgs = [$numericUsageExpr, $genderExpr]; + $optionsExpr = $arg2; + $options = FbtUtils::collectOptions($this->moduleName, $optionsExpr, FbtConstants::VALID_PRONOUN_OPTIONS); + + if (FbtUtils::getOptionBooleanValue($options, 'human', $node)) { + $pronounArgs[] = [ + 'human' => 1, + ]; + } + + $this->runtimeArgs[] = self::callFbt('pronoun', $pronounArgs); + } elseif ($constructName === 'name') { + if (count($args) < 3) { + throw FbtUtils::errorAt($node, "Missing arguments. Must have three arguments: label, value, gender"); + } + + $paramName = $arg0; + $this->variations[$paramName] = [ + 'type' => 'gender', + 'token' => $paramName, + ]; + $this->runtimeArgs[] = self::callFbt('name', $args); + } else { + throw FbtUtils::errorAt($node, "Unknown $moduleName method $constructName"); + } + } + } + + /** + * @return void + * @throws \fbt\Exceptions\FbtParserException + */ + protected function _collectFbtCalls() + { + if (! empty($this->options['subject'])) { + $this->hasTable = true; + } + + if (is_array($this->text)) { + $this->traverse(array_filter($this->text, function ($text) { + return ($text instanceof fbtNode); + })); + } + + if (! empty($this->options['subject'])) { + array_unshift($this->runtimeArgs, self::callFbt('subject', [$this->options['subject']])); + } + } + + protected function _isTableNeeded(): bool + { + return count($this->variations) > 0 || $this->hasTable; + } + + protected function _convertToStringArrayNodeIfNeeded($textNode): array + { + if (is_string($textNode)) { + return [$textNode]; + } + + return $textNode; + } + + /** + * Extracts texts that contains variations or enums, concatenating + * literal parts. + * Example: + * + * [ + * 'Hello, ', fbt::param('user', user, ['gender' => 'male']), '! ', + * 'Your score is ', fbt::param('score', $score), '!', + * ] + * => + * ["Hello, ", ['type' => 'gender', 'token' => 'user'], "! Your score is {score}!"] + * + * @throws \fbt\Exceptions\FbtParserException + */ + private function _extractTableTextsFromStringArray($node, array $variations): array + { + return array_reduce($node, function ($results, $element) use ($variations) { + return array_merge($results, $this->_extractTableTextsFromStringArrayItem($element, $variations)); + }, []); + } + + /** + * Extracts texts from each fbt text array item: + * + * "Hello, " . fbt::param('user', $user, ['gender' => 'male']) . "! " . + * "Your score is " . fbt::param('score', $score) . "!" + * => + * ["Hello, ", ['type' => 'gender', 'token' => 'user'], "! Your score is {score}!"] + * + * @throws \fbt\Exceptions\FbtParserException + */ + private function _extractTableTextsFromStringArrayItem($node, array $variations, array $texts = []): array + { + if (is_string($node)) { + // If we already collected a literal part previously, and + // current part is a literal as well, just concatenate them. + $previousText = $texts[count($texts) - 1] ?? null; + + if (is_string($previousText)) { + $texts[count($texts) - 1] = FbtUtils::normalizeSpaces($previousText . $node); + } else { + $texts[] = $node; + } + + return $texts; + } elseif ($node instanceof fbtNode) { + $args = $node->args; + @list($arg0, $arg1, $arg2) = $args; + + switch ($node->name) { + case 'param': + $texts[] = $variations[$arg0] ?? '{' . $arg0 . '}'; + + break; + case 'enum': + $texts[] = [ + 'type' => 'enum', + 'range' => $args[1], + 'value' => $args[0], + ]; + + break; + case 'plural': + $singular = $arg0; + $opts = FbtUtils::collectOptions($this->moduleName, $arg2, FbtConstants::validPluralOptions()); + $defaultToken = isset($opts['showCount']) && $opts['showCount'] !== 'no' ? FbtConstants::PLURAL_PARAM_TOKEN : null; + + if (! empty($opts['showCount']) && $opts['showCount'] === 'ifMany' && empty($opts['many'])) { + throw new FbtParserException( + "The 'many' attribute must be set explicitly if showing count only " + . "on 'ifMany', since the singular form presumably starts with an article" + ); + } + + $data = array_merge($opts, [ + 'type' => 'plural', + // Set default value if `opts[optionName]` isn't defined + 'showCount' => $opts['showCount'] ?? 'no', + 'name' => $opts['name'] ?? $defaultToken, + 'singular' => $singular, + 'count' => $arg1, + 'many' => $opts['many'] ?? $singular . 's', + ]); + + if (! empty($opts['showCount']) && $opts['showCount'] !== 'no') { + if ($opts['showCount'] === 'yes') { + $data['singular'] = '1 ' . $data['singular']; + } + + $data['many'] = '{' . $data['name'] . '} ' . $data['many']; + } + + $texts[] = $data; + + break; + case 'pronoun': + // Usage: fbt::pronoun(usage, gender [, options]) + $options = []; + + foreach (array_keys($options) as $key) { + $options[$key] = FbtUtils::getOptionBooleanValue($options, $key, $node->node); + } + + $pronounData = array_merge($options, [ + 'type' => 'pronoun', + 'usage' => $arg0, + 'gender' => $arg1, + ]); + + $texts[] = $pronounData; + + break; + case 'name': + $texts[] = $variations[$arg0]; + + break; + } + } + + return $texts; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + protected function _getTexts(array $variations, bool $isTable): array + { + $options = $this->options; + + $arrayTextNode = $this->_convertToStringArrayNodeIfNeeded($this->text); + + if ($isTable) { + $texts = $this->_normalizeTableTexts($this->_extractTableTextsFromStringArray($arrayTextNode, $variations)); + } else { + $unnormalizedText = implode('', $arrayTextNode); + $texts = [trim(FbtUtils::normalizeSpaces($unnormalizedText, $options))]; + } + + if (isset($options['subject'])) { + array_unshift($texts, [ + 'type' => 'subject', + ]); + } + + return $texts; + } + + /** + * Normalizes first and last elements in the + * table texts by triming them left and right accordingly. + * [" Hello, ", {enum}, " world! "] -> ["Hello, ", {enum}, " world!"] + */ + protected function _normalizeTableTexts(array $texts): array + { + $firstText = $texts[0]; + + if (is_string($firstText)) { + $texts[0] = ltrim($firstText); + } + + $lastText = $texts[count($texts) - 1] ?? null; + + if (is_string($lastText)) { + $texts[count($texts) - 1] = rtrim($lastText); + } + + return $texts; + } + + protected function _getDescription(): string + { + return trim(FbtUtils::normalizeSpaces($this->desc, $this->options)); + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + protected function _getPhrase(array $texts, string $desc, bool $isTable): array + { + $phraseType = $isTable ? FbtConstants::FBT_TYPE['TABLE'] : FbtConstants::FBT_TYPE['TEXT']; + $jsfbt = JSFbtBuilder::build($phraseType, $texts); + + return array_merge( + [ + 'desc' => $desc, + ], + // Merge with fbt callsite options + $this->defaultFbtOptions, + $this->options, + [ + 'type' => $phraseType, + 'jsfbt' => $jsfbt, + ] + ); + } +} diff --git a/src/fbt/Transform/FbtTransform/Processors/HTMLFbtProcessor.php b/src/fbt/Transform/FbtTransform/Processors/HTMLFbtProcessor.php new file mode 100644 index 0000000..0e3df4c --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Processors/HTMLFbtProcessor.php @@ -0,0 +1,307 @@ +moduleName = $nodeChecker->moduleName; + $this->node = $node; + $this->nodeChecker = $nodeChecker; + } + + /** + * @param Node $node + * @return HTMLFbtProcessor|null + */ + public static function create(Node $node) + { + $nodeChecker = FbtNodeChecker::forFbt($node); + + return $nodeChecker !== null ? + new HTMLFbtProcessor($nodeChecker, $node) : null; + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + private function _getText($childNodes) + { + return count($childNodes) > 1 + ? $this->_createConcatFromExpressions($childNodes) + : $childNodes[0]; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + private function _getDescription($text): string + { + $moduleName = $this->moduleName; + $node = $this->node; + + $commonAttributeValue = $this->_getCommonAttributeValue(); + + if ($commonAttributeValue) { + $textValue = FbtUtils::normalizeSpaces(trim($text)); + $descValue = FbtCommon::getDesc($textValue); + + if (empty($descValue)) { + throw FbtUtils::errorAt($node, FbtCommon::getUnknownCommonStringErrorMessage($moduleName, $textValue)); + } + + if (FbtUtils::getAttributeByName($this->node, 'desc')) { + throw FbtUtils::errorAt($node, '<' . $moduleName . ' common="true"> must not have "desc" attribute'); + } + + $desc = $descValue; + } else { + $desc = $this->_getDescAttributeValue(); + } + + return $desc; + } + + /** + * @return null|array + * @throws \fbt\Exceptions\FbtParserException + */ + private function _getOptions() + { + // Optional attributes to be passed as options. + + $this->_assertHasMandatoryAttributes(); + + return $this->node->getAllAttributes() // js~php diff + ? FbtUtils::getOptionsFromAttributes($this->node, FbtConstants::VALID_FBT_OPTIONS, FbtConstants::FBT_REQUIRED_ATTRIBUTES) + : null; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + private function _assertHasMandatoryAttributes() + { + if (! count(array_intersect(array_keys($this->node->getAllAttributes()), FbtConstants::FBT_CALL_MUST_HAVE_AT_LEAST_ONE_OF_THESE_ATTRIBUTES))) { + throw FbtUtils::errorAt($this->node, "<$this->moduleName> must have at least one of these attributes: " . implode(', ', FbtConstants::FBT_CALL_MUST_HAVE_AT_LEAST_ONE_OF_THESE_ATTRIBUTES)); + } + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + private function _createFbtFunctionCallNode($text, $desc, $options): fbtNamespace + { + invariant($text, 'text cannot be null'); + invariant($desc, 'desc cannot be null'); + + $args = [$text, $desc]; + + if ($options != null) { + $args[] = $options; + } + + return fbt(...$args); + } + + /** + * @return void + * @throws FbtParserException + */ + private function _assertNoNestedFbts() + { + $this->nodeChecker->assertNoNestedFbts($this->node); + } + + private function _isImplicitFbt(): bool + { + return $this->node->getAttribute('implicitFbt') === 'true'; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + private function _addImplicitDescriptionsToChildrenRecursively(): self + { + FbtAutoWrap::createImplicitDescriptions($this->moduleName, $this->node); + + return $this; + } + + /** + * Given a node, and its index location in phrases, any children of the given + * node that are implicit are given their parent's location. This can then + * be used to link the inner strings with their enclosing string. + */ + private function _setPhraseIndexOnImplicitChildren($phraseIndex): self + { + $children = $this->node->children(); + + if (! $children) { + return $this; + } + + foreach ($children as $child) { + if ($child->implicitDesc != null && $child->implicitDesc !== '') { + $child->parentIndex = $phraseIndex; + $child->setAttribute('parentIndex', $phraseIndex); + } + } + + return $this; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + private function _transformChildrenToFbtCalls(array $nodes): array + { + return array_map(function ($node) { + return $this->_transformNamespacedFbtElement($node); + }, FbtUtils::filterEmptyNodes($nodes)); + } + + /** + * Transform a namespaced fbt JSXElement into a + * method call. E.g. `` or to `fbt::param()` + * @throws \fbt\Exceptions\FbtParserException + */ + private function _transformNamespacedFbtElement(Node $node) + { + switch ($node->nodetype) { + case HDOM_TYPE_ELEMENT: + return $this->_toFbtNamespacedCall($node); + case HDOM_TYPE_TEXT: + return FbtUtils::normalizeSpaces($node->innertext); + default: + throw FbtUtils::errorAt($node, "Unknown namespace fbt type $node->type"); + } + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + // WARNING: this method has side-effects because it alters the given `node` object + // You shouldn't try to run this multiple times on the same `node`. + private function _toFbtNamespacedCall(Node $node) + { + $moduleName = $this->moduleName; + $name = FbtUtils::validateNamespacedFbtElement($moduleName, $node); + $getNamespacedArgs = new GetNamespacedArgs($moduleName); + $args = [ + $node, + $getNamespacedArgs->{$name}($node), + ]; + + if ($name === 'implicitParamMarker') { + $name = 'param'; + + // js~php diff: + $content = (string)new fbtElement($node->tag, fbt($this->_transformChildrenToFbtCalls($node->nodes), $node->getAttribute('desc'), [ + 'implicitFbt' => true, + 'subject' => $this->node->getAttribute('subject') ?: null, + 'project' => $this->node->getAttribute('project'), + 'author' => $this->node->getAttribute('author'), + ]), $node->getAllAttributes()); + $args[1][1] = $content; + $args[2] = $content; + } + + return call_user_func_array([fbtNamespace::class, $name], $args); + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + private function _createConcatFromExpressions(array $nodes): array + { + invariant($nodes, 'Cannot create an expression without nodes.'); + + // js~php diff + return $nodes; + } + + /** + * @throws \fbt\Exceptions\FbtParserException + */ + private function _getDescAttributeValue(): string + { + $node = $this->node; + $descAttr = FbtUtils::getAttributeByNameOrThrow($node, 'desc'); + + if (! $descAttr) { + throw FbtUtils::errorAt($node, "<$this->moduleName> requires a \"desc\" attribute"); + } + + + return $descAttr; + } + + /** + * @return null|bool + * @throws \fbt\Exceptions\FbtParserException + */ + private function _getCommonAttributeValue() + { + $commonAttr = FbtUtils::getAttributeByName($this->node, 'common'); + + if (! $commonAttr) { + return null; + } + + if ($commonAttr === 'true' || $commonAttr === 'false') { + return $commonAttr === 'true'; + } + + throw new FbtParserException("`common` attribute for <$this->moduleName> requires boolean literal"); + } + + /** + * @throws \fbt\Exceptions\FbtParserException|\fbt\Exceptions\FbtException + */ + public function convertToFbtFunctionCallNode(): fbtNamespace + { + $this->_assertNoNestedFbts(); + + if (! $this->_isImplicitFbt()) { + $this->_addImplicitDescriptionsToChildrenRecursively(); + } + + // js~php diff: + + // $this->_setPhraseIndexOnImplicitChildren($phraseIndex); + + $children = $this->_transformChildrenToFbtCalls($this->node->nodes); + + $text = $this->_getText($children); + + $description = $this->_getDescription($text); + + return $this->_createFbtFunctionCallNode( + $text, + $description, + $this->_getOptions() + ); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType01.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType01.php new file mode 100644 index 0000000..a26bb88 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType01.php @@ -0,0 +1,30 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType03.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType03.php new file mode 100644 index 0000000..07a1b1b --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType03.php @@ -0,0 +1,40 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType04.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType04.php new file mode 100644 index 0000000..338b103 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType04.php @@ -0,0 +1,40 @@ + "a number like 0, 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n >= 0 && $n <= 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType05.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType05.php new file mode 100644 index 0000000..61a8215 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType05.php @@ -0,0 +1,40 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "not 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType06.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType06.php new file mode 100644 index 0000000..8b5d884 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType06.php @@ -0,0 +1,40 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType07.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType07.php new file mode 100644 index 0000000..253a63d --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType07.php @@ -0,0 +1,40 @@ + "a number like 0, 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n >= 0 && $n <= 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType08.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType08.php new file mode 100644 index 0000000..fd2e185 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType08.php @@ -0,0 +1,40 @@ + "a number like 0, 1, or between 11 and 99.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "between 2 and 10 or greater than 99.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n >= 0 && $n <= 1 || $n >= 11 && $n <= 99) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType09.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType09.php new file mode 100644 index 0000000..7137592 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType09.php @@ -0,0 +1,40 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "not 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType10.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType10.php new file mode 100644 index 0000000..54cb91d --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType10.php @@ -0,0 +1,40 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 2~16, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType11.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType11.php new file mode 100644 index 0000000..6066cdd --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType11.php @@ -0,0 +1,40 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 2~16, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType12.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType12.php new file mode 100644 index 0000000..f8ea52e --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType12.php @@ -0,0 +1,40 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 2~16, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType13.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType13.php new file mode 100644 index 0000000..e44e198 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType13.php @@ -0,0 +1,40 @@ + "a number like 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if (($n === 1 || $n === 2 || $n === 3) || ($n % 10 !== 4 && $n % 10 !== 6 && $n % 10 !== 9)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType14.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType14.php new file mode 100644 index 0000000..296db0f --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType14.php @@ -0,0 +1,46 @@ + "a number like 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, …", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 2~9, 22~29, 102, 1002, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 0 || $n % 100 >= 11 && $n % 100 <= 19) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType15.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType15.php new file mode 100644 index 0000000..17f5971 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType15.php @@ -0,0 +1,46 @@ + "0.", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType16.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType16.php new file mode 100644 index 0000000..e273f58 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType16.php @@ -0,0 +1,46 @@ + "0.", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType17.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType17.php new file mode 100644 index 0000000..0ef5e44 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType17.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 3~17, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType18.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType18.php new file mode 100644 index 0000000..da953fe --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType18.php @@ -0,0 +1,46 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "between 2 and 10.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 10.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n >= 2 && $n <= 10) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType19.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType19.php new file mode 100644 index 0000000..c9e524e --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType19.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 0, 2~19, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 20~35, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 0 || $n % 100 >= 1 && $n % 100 <= 19) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType20.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType20.php new file mode 100644 index 0000000..6bd841e --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType20.php @@ -0,0 +1,46 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType21.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType21.php new file mode 100644 index 0000000..f0f0a02 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType21.php @@ -0,0 +1,52 @@ + "1 or 11.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2 or 12.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "between 3 and 10, or between 13 and 19.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 19.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if (($n === 1 || $n === 11)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if (($n === 2 || $n === 12)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if (($n >= 3 && $n <= 10 || $n >= 13 && $n <= 19)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType22.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType22.php new file mode 100644 index 0000000..9560c37 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType22.php @@ -0,0 +1,52 @@ + "a number like 1, 101, 201, 301, 401, 501, 601, 701, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "a number like 2, 102, 202, 302, 402, 502, 602, 702, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 100 === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 100 === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if ($n % 100 >= 3 && $n % 100 <= 4) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType23.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType23.php new file mode 100644 index 0000000..cc3fed2 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType23.php @@ -0,0 +1,52 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 3~4, 103~104, 203~204, 1003~1004, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 100 === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 100 === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if ($n % 100 >= 3 && $n % 100 <= 4) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType24.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType24.php new file mode 100644 index 0000000..d827f29 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType24.php @@ -0,0 +1,52 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 20, 30, 40, 50, 60, 70, 80, 90, 100, 1000, 10000, 100000, 1000000, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 3~17, 101, 1001, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 100 === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 100 === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if (($n < 0 || $n > 10) && $n % 10 === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType25.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType25.php new file mode 100644 index 0000000..4fcf6d4 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType25.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "between 2 and 4.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "0 or greater than 4.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n >= 2 && $n <= 4) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType26.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType26.php new file mode 100644 index 0000000..5c89a83 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType26.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType27.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType27.php new file mode 100644 index 0000000..b28e283 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType27.php @@ -0,0 +1,46 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType28.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType28.php new file mode 100644 index 0000000..35ea0ab --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType28.php @@ -0,0 +1,46 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~9, 22~29, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && ($n % 100 < 11 || $n % 100 > 19)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 9 && ($n % 100 < 11 || $n % 100 > 19)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType29.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType29.php new file mode 100644 index 0000000..a398d7c --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType29.php @@ -0,0 +1,52 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 0, 2~10, 102~107, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 11~19, 111~117, 1011, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 20~35, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 0 || $n % 100 >= 2 && $n % 100 <= 10) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + if ($n % 100 >= 11 && $n % 100 <= 19) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType30.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType30.php new file mode 100644 index 0000000..49d9306 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType30.php @@ -0,0 +1,46 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType31.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType31.php new file mode 100644 index 0000000..ef5bd04 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType31.php @@ -0,0 +1,58 @@ + "a number like 1, 21, 31, 41, 51, 61, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "a number like 2, 22, 32, 42, 52, 62, 82, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 1000000, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 5~8, 10~20, 100, 1000, 10000, 100000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && ($n % 100 !== 11 && $n % 100 !== 71 && $n % 100 !== 91)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 === 2 && ($n % 100 !== 12 && $n % 100 !== 72 && $n % 100 !== 92)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if (($n % 10 >= 3 && $n % 10 <= 4 || $n % 10 === 9) && (($n % 100 < 10 || $n % 100 > 19) && ($n % 100 < 70 || $n % 100 > 79) && ($n % 100 < 90 || $n % 100 > 99))) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + if ($n !== 0 && $n % 1000000 === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType32.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType32.php new file mode 100644 index 0000000..7d8055a --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType32.php @@ -0,0 +1,58 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "between 3 and 6.", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "between 7 and 10.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 11~25, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if ($n >= 3 && $n <= 6) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + if ($n >= 7 && $n <= 10) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType33.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType33.php new file mode 100644 index 0000000..7927acf --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType33.php @@ -0,0 +1,52 @@ + "a number like 1, 11, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "a number like 2, 12, 22, 32, 42, 52, 62, 72, 82, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 1000, 1020, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 3~10, 103, 1003, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if (($n % 100 === 0 || $n % 100 === 20 || $n % 100 === 40 || $n % 100 === 60 || $n % 100 === 80)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType34.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType34.php new file mode 100644 index 0000000..454ff45 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType34.php @@ -0,0 +1,64 @@ + "0.", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 3~10, 103~110, 1003, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 11~26, 111, 1011, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if ($n % 100 >= 3 && $n % 100 <= 10) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + if ($n % 100 >= 11 && $n % 100 <= 99) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType35.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType35.php new file mode 100644 index 0000000..3afa410 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType35.php @@ -0,0 +1,64 @@ + "0.", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "3.", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "6.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + if ($n === 3) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + if ($n === 6) { + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType36.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType36.php new file mode 100644 index 0000000..d0e5781 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType36.php @@ -0,0 +1,40 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType37.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType37.php new file mode 100644 index 0000000..d8e3d37 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType37.php @@ -0,0 +1,40 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "not 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType38.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType38.php new file mode 100644 index 0000000..eb745d0 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType38.php @@ -0,0 +1,40 @@ + "0 or 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 0 || $n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType39.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType39.php new file mode 100644 index 0000000..1f34ac0 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType39.php @@ -0,0 +1,30 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "0 or greater than 2.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType41.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType41.php new file mode 100644 index 0000000..20c2cad --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType41.php @@ -0,0 +1,40 @@ + "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 0, 2~16, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType42.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType42.php new file mode 100644 index 0000000..a35dfdb --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType42.php @@ -0,0 +1,30 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['MANY'] => "a number like 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 12 || $n % 100 > 14)) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['MANY']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType44.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType44.php new file mode 100644 index 0000000..8dd451f --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType44.php @@ -0,0 +1,40 @@ + "a number like 0, 1.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "greater than 1.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n >= 0 && $n <= 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType45.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType45.php new file mode 100644 index 0000000..3dbbeef --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType45.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['TWO'] => "2.", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "0 or greater than 2.", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 2) { + return IntlVariations::INTL_NUMBER_VARIATIONS['TWO']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType46.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType46.php new file mode 100644 index 0000000..6bbd79e --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType46.php @@ -0,0 +1,46 @@ + "1.", + IntlVariations::INTL_NUMBER_VARIATIONS['FEW'] => "a number like 0, 2~19, 102, 1002, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 20~35, 100, 1000, 10000, 100000, 1000000, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n === 1) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + if ($n === 0 || $n % 100 >= 1 && $n % 100 <= 19) { + return IntlVariations::INTL_NUMBER_VARIATIONS['FEW']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType47.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType47.php new file mode 100644 index 0000000..d2ea7f3 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlCLDRNumberType47.php @@ -0,0 +1,46 @@ + "a number like 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, …", + IntlVariations::INTL_NUMBER_VARIATIONS['ONE'] => "a number like 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + IntlVariations::INTL_NUMBER_VARIATIONS['OTHER'] => "a number like 2~9, 22~29, 102, 1002, …", + ]; + + return $examples[$variation] ?? null; + } + + public function getVariation($n): int + { + if ($n % 10 === 0 || $n % 100 >= 11 && $n % 100 <= 19) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ZERO']; + } + + if ($n % 10 === 1 && $n % 100 !== 11) { + return IntlVariations::INTL_NUMBER_VARIATIONS['ONE']; + } + + return IntlVariations::INTL_NUMBER_VARIATIONS['OTHER']; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlNumberConsistency.php b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlNumberConsistency.php new file mode 100644 index 0000000..4f8b46b --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/CLDR/IntlNumberConsistency.php @@ -0,0 +1,14 @@ + "ceb", + "ck_US" => "chr", + "fb_AA" => "en", + "fb_AC" => "en", + "fb_HA" => "en", + "fb_AR" => "ar", + "fb_HX" => "en", + "fb_LS" => "en", + "fb_LL" => "en", + "fb_RL" => "en", + "fb_ZH" => "zh", + "tl_PH" => "fil", + "sy_SY" => "syr", + "qc_GT" => "quc", + "tl_ST" => "tlh", + "gx_GR" => "grc", + "qz_MM" => "my", + "eh_IN" => "hi", + "cb_IQ" => "ckb", + "zz_TR" => "zza", + "tz_MA" => "tzm", + "sz_PL" => "szl", + "bp_IN" => "bho", + "ns_ZA" => "nso", + "fv_NG" => "fuv", + "em_ZM" => "bem", + "qr_GR" => "rup", + "qk_DZ" => "kab", + "qv_IT" => "vec", + "qs_DE" => "dsb", + "qb_DE" => "hsb", + "qe_US" => "esu", + "bv_DE" => "bar", + "qt_US" => "tli", + ]; + + public static function get($locale): string + { + // If given an fb-locale ("xx_XX"), try to map it to a language. Otherwise + // return "xx". If no '_' is found, return locale as-is. + $idx = strpos($locale, '_'); + + return self::LOC_TO_LANG[$locale] ?? ($idx !== false ? substr($locale, 0, $idx) : $locale); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/FbtSite.php b/src/fbt/Transform/FbtTransform/Translate/FbtSite.php new file mode 100644 index 0000000..dd38791 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/FbtSite.php @@ -0,0 +1,159 @@ + source data from a callsite and all + * the information necessary to produce the translated payload. It is + * used primarily by TranslationBuilder for this process. + */ +class FbtSite +{ + /* @var mixed */ + private $_type; + private $_hashToText; + /* @var mixed */ + private $_tableOrHash; + /* @var null|array */ + private $_metadata = null; + /* @var string */ + private $_project; + + public function __construct( + $type, + $hashToText, + $tableData, // source table & metadata + $project + ) { + $hasTableData = is_array($tableData); + invariant( + $type === FbtConstants::FBT_TYPE['TEXT'] || $hasTableData, + 'TEXT types should have no table data and TABLE require it' + ); + if ($type === FbtConstants::FBT_TYPE['TEXT']) { + invariant( + count(array_keys($hashToText)) === 1, + 'TEXT types should be a singleton entry' + ); + $this->_tableOrHash = array_keys($hashToText)[0]; + } + $this->_type = $type; + $this->_hashToText = $hashToText; + if ($hasTableData) { + $this->_tableOrHash = $tableData['t']; + $this->_metadata = FbtSiteMetadata::wrap($tableData['m']); + } + $this->_project = $project; + } + + public function getHashToText() + { + return $this->_hashToText; + } + + public function getMetadata() + { + return $this->_metadata ?? []; + } + + public function getProject() + { + return $this->_project; + } + + public function getType() + { + return $this->_type; + } + + // In a type of TABLE, this looks something like: + // + // ["*" => + // [... [ "*" => ] ] ] + // + // In a type of TEXT, this is simply the HASH + public function getTableOrHash() + { + return $this->_tableOrHash; + } + + // Replaces leaves in our table with corresponding hashes + public static function _hashifyLeaves( + $entry, // Represents a recursive descent into the table + $textToHash // Reverse mapping of hashToText for leaf lookups + ) { + return is_string($entry) + ? $textToHash[$entry] + : FbtUtils::objMap($entry, function ($branch, $key) use ($textToHash) { + return self::_hashifyLeaves($branch, $textToHash); + }); + } + + /** + * From a run of collectFbt using TextPackager. NOTE: this is NOT + * the output of serialize + * + * Relevant keys processed: + * { + * hashToText: {hash: text}, + * type: TABLE|TEXT + * jsfbt: { + * m: [levelMetadata,...] + * t: {...} + * } | text + * } + */ + public static function fromScan($json) + { + $tableData = $json['jsfbt']; + if ($json['type'] === FbtConstants::FBT_TYPE['TABLE']) { + $textToHash = []; + foreach ($json['hashToText'] as $k => $text) { + invariant( + empty($textToHash[$text]), // undefined + "Duplicate texts pointing to different hashes shouldn't be possible" + ); + $textToHash[$text] = $k; + } + $tableData = [ + 't' => FbtSite::_hashifyLeaves($json['jsfbt']['t'], $textToHash), + 'm' => $json['jsfbt']['m'], + ]; + } + + $fbtSite = new FbtSite( + $json['type'], + $json['hashToText'], + $tableData, + $json['project'] + ); + + return $fbtSite; + } + + public function serialize() + { + $json = [ + '_t' => $this->getType(), + 'h2t' => $this->getHashToText(), + 'p' => $this->getProject(), + ]; + if ($this->_type === FbtConstants::FBT_TYPE['TABLE']) { + $json['_d'] = [ + 't' => $this->_tableOrHash, + 'm' => FbtSiteMetadata::unwrap($this->_metadata), + ]; + } + + return $json; + } + + public static function deserialize($json) + { + return new FbtSite($json['_t'], $json['h2t'], $json['_d'], $json['p']); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetaEntry.php b/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetaEntry.php new file mode 100644 index 0000000..028cd3a --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetaEntry.php @@ -0,0 +1,128 @@ +_type = $type; + $this->_token = $token; + $this->_mask = $mask; + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + public static function wrap($entry): FbtSiteMetaEntry + { + FbtSiteMetaEntry::_validate($entry); + + return new FbtSiteMetaEntry( + $entry['type'] ?? null, + $entry['token'] ?? null, + $entry['mask'] ?? null + ); + } + + public function getToken() + { + return $this->_token; + } + + public function hasVariationMask(): bool + { + if ($this->_token === null) { + return false; + } + if ($this->_type === null) { + return $this->_mask !== null; + } + + return self::getVariationMaskFromType($this->_type) !== null; + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + public function getVariationMask() + { + invariant( + $this->hasVariationMask() === true, + 'check hasVariationMask to avoid this invariant' + ); + + if ($this->_type === null) { + return $this->_mask; + } else { + return self::getVariationMaskFromType($this->_type); + } + } + + public function unwrap(): array + { + $entry = []; + if ($this->_token !== null) { + $entry['token'] = $this->_token; + } + if ($this->_mask !== null) { + $entry['mask'] = $this->_mask; + } + if ($this->_type !== null) { + $entry['type'] = $this->_type; + } + + return $entry; + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + public static function _validate($entry) + { + $type = $entry['type'] ?? null; + $token = $entry['token'] ?? null; + $mask = $entry['mask'] ?? null; + if ($type === null) { + invariant( + $token !== null && $mask !== null, + 'token and mask should be specified when there is not type' + ); + } else { + invariant( + $mask === null, + 'mask should not be specified when there is type' + ); + if ($type === IntlVariations::INTL_FBT_VARIATION_TYPE['GENDER']) { + invariant( + $token !== null, + 'token should be specified for gender variation' + ); + } elseif ($type === IntlVariations::INTL_FBT_VARIATION_TYPE['PRONOUN']) { + invariant( + $token === null, + 'token should not be specified for pronoun variation' + ); + } + } + } + + /** + * @param int|null $type + * @return int|mixed|null + */ + public static function getVariationMaskFromType($type) + { + $_variationTypeToMask = []; + $_variationTypeToMask[IntlVariations::INTL_FBT_VARIATION_TYPE['GENDER']] = IntlVariations::INTL_VARIATION_MASK['GENDER']; + $_variationTypeToMask[IntlVariations::INTL_FBT_VARIATION_TYPE['NUMBER']] = IntlVariations::INTL_VARIATION_MASK['NUMBER']; + + return $_variationTypeToMask[$type] ?? null; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetadata.php b/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetadata.php new file mode 100644 index 0000000..17fb8c0 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/FbtSiteMetadata.php @@ -0,0 +1,28 @@ +unwrap() : null; + }, $metaEntries); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/Gender/IntlDefaultGenderType.php b/src/fbt/Transform/FbtTransform/Translate/Gender/IntlDefaultGenderType.php new file mode 100644 index 0000000..b0e8857 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/Gender/IntlDefaultGenderType.php @@ -0,0 +1,22 @@ + 1, + "lv_LV" => 1, + "ar_AR" => 1, + "ks_IN" => 1, + ]; + + const MERGED_LANGS = [ + "ht" => 1, + "lv" => 1, + "ar" => 1, + "ks" => 1, + ]; + + /** + * @param $lang + * @return IntlDefaultGenderType|IntlMergedUnknownGenderType + */ + public static function forLanguage($lang) + { + if (array_key_exists($lang, self::MERGED_LANGS)) { + return new IntlMergedUnknownGenderType(); + } + + return new IntlDefaultGenderType(); + } + + /** + * @param $locale + * @return IntlDefaultGenderType|IntlMergedUnknownGenderType + */ + public static function forLocale($locale) + { + if (array_key_exists($locale, self::MERGED_LOCALES)) { + return new IntlMergedUnknownGenderType(); + } + + return IntlGenderType::forLanguage(FBLocaleToLang::get($locale)); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/Gender/IntlMergedUnknownGenderType.php b/src/fbt/Transform/FbtTransform/Translate/Gender/IntlMergedUnknownGenderType.php new file mode 100644 index 0000000..a765b13 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/Gender/IntlMergedUnknownGenderType.php @@ -0,0 +1,21 @@ + 0x10, // 0b10000 + 'ONE' => 0x4, // 0b00100 + 'TWO' => 0x8, // 0b01000 + 'FEW' => 0x14, // 0b10100 + 'MANY' => 0xc, // 0b01100 + 'OTHER' => 0x18, // 0b11000 + ]; + + const INTL_GENDER_VARIATIONS = [ + 'MALE' => 1, + 'FEMALE' => 2, + 'UNKNOWN' => 3, + ]; + + // Two bitmasks for representing gender/number variations. Give a bit + // between number/gender in case CLDR ever exceeds 7 options + const INTL_VARIATION_MASK = [ + 'NUMBER' => 0x1c, // 0b11100 + 'GENDER' => 0x03, // 0b00011 + ]; + + const INTL_FBT_VARIATION_TYPE = [ + 'GENDER' => 1, + 'NUMBER' => 2, + 'PRONOUN' => 3, + ]; + + /** + * @throws \fbt\Exceptions\FbtException + */ + public static function getType($n): int + { + if (! self::isValidValue($n)) { + throw new FbtException('Invalid NumberType: ' . $n); + } + + return $n & self::INTL_VARIATION_MASK['NUMBER'] + ? self::INTL_VARIATION_MASK['NUMBER'] + : self::INTL_VARIATION_MASK['GENDER']; + } + + // This is not CLDR, but an fbt-specific marker that exists so that + // singular phrases are not overwritten by multiplexed plural phrases + // with a singular entry + const EXACTLY_ONE = '_1'; + + const SUBJECT = '__subject__'; + const VIEWING_USER = '__viewing_user__'; + + public static function isValidValue($v): bool + { + $specials = [ + // The default entry. When no entry exists, we fallback to this in the fbt + // table access logic. + '*' => true, + self::EXACTLY_ONE => true, + ]; + + return ( + $specials[$v] ?? + ($v & self::INTL_VARIATION_MASK['NUMBER'] && ! ($v & ~self::INTL_VARIATION_MASK['NUMBER'])) || + ($v & self::INTL_VARIATION_MASK['GENDER'] && ! ($v & ~self::INTL_VARIATION_MASK['GENDER'])) + ); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/TranslationBuilder.php b/src/fbt/Transform/FbtTransform/Translate/TranslationBuilder.php new file mode 100644 index 0000000..d3af4c9 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/TranslationBuilder.php @@ -0,0 +1,476 @@ + translation (TranslationData | string) + TranslationConfig $config, // Configuration for variation defaults (number/gender) + FbtSite $fbtSite, // fbtSite to translate + bool $inclHash // include hash/identifer in leaf of payloads + ) { + $this->_translations = $translations; + $this->_config = $config; + $this->_fbtSite = $fbtSite; + $this->_tokenMasks = []; // token => mask + $this->_metadata = $fbtSite->getMetadata(); // [{token: ..., mask: ...}, ...] + $this->_tableOrHash = $fbtSite->getTableOrHash(); + $this->_hasVCGenderVariation = $this->_findVCGenderVariation(); + $this->_hasTranslations = $this->_translationsExist(); + $this->_inclHash = $inclHash; + self::$_mem = []; + + // If a gender variation exists, add it to our table + if ($this->_hasVCGenderVariation) { + $this->_tableOrHash = ['*' => $this->_tableOrHash]; + array_unshift( + $this->_metadata, + FbtSiteMetaEntry::wrap([ + 'token' => IntlVariations::VIEWING_USER, + 'mask' => IntlVariations::INTL_VARIATION_MASK['GENDER'], + ]) + ); + } + + for ($ii = 0; $ii < count($this->_metadata); ++$ii) { + $metadata = $this->_metadata[$ii]; + if ($metadata !== null && $metadata->hasVariationMask()) { + $this->_tokenMasks[$metadata->getToken()] = $metadata->getVariationMask(); + } + } + } + + public function hasTranslations(): bool + { + return $this->_hasTranslations; + } + + public function build() + { + $table = $this->_buildRecursive($this->_tableOrHash); + if ($this->_hasVCGenderVariation) { + // This hidden key is checked during JS fbt runtime to signal that we + // should access the first entry of our table with the viewer's gender + $table['__vcg'] = 1; + } + + return $table; + } + + private function _translationsExist(): bool + { + foreach ($this->_fbtSite->getHashToText() as $hash) { + $transData = $this->_translations[$hash] ?? null; + if ( + ! ($transData instanceof TranslationData) || + $transData->hasTranslation() + ) { + // There is a translation or simple string for generated translation + return true; + } + } + + return false; + } + + /** + * Inspect all translation variations for a hidden viewer context token + */ + private function _findVCGenderVariation(): bool + { + foreach (array_keys($this->_fbtSite->getHashToText()) as $hash) { + $transData = $this->_translations[$hash] ?? null; + if (! ($transData instanceof TranslationData)) { + continue; + } + + $tokens = $transData->tokens; + foreach ($tokens as $token) { + if ($token === IntlVariations::VIEWING_USER) { + return true; + } + } + } + + return false; + } + + /** + * Given a hash (or hash-table), return the translated text (or table of + * texts). If the hash (or hashes) do not have a translation, then the + * original text will be used as the translation. + * + * If we should include the string hash then the method returns a vector with + * [string, hash] so that the hash is available to the run-time logging code. + * + * @param string|array $hashOrTable + * @param array $tokenConstraints + * @param int $levelIdx + * @return array|TranslationData|string|null + * + * @throws FbtException + */ + private function _buildRecursive( + $hashOrTable, + array $tokenConstraints = [], // token_name => variation constraint + int $levelIdx = 0 + ) { + if (is_string($hashOrTable)) { + return $this->_getLeafTranslation($hashOrTable, $tokenConstraints); + } + + $table = []; + foreach ($hashOrTable as $key => $branchOrLeaf) { + $trans = $this->_buildRecursive( + $branchOrLeaf, + $tokenConstraints, + $levelIdx + 1 + ); + if (shouldStore($trans)) { + $table[$key] = $trans; + } + + // This level will have metadata if it could potentially have variations. + // Below, we fill the table with those variation entries. + // + // NOTE: A key of '_1' (EXACTLY_ONE) will be processed by the + // buildRecursive call above, as its corresponding token constraint is + // defaulted to '*'. See _getConstraintMap for more details + $metadata = $this->_metadata[$levelIdx] ?? null; + if ( + $metadata !== null && + $metadata->hasVariationMask() && + $key !== IntlVariations::EXACTLY_ONE + ) { + $mask = $metadata->getVariationMask(); + invariant( + $mask === IntlVariations::INTL_VARIATION_MASK['NUMBER'] || $mask === IntlVariations::INTL_VARIATION_MASK['GENDER'], + 'Unknown variation mask' + ); + invariant( + IntlVariations::isValidValue($key), + 'We expect variation value keys for variations' + ); + $token = $metadata->getToken(); + $variationCandidates = getTypesFromMask($mask); + foreach ($variationCandidates as $variationKey) { + $tokenConstraints[$token] = $variationKey; + $trans = $this->_buildRecursive( + $branchOrLeaf, + $tokenConstraints, + $levelIdx + 1 + ); + if (shouldStore($trans)) { + $table[$variationKey] = $trans; + } + } + unset($tokenConstraints[$token]); + } + + // js~php diff + // @see https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order + uksort($table, function ($a, $b) { + return is_int($b) - is_int($a) ?: strnatcmp($a, $b); + }); + } + + return $table; + } + + /** + * @param string $hash + * @param array $tokenConstraints + * + * @return string|array|TranslationData|null + */ + private function _getLeafTranslation( + string $hash, // string + array $tokenConstraints // {string: string}: token => constraint + ) { + $transData = $this->_translations[$hash] ?? null; + if (is_string($transData)) { + // Fake translations are just simple strings. There's no such thing as + // variation support for these locales. So if token constraints were + // specified, return null and rely on runtime fallback to wildcard. + $translation = $tokenConstraints ? null : $transData; + } else { + // Real translations are TranslationData objects, so we call the + // getDefaultTranslation() method to get the translation (we hope), or use + // original text if no translation exist. + $source = $this->_fbtSite->getHashToText()[$hash]; + $defTranslation = $transData ? $transData->getDefaultTranslation($this->_config) : null; + $translation = FbtUtils::hasKeys($tokenConstraints) + ? $this->getConstrainedTranslation($hash, $tokenConstraints) + : // If no translation available, use the English source text + $defTranslation ?? $source; + } + + // fbt: disable null translation for variation + if (! $translation) { + return null; + } + + // Couple the string with a hash if it was marked as such. We do this + // when logging impressions or when using QuickTranslations. The logging + // is performed by `fbt::_(...)` + return $this->_inclHash ? [$translation, $hash] : $translation; + } + + /** + * Given a hash and restraints on the token variations, retrieve the + * appropriate translation for our map. A null entry is a signal + * not to add the translation to the map, because it's already in + * the map via its fallback ('*') keys. + */ + public function getConstrainedTranslation( + string $hash, // string + array $tokenConstraints // dict : token => constraint + ) { + $constraintKeys = []; + foreach ($this->_tokenMasks as $token => $mask) { + $val = $tokenConstraints[$token] ?? '*'; + $constraintKeys[] = [$token, $val]; + } + $constraintMap = $this->_getConstraintMap($hash); + $aggregateKey = buildConstraintKey($constraintKeys); + $translation = $constraintMap[$aggregateKey] ?? null; + if (! $translation) { + return null; + } + for ($ii = 0; $ii < count($constraintKeys); ++$ii) { + list($token, $constraint) = $constraintKeys[$ii]; + if ($constraint === '*') { + continue; + } + + // If any of the constraints share the same translation as the wildcard + // (default) entry at this level, don't add an entry to the table. They + // will be in the table under the '*' key. + $constraintKeys[$ii] = [$token, '*']; + $wildKey = buildConstraintKey($constraintKeys); + $wildTranslation = $constraintMap[$wildKey] ?? null; + if ($wildTranslation === $translation) { + return null; + } + // Set the constraint back + $constraintKeys[$ii] = [$token, $constraint]; + } + + return $translation; + } + + /** + * Populates our variation constraint map. The map is of all possible + * variation combinations (serialized as a string) to the appropriate + * translation. For example, JavaScript like: + * + * fbt('Hi ' . fbt::param('user', $viewer->name, ['gender' => $viewer->gender]) . + * ', would you like to play ' . + * fbt::param('count', $gameCount, ['number' => true]) . + * ' games of ' . fbt::enum($game, ['chess','backgammon','poker']) . + * '? Click ' . fbt::param('link', createElement('a', ...)), 'sample'), + * + * will have variations for the 'user' and 'count' parameters. Accounting for + * all variations in a locale where we don't merge unknown gender into male + * and we have the dual number variation, the map will have the following keys + * mapping to the corresponding translation. + * + * user%*:count%* [default (unknown) - default (other) ] + * user%*:count%4 [default - one ] + * user%*:count%20 [default - few ] + * user%*:count%24 [default - other ] + * user%1:count%* [male - default (other) ] + * user%1:count%4 [male - one ] + * user%1:count%20 [male - few ] + * user%1:count%24 [male - other ] + * user%2:count%* [female - default (other) ] + * user%2:count%4 [female - singular ] + * user%2:count%20 [female - few ] + * user%2:count%24 [female - other ] + * user%3:count%* [unknown gender - default (other) ] + * user%3:count%4 [unknown gender - singular ] + * user%3:count%20 [unknown gender - few ] + * user%3:count%24 [unknown gender - other ] + * + * Note we have duplicate translations in this map. As an example, the + * following keys map to the same translation + * 'user%*:count%*' (default - default) + * 'user%3:count%*' (unknown - default) + * 'user%3:count%24' (unknown - other) + * + * These translations are deduped later in getConstrainedTranslation such + * that only the 'user%*:count%*' in our tree is in the JSON map. i.e. + * + * { + * // No unknown gender entry exists at this level - we rely on fallback + * '*' => { + * // no plural entry exists at this level + * '*' => {translation}, + * ... + * + * }, + * ... + * } + */ + + // Yes this is hand-rolled memoization :( + // TODO: T37795723 - Pull in a lightweight (not bloated) memoization library + /** @var array */ + private static $_mem; + + private function _getConstraintMap($hash) + { + if (array_key_exists($hash, self::$_mem)) { + return self::$_mem[$hash]; + } + + $constraintMap = []; + $transData = $this->_translations[$hash] ?? null; + if (! $transData) { + // No translation? No constraints. + return (self::$_mem[$hash] = $constraintMap); + } + + // For every possible variation combination, create a mapping to its + // corresponding translation + foreach ($transData->translations as $translation) { + $constraints = []; + foreach ($translation['variations'] as $idx => $variation) { + // We prune entries that contain non-default variations + // for tokens we haven't specified. + $token = $transData->tokens[$idx]; + if ( + // Token variation type not specified + empty($this->_tokenMasks[$token]) || + // Translated variation type is different than token variation type + $this->_tokenMasks[$token] !== $transData->types[$idx] + ) { + // Only add default tokens we haven't specified. + if (! $this->_config->isDefaultVariation($variation)) { + return; + } + } + $constraints[$token] = $variation; + } + // A note about fbt:plurals. They can introduce global token + // discrepancies between leaf nodes. Singular translations don't have + // number tokens, but their plural counterparts can (when showCount = + // "ifMany" or "yes"). If we are dealing with the singular leaf of an + // fbt:plural, since it has a unique hash, we can $it masquerade as + // default: '*', since no such variation actually exists for a + // non-existent token + $constraintKeys = []; + foreach ($this->_tokenMasks as $k => $mask) { + $constraintKeys[] = [$k, $constraints[$k] ?? '*']; + } + $this->_insertConstraint( + $constraintKeys, + $constraintMap, + $translation['translation'], + 0 + ); + } + + return self::$_mem[$hash] = $constraintMap; + } + + /** + * @throws FbtException + */ + private function _insertConstraint( + array $keys, // [[token, constraint]] + &$constraintMap, // {key: translation} + string $translation, // string + int $defaultingLevel // int + ) { + $aggregateKey = buildConstraintKey($keys); + if (isset($constraintMap[$aggregateKey])) { + throw new FbtException( + 'Unexpected duplicate key: ' . + $aggregateKey . + "\nOriginal: " . + $constraintMap[$aggregateKey] . + "\nNew: " . + $translation + ); + } + $constraintMap[$aggregateKey] = $translation; + + // Also include duplicate '*' entries if it is a default value + for ($ii = $defaultingLevel; $ii < count($keys); $ii++) { + list($tok, $val) = $keys[$ii]; + if ($val !== '*' && $this->_config->isDefaultVariation($val)) { + $keys[$ii] = [$tok, '*']; + $this->_insertConstraint($keys, $constraintMap, $translation, $ii + 1); + $keys[$ii] = [$tok, $val]; // return the value back + } + } + } +} + +function shouldStore($branch): bool +{ + return $branch !== null && (is_string($branch) || FbtUtils::hasKeys($branch)); +} + +/** + * Build the aggregate key with which we access the constraint map. The + * constraint map maps the given constraints to the appropriate translation + */ +function buildConstraintKey( + array $keys // [[token, constraint]] +): string { + return implode(':', array_map(function ($kv) { + return $kv[0] . '%' . $kv[1]; + }, $keys)); +} + +/** + * @throws \fbt\Exceptions\FbtException + */ +function getTypesFromMask($mask): array +{ + $type = IntlVariations::getType($mask); + if ($type === IntlVariations::INTL_VARIATION_MASK['NUMBER']) { + return array_values(IntlVariations::INTL_NUMBER_VARIATIONS); + } else { + $gender = IntlVariations::INTL_GENDER_VARIATIONS; + + return [ + $gender['MALE'], + $gender['FEMALE'], + $gender['UNKNOWN'], + ]; + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/TranslationConfig.php b/src/fbt/Transform/FbtTransform/Translate/TranslationConfig.php new file mode 100644 index 0000000..bc3cccd --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/TranslationConfig.php @@ -0,0 +1,53 @@ +numberType = $numberType; + $this->genderType = $genderType; + } + + public function getTypesFromMask( + $mask // IntlVariationType + ) { + if ($mask === IntlVariations::INTL_FBT_VARIATION_TYPE['NUMBER']) { + $types = $this->numberType->getNumberVariations(); + + return array_merge([IntlVariations::EXACTLY_ONE], $types); + } + + return $this->genderType->getGenderVariations(); + } + + public function isDefaultVariation( + $variation // mixed + ) { + $value = intval($variation); + if (is_nan($value)) { + return false; + } + + return ( + $value === $this->numberType->getFallback() || + $value === $this->genderType->getFallback() + ); + } + + public static function fromFBLocale($locale): TranslationConfig + { + return new TranslationConfig( + IntlNumberType::getLocale($locale), + IntlGenderType::forLocale($locale) + ); + } +} diff --git a/src/fbt/Transform/FbtTransform/Translate/TranslationData.php b/src/fbt/Transform/FbtTransform/Translate/TranslationData.php new file mode 100644 index 0000000..38c86c8 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Translate/TranslationData.php @@ -0,0 +1,55 @@ +tokens = $tokens; + $this->types = $types; + $this->translations = $translations; + } + + public static function fromJSON(array $json): TranslationData + { + return new TranslationData($json['tokens'], $json['types'], $json['translations']); + } + + public static function deserialize(string $jsonStr) + { + self::fromJSON(json_decode($jsonStr)); + } + + public function hasTranslation(): bool + { + return count($this->translations) > 0; + } + + // Makes a best effort attempt at finding the default translation. + public function getDefaultTranslation($config) + { + if (empty($this->_defaultTranslation)) { + for ($i = 0; $i < count($this->translations); ++$i) { + $trans = $this->translations[$i]; + $isDefault = true; + foreach ($trans['variations'] as $v) { + if (! $config->isDefaultVariation($v)) { + $isDefault = false; + + break; + } + } + if ($isDefault) { + return ($this->_defaultTranslation = $trans['translation']); + } + } + $this->_defaultTranslation = null; + } + + return $this->_defaultTranslation; + } +} diff --git a/src/fbt/Transform/FbtTransform/Utils/GetNamespacedArgs.php b/src/fbt/Transform/FbtTransform/Utils/GetNamespacedArgs.php new file mode 100644 index 0000000..01a988a --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Utils/GetNamespacedArgs.php @@ -0,0 +1,190 @@ +moduleName = $moduleName; + } + + /** + * Node that is a child of a node that should be handled as + * + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function implicitParamMarker(Node $node): array + { + $newNode = FbtAutoWrap::wrapImplicitFBTParam($this->moduleName, $node); + + return ['=' . $newNode->getAttribute('paramName'), $newNode->outertext()]; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function param(Node $node): array + { + $nameAttr = FbtUtils::normalizeSpaces(FbtUtils::getAttributeByNameOrThrow($node, 'name')); + $options = FbtUtils::getOptionsFromAttributes($node, FbtConstants::validParamOptions(), FbtConstants::REQUIRED_PARAM_OPTIONS + FbtUtils::FBT_CORE_ATTRIBUTES); + + // js~php diff: + + $paramChildren = array_values(array_filter(FbtUtils::filterEmptyNodes($node->nodes), function (Node $node) { + return $node->isElement(); + })); + + if (count($paramChildren) === 0 && count($node->nodes) === 1 && $node->nodes[0]->isText()) { + $paramChildren = [$node->nodes[0]->innertext]; + } + + if (count($paramChildren) !== 1) { + throw FbtUtils::errorAt($node, "$this->moduleName:param expects an string or HTML element, and only one"); + } + + // restore nodes noise (Simple HTML DOM issue) + $node = NodeParser::parse('' . $node->innertext() . '', false, true, DEFAULT_TARGET_CHARSET, false) + ->find('html', 0); + + $value = FbtUtils::makeFbtElementArrayFromNode($node->children() ?: $node->nodes)[0] ?? ''; + if (! empty($options['number'])) { + $value = (string)$value; + } + + $paramArgs = [$nameAttr, $value]; + + if (count($options) > 0) { + $paramArgs[] = $options; + } + + return $paramArgs; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function plural(Node $node): array + { + $options = FbtUtils::getOptionsFromAttributes($node, FbtConstants::validPluralOptions(), FbtConstants::PLURAL_REQUIRED_ATTRIBUTES); + $countAttr = FbtUtils::getAttributeByNameOrThrow($node, 'count'); + $pluralChildren = FbtUtils::filterEmptyNodes($node->nodes); + + if (count($pluralChildren) !== 1) { + throw FbtUtils::errorAt($node, "$this->moduleName:plural expects text or an expression, and only one"); + } + + $singularNode = $pluralChildren[0]; + $singularText = $singularNode->innertext; + $singularArg = trim(FbtUtils::normalizeSpaces($singularText)); // fbt diff rtrim() + + return [$singularArg, $countAttr, $options]; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function pronoun(Node $node): array + { + if (! $node->isSelfClosing()) { + throw FbtUtils::errorAt($node, "$this->moduleName:pronoun must be a self-closing element"); + } + + $typeAttr = FbtUtils::getAttributeByNameOrThrow($node, 'type'); + + $validPronounUsages = FbtConstants::VALID_PRONOUN_USAGES; + if (! isset($validPronounUsages[$typeAttr])) { + throw FbtUtils::errorAt($node, "$this->moduleName:pronoun attribute \"type\" must be one of [" . implode(', ', array_keys($validPronounUsages)) . ']'); + } + + $result = [$typeAttr]; + $result[] = FbtUtils::getAttributeByNameOrThrow($node, 'gender'); + $options = FbtUtils::getOptionsFromAttributes($node, FbtConstants::VALID_PRONOUN_OPTIONS, FbtConstants::PRONOUN_REQUIRED_ATTRIBUTES); + + if (0 < count($options)) { + $result[] = $options; + } + + return $result; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function name(Node $node): array + { + $nameAttribute = FbtUtils::getAttributeByNameOrThrow($node, 'name'); + $genderAttribute = FbtUtils::getAttributeByNameOrThrow($node, 'gender'); + $nameChildren = FbtUtils::filterEmptyNodes($node->nodes); + + if (count($nameChildren) !== 1) { + throw FbtUtils::errorAt($node, "$this->moduleName:name expects text or an expression, and only one"); + } + + $singularArg = $nameChildren[0]; + + if ($singularArg->isText()) { + $singularArg = FbtUtils::normalizeSpaces($singularArg->innertext); + } + + return [$nameAttribute, $singularArg, $genderAttribute]; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function sameParam(Node $node): array + { + if (! $node->isSelfClosing()) { + throw FbtUtils::errorAt($node, "$this->moduleName:same-param must be a self-closing element"); + } + + $nameAttr = FbtUtils::getAttributeByNameOrThrow($node, 'name'); + + return [$nameAttr]; + } + + /** + * or + * + * @throws \fbt\Exceptions\FbtParserException + */ + public function enum(Node $node): array + { + if (! $node->isSelfClosing()) { + throw FbtUtils::errorAt($node, "$this->moduleName:enum must be a self-closing element"); + } + + $rangeAttr = null; + + try { + $rangeAttr = FbtUtils::getAttributeByNameOrThrow($node, 'enum-range'); + $rangeAttrValue = FbtUtils::extractEnumRange($rangeAttr); + } catch (\Exception $ex) { + throw FbtUtils::errorAt($node, 'Expected JSON for enum-range attribute but got ' . $rangeAttr); // js~php diff + } + + $valueAttr = FbtUtils::getAttributeByNameOrThrow($node, 'value'); + + return [$valueAttr, $rangeAttrValue]; + } +} diff --git a/src/fbt/Transform/FbtTransform/Utils/TextPackager.php b/src/fbt/Transform/FbtTransform/Utils/TextPackager.php new file mode 100644 index 0000000..2e19658 --- /dev/null +++ b/src/fbt/Transform/FbtTransform/Utils/TextPackager.php @@ -0,0 +1,88 @@ +hash = $hash; + } + + /** + * The hash function signature should look like: + * [{desc: '...', texts: ['t1',...,'tN']},...]) => + * [[hash1,...,hashN],...] + * + * @throws FbtException + */ + public function pack(array $phrases): array + { + $flatTexts = array_map(function ($phrase) { + return [ + "desc" => $phrase['desc'], + "texts" => $this->_flattenTexts( + ($phrase['type'] === FbtConstants::FBT_TYPE['TABLE']) + ? $phrase['jsfbt']['t'] + : $phrase['jsfbt'] + ), + ]; + }, $phrases); + + + $hashes = call_user_func_array([FbtHash::class, $this->hash], [$flatTexts]); + + foreach ($flatTexts as $phraseIdx => $flatText) { + $hashToText = []; + foreach ($flatText['texts'] as $textIdx => $text) { + $hash = $hashes[$phraseIdx][$textIdx]; + if ($hash == null) { + throw new FbtException('Missing hash for text: ' . $text); + } + $hashToText[$hash] = $text; + } + + $phrases[$phraseIdx] = array_merge( + [ + 'hashToText' => $hashToText, + ], + $phrases[$phraseIdx] + ); + } + + return $phrases; + } + + /** + * @param array|string $texts + * + * @return array|string[] + */ + private function _flattenTexts($texts): array + { + if (is_string($texts)) { + // return all tree leaves of a jsfbt TABLE or singleton array in the case of + // a TEXT type + return [$texts]; + } + + $aggregate = []; + foreach ($texts as $text) { + $aggregate = array_merge($aggregate, $this->_flattenTexts($text)); + } + + return $aggregate; + } +} diff --git a/src/fbt/Transform/FbtTransform/fbtHash.php b/src/fbt/Transform/FbtTransform/fbtHash.php new file mode 100644 index 0000000..8c4100e --- /dev/null +++ b/src/fbt/Transform/FbtTransform/fbtHash.php @@ -0,0 +1,79 @@ + 62 || $number < 0) { + return ''; + } + + $output = ''; + do { + $output = self::BASE_N_SYMBOLS[$number % $base] . $output; + $number = floor($number / $base); + } while ($number > 0); + + return $output; + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + public static function fbtHashKey($jsfbt, $desc, $noStringify = false): string + { + return self::uintToBaseN(self::fbtJenkinsHash($jsfbt, $desc, $noStringify), 62); + } + + /** + * @throws \fbt\Exceptions\FbtException + */ + public static function fbtJenkinsHash($jsfbt, $desc, $noStringify = false): int + { + $payload = $noStringify ? $jsfbt : json_encode($jsfbt, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + invariant( + is_string($payload), + 'JSFBT is not a string type. Please disable noStringify' + ); + $key = $payload . '|' . $desc; + + return self::jenkinsHash($key); + } + + public static function toUtf8($str): array + { + return array_map('mb_ord', mb_str_split($str)); + } + + // Hash computation for each string that matches the dump script in i18n's php. + public static function jenkinsHash(string $str): int + { + if (! $str) { + return 0; + } + + $utf8 = self::toUtf8($str); + $hash = 0; + $len = count($utf8); + for ($i = 0; $i < $len; $i++) { + $hash = $hash + $utf8[$i]; + $hash = unsignedRightShift($hash + ($hash << 10), 0); + $hash = $hash ^ unsignedRightShift($hash, 6); + } + + $hash = unsignedRightShift($hash + ($hash << 3), 0); + $hash = $hash ^ unsignedRightShift($hash, 11); + $hash = unsignedRightShift($hash + ($hash << 15), 0); + + return $hash; + } +} diff --git a/src/fbt/Util/NodeParser.php b/src/fbt/Util/NodeParser.php new file mode 100644 index 0000000..fdc509d --- /dev/null +++ b/src/fbt/Util/NodeParser.php @@ -0,0 +1,20 @@ + $maxLen) { + $dom->clear(); + + return false; + } + + return $dom->load($contents, $lowercase, $stripRN); +} + +function str_get_html( + $str, + $lowercase = true, + $forceTagsClosed = true, + $target_charset = DEFAULT_TARGET_CHARSET, + $stripRN = true, + $defaultBRText = DEFAULT_BR_TEXT, + $defaultSpanText = DEFAULT_SPAN_TEXT +) { + $dom = new DOM( + null, + $lowercase, + $forceTagsClosed, + $target_charset, + $stripRN, + $defaultBRText, + $defaultSpanText + ); + + if (empty($str) || strlen($str) > MAX_FILE_SIZE) { + $dom->clear(); + + return false; + } + + return $dom->load($str, $lowercase, $stripRN); +} + +function dump_html_tree($node, $show_attr = true, $deep = 0) +{ + $node->dump($node); +} + +class Node +{ + public $nodetype = HDOM_TYPE_TEXT; + public $tag = 'text'; + public $attr = []; + public $children = []; + public $nodes = []; + public $parent = null; + public $_ = []; + public $tag_start = 0; + private $dom = null; + + public function __construct($dom) + { + $this->dom = $dom; + $dom->nodes[] = $this; + } + + public function __destruct() + { + $this->clear(); + } + + public function __toString() + { + return $this->outertext(); + } + + public function clear() + { + $this->dom = null; + $this->nodes = null; + $this->parent = null; + $this->children = null; + } + + public function dump($show_attr = true, $depth = 0) + { + echo str_repeat("\t", $depth) . $this->tag; + + if ($show_attr && count($this->attr) > 0) { + echo '('; + foreach ($this->attr as $k => $v) { + echo "[$k]=>\"$v\", "; + } + echo ')'; + } + + echo "\n"; + + if ($this->nodes) { + foreach ($this->nodes as $node) { + $node->dump($show_attr, $depth + 1); + } + } + } + + public function dump_node($echo = true) + { + $string = $this->tag; + + if (count($this->attr) > 0) { + $string .= '('; + foreach ($this->attr as $k => $v) { + $string .= "[$k]=>\"$v\", "; + } + $string .= ')'; + } + + if (count($this->_) > 0) { + $string .= ' $_ ('; + foreach ($this->_ as $k => $v) { + if (is_array($v)) { + $string .= "[$k]=>("; + foreach ($v as $k2 => $v2) { + $string .= "[$k2]=>\"$v2\", "; + } + $string .= ')'; + } else { + $string .= "[$k]=>\"$v\", "; + } + } + $string .= ')'; + } + + if (isset($this->text)) { + $string .= " text: ({$this->text})"; + } + + $string .= ' HDOM_INNER_INFO: '; + + if (isset($node->_[HDOM_INFO_INNER])) { + $string .= "'" . $node->_[HDOM_INFO_INNER] . "'"; + } else { + $string .= ' NULL '; + } + + $string .= ' children: ' . count($this->children); + $string .= ' nodes: ' . count($this->nodes); + $string .= ' tag_start: ' . $this->tag_start; + $string .= "\n"; + + if ($echo) { + echo $string; + + return; + } else { + return $string; + } + } + + public function parent($parent = null) + { + // I am SURE that this doesn't work properly. + // It fails to unset the current node from it's current parents nodes or + // children list first. + if ($parent !== null) { + $this->parent = $parent; + $this->parent->nodes[] = $this; + $this->parent->children[] = $this; + } + + return $this->parent; + } + + public function has_child() + { + return ! empty($this->children); + } + + public function children($idx = -1) + { + if ($idx === -1) { + return $this->children; + } + + if (isset($this->children[$idx])) { + return $this->children[$idx]; + } + + return null; + } + + public function first_child() + { + if (count($this->children) > 0) { + return $this->children[0]; + } + + return null; + } + + public function last_child() + { + if (count($this->children) > 0) { + return end($this->children); + } + + return null; + } + + public function next_sibling() + { + if ($this->parent === null) { + return null; + } + + $idx = array_search($this, $this->parent->children, true); + + if ($idx !== false && isset($this->parent->children[$idx + 1])) { + return $this->parent->children[$idx + 1]; + } + + return null; + } + + public function prev_sibling() + { + if ($this->parent === null) { + return null; + } + + $idx = array_search($this, $this->parent->children, true); + + if ($idx !== false && $idx > 0) { + return $this->parent->children[$idx - 1]; + } + + return null; + } + + public function find_ancestor_tag($tag) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + if ($this->parent === null) { + return null; + } + + $ancestor = $this->parent; + + while (! is_null($ancestor)) { + if (is_object($debug_object)) { + $debug_object->debug_log(2, 'Current tag is: ' . $ancestor->tag); + } + + if ($ancestor->tag === $tag) { + break; + } + + $ancestor = $ancestor->parent; + } + + return $ancestor; + } + + public function innertext() + { + if (isset($this->_[HDOM_INFO_INNER])) { + return $this->_[HDOM_INFO_INNER]; + } + + if (isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + + $ret = ''; + + foreach ($this->nodes as $n) { + $ret .= $n->outertext(); + } + + return $ret; + } + + public function outertext() + { + global $debug_object; + + if (is_object($debug_object)) { + $text = ''; + + if ($this->tag === 'text') { + if (! empty($this->text)) { + $text = ' with text: ' . $this->text; + } + } + + $debug_object->debug_log(1, 'Innertext of tag: ' . $this->tag . $text); + } + + if ($this->tag === 'root') { + return $this->innertext(); + } + + // todo: What is the use of this callback? Remove? + if ($this->dom && $this->dom->callback !== null) { + call_user_func_array($this->dom->callback, [$this]); + } + + if (isset($this->_[HDOM_INFO_OUTER])) { + return $this->_[HDOM_INFO_OUTER]; + } + + if (isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + + $ret = ''; + + if ($this->dom && $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]) { + $ret = $this->dom->nodes[$this->_[HDOM_INFO_BEGIN]]->makeup(); + } + + if (isset($this->_[HDOM_INFO_INNER])) { + // todo:
should either never have HDOM_INFO_INNER or always + if ($this->tag !== 'br') { + $ret .= $this->_[HDOM_INFO_INNER]; + } + } elseif ($this->nodes) { + foreach ($this->nodes as $n) { + $ret .= $this->convert_text($n->outertext()); + } + } + + if (isset($this->_[HDOM_INFO_END]) && $this->_[HDOM_INFO_END] != 0) { + $ret .= 'tag . '>'; + } + + return $ret; + } + + public function text() + { + if (isset($this->_[HDOM_INFO_INNER])) { + return $this->_[HDOM_INFO_INNER]; + } + + switch ($this->nodetype) { + case HDOM_TYPE_TEXT: return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + case HDOM_TYPE_COMMENT: return ''; + case HDOM_TYPE_UNKNOWN: return ''; + } + + if (strcasecmp($this->tag, 'script') === 0) { + return ''; + } + if (strcasecmp($this->tag, 'style') === 0) { + return ''; + } + + $ret = ''; + + // In rare cases, (always node type 1 or HDOM_TYPE_ELEMENT - observed + // for some span tags, and some p tags) $this->nodes is set to NULL. + // NOTE: This indicates that there is a problem where it's set to NULL + // without a clear happening. + // WHY is this happening? + if (! is_null($this->nodes)) { + foreach ($this->nodes as $n) { + // Start paragraph after a blank line + if ($n->tag === 'p') { + $ret = trim($ret) . "\n\n"; + } + + $ret .= $this->convert_text($n->text()); + + // If this node is a span... add a space at the end of it so + // multiple spans don't run into each other. This is plaintext + // after all. + if ($n->tag === 'span') { + $ret .= $this->dom->default_span_text; + } + } + } + + return $ret; + } + + public function xmltext() + { + $ret = $this->innertext(); + $ret = str_ireplace('', '', $ret); + + return $ret; + } + + public function makeup() + { + // text, comment, unknown + if (isset($this->_[HDOM_INFO_TEXT])) { + return $this->dom->restore_noise($this->_[HDOM_INFO_TEXT]); + } + + $ret = '<' . $this->tag; + $i = -1; + + foreach ($this->attr as $key => $val) { + ++$i; + + // skip removed attribute + if ($val === null || $val === false) { + continue; + } + + $ret .= $this->_[HDOM_INFO_SPACE][$i][0]; + + //no value attr: nowrap, checked selected... + if ($val === true) { + $ret .= $key; + } else { + switch ($this->_[HDOM_INFO_QUOTE][$i]) { + case HDOM_QUOTE_DOUBLE: $quote = '"'; + +break; + case HDOM_QUOTE_SINGLE: $quote = '\''; + +break; + default: $quote = ''; + } + + $ret .= $key + . $this->_[HDOM_INFO_SPACE][$i][1] + . '=' + . $this->_[HDOM_INFO_SPACE][$i][2] + . $quote + . $val + . $quote; + } + } + + $ret = $this->dom->restore_noise($ret); + + return $ret . $this->_[HDOM_INFO_ENDSPACE] . '>'; + } + + public function find($selector, $idx = null, $lowercase = false) + { + $selectors = $this->parse_selector($selector); + if (($count = count($selectors)) === 0) { + return []; + } + $found_keys = []; + + // find each selector + for ($c = 0; $c < $count; ++$c) { + // The change on the below line was documented on the sourceforge + // code tracker id 2788009 + // used to be: if (($levle=count($selectors[0]))===0) return array(); + if (($levle = count($selectors[$c])) === 0) { + return []; + } + if (! isset($this->_[HDOM_INFO_BEGIN])) { + return []; + } + + $head = [$this->_[HDOM_INFO_BEGIN] => 1]; + $cmd = ' '; // Combinator + + // handle descendant selectors, no recursive! + for ($l = 0; $l < $levle; ++$l) { + $ret = []; + + foreach ($head as $k => $v) { + $n = ($k === -1) ? $this->dom->root : $this->dom->nodes[$k]; + //PaperG - Pass this optional parameter on to the seek function. + $n->seek($selectors[$c][$l], $ret, $cmd, $lowercase); + } + + $head = $ret; + $cmd = $selectors[$c][$l][4]; // Next Combinator + } + + foreach ($head as $k => $v) { + if (! isset($found_keys[$k])) { + $found_keys[$k] = 1; + } + } + } + + // sort keys + ksort($found_keys); + + $found = []; + foreach ($found_keys as $k => $v) { + $found[] = $this->dom->nodes[$k]; + } + + // return nth-element or array + if (is_null($idx)) { + return $found; + } elseif ($idx < 0) { + $idx = count($found) + $idx; + } + + return (isset($found[$idx])) ? $found[$idx] : null; + } + + protected function seek($selector, &$ret, $parent_cmd, $lowercase = false) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + list($tag, $id, $class, $attributes, $cmb) = $selector; + $nodes = []; + + if ($parent_cmd === ' ') { // Descendant Combinator + // Find parent closing tag if the current element doesn't have a closing + // tag (i.e. void element) + $end = (! empty($this->_[HDOM_INFO_END])) ? $this->_[HDOM_INFO_END] : 0; + if ($end == 0) { + $parent = $this->parent; + while (! isset($parent->_[HDOM_INFO_END]) && $parent !== null) { + $end -= 1; + $parent = $parent->parent; + } + $end += $parent->_[HDOM_INFO_END]; + } + + // Get list of target nodes + $nodes_start = $this->_[HDOM_INFO_BEGIN] + 1; + $nodes_count = $end - $nodes_start; + $nodes = array_slice($this->dom->nodes, $nodes_start, $nodes_count, true); + } elseif ($parent_cmd === '>') { // Child Combinator + $nodes = $this->children; + } elseif ($parent_cmd === '+' + && $this->parent + && in_array($this, $this->parent->children)) { // Next-Sibling Combinator + $index = array_search($this, $this->parent->children, true) + 1; + if ($index < count($this->parent->children)) { + $nodes[] = $this->parent->children[$index]; + } + } elseif ($parent_cmd === '~' + && $this->parent + && in_array($this, $this->parent->children)) { // Subsequent Sibling Combinator + $index = array_search($this, $this->parent->children, true); + $nodes = array_slice($this->parent->children, $index); + } + + // Go throgh each element starting at this element until the end tag + // Note: If this element is a void tag, any previous void element is + // skipped. + foreach ($nodes as $node) { + $pass = true; + + // Skip root nodes + if (! $node->parent) { + $pass = false; + } + + // Handle 'text' selector + if ($pass && $tag === 'text' && $node->tag === 'text') { + $ret[array_search($node, $this->dom->nodes, true)] = 1; + unset($node); + + continue; + } + + // Skip if node isn't a child node (i.e. text nodes) + if ($pass && ! in_array($node, $node->parent->children, true)) { + $pass = false; + } + + // Skip if tag doesn't match + if ($pass && $tag !== '' && $tag !== $node->tag && $tag !== '*') { + $pass = false; + } + + // Skip if ID doesn't exist + if ($pass && $id !== '' && ! isset($node->attr['id'])) { + $pass = false; + } + + // Check if ID matches + if ($pass && $id !== '' && isset($node->attr['id'])) { + // Note: Only consider the first ID (as browsers do) + $node_id = explode(' ', trim($node->attr['id']))[0]; + + if ($id !== $node_id) { + $pass = false; + } + } + + // Check if all class(es) exist + if ($pass && $class !== '' && is_array($class) && ! empty($class)) { + if (isset($node->attr['class'])) { + $node_classes = explode(' ', $node->attr['class']); + + if ($lowercase) { + $node_classes = array_map('strtolower', $node_classes); + } + + foreach ($class as $c) { + if (! in_array($c, $node_classes)) { + $pass = false; + + break; + } + } + } else { + $pass = false; + } + } + + // Check attributes + if ($pass + && $attributes !== '' + && is_array($attributes) + && ! empty($attributes)) { + foreach ($attributes as $a) { + list( + $att_name, + $att_expr, + $att_val, + $att_inv, + $att_case_sensitivity + ) = $a; + + // Handle indexing attributes (i.e. "[2]") + /** + * Note: This is not supported by the CSS Standard but adds + * the ability to select items compatible to XPath (i.e. + * the 3rd element within it's parent). + * + * Note: This doesn't conflict with the CSS Standard which + * doesn't work on numeric attributes anyway. + */ + if (is_numeric($att_name) + && $att_expr === '' + && $att_val === '') { + $count = 0; + + // Find index of current element in parent + foreach ($node->parent->children as $c) { + if ($c->tag === $node->tag) { + ++$count; + } + if ($c === $node) { + break; + } + } + + // If this is the correct node, continue with next + // attribute + if ($count === (int)$att_name) { + continue; + } + } + + // Check attribute availability + if ($att_inv) { // Attribute should NOT be set + if (isset($node->attr[$att_name])) { + $pass = false; + + break; + } + } else { // Attribute should be set + // todo: "plaintext" is not a valid CSS selector! + if ($att_name !== 'plaintext' + && ! isset($node->attr[$att_name])) { + $pass = false; + + break; + } + } + + // Continue with next attribute if expression isn't defined + if ($att_expr === '') { + continue; + } + + // If they have told us that this is a "plaintext" + // search then we want the plaintext of the node - right? + // todo "plaintext" is not a valid CSS selector! + if ($att_name === 'plaintext') { + $nodeKeyValue = $node->text(); + } else { + $nodeKeyValue = $node->attr[$att_name]; + } + + if (is_object($debug_object)) { + $debug_object->debug_log( + 2, + 'testing node: ' + . $node->tag + . ' for attribute: ' + . $att_name + . $att_expr + . $att_val + . ' where nodes value is: ' + . $nodeKeyValue + ); + } + + // If lowercase is set, do a case insensitive test of + // the value of the selector. + if ($lowercase) { + $check = $this->match( + $att_expr, + strtolower($att_val), + strtolower($nodeKeyValue), + $att_case_sensitivity + ); + } else { + $check = $this->match( + $att_expr, + $att_val, + $nodeKeyValue, + $att_case_sensitivity + ); + } + + if (is_object($debug_object)) { + $debug_object->debug_log( + 2, + 'after match: ' + . ($check ? 'true' : 'false') + ); + } + + if (! $check) { + $pass = false; + + break; + } + } + } + + // Found a match. Add to list and clear node + if ($pass) { + $ret[$node->_[HDOM_INFO_BEGIN]] = 1; + } + unset($node); + } + // It's passed by reference so this is actually what this function returns. + if (is_object($debug_object)) { + $debug_object->debug_log(1, 'EXIT - ret: ', $ret); + } + } + + protected function match($exp, $pattern, $value, $case_sensitivity) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + if ($case_sensitivity === 'i') { + $pattern = strtolower($pattern); + $value = strtolower($value); + } + + switch ($exp) { + case '=': + return ($value === $pattern); + case '!=': + return ($value !== $pattern); + case '^=': + return preg_match('/^' . preg_quote($pattern, '/') . '/', $value); + case '$=': + return preg_match('/' . preg_quote($pattern, '/') . '$/', $value); + case '*=': + return preg_match('/' . preg_quote($pattern, '/') . '/', $value); + case '|=': + /** + * [att|=val] + * + * Represents an element with the att attribute, its value + * either being exactly "val" or beginning with "val" + * immediately followed by "-" (U+002D). + */ + return strpos($value, $pattern) === 0; + case '~=': + /** + * [att~=val] + * + * Represents an element with the att attribute whose value is a + * whitespace-separated list of words, one of which is exactly + * "val". If "val" contains whitespace, it will never represent + * anything (since the words are separated by spaces). Also if + * "val" is the empty string, it will never represent anything. + */ + return in_array($pattern, explode(' ', trim($value)), true); + } + + return false; + } + + protected function parse_selector($selector_string) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + /** + * Pattern of CSS selectors, modified from mootools (https://mootools.net/) + * + * Paperg: Add the colon to the attribute, so that it properly finds + * like google does. + * + * Note: if you try to look at this attribute, you MUST use getAttribute + * since $dom->x:y will fail the php syntax check. + * + * Notice the \[ starting the attribute? and the @? following? This + * implies that an attribute can begin with an @ sign that is not + * captured. This implies that an html attribute specifier may start + * with an @ sign that is NOT captured by the expression. Farther study + * is required to determine of this should be documented or removed. + * + * Matches selectors in this order: + * + * [0] - full match + * + * [1] - tag name + * ([\w:\*-]*) + * Matches the tag name consisting of zero or more words, colons, + * asterisks and hyphens. + * + * [2] - id name + * (?:\#([\w-]+)) + * Optionally matches a id name, consisting of an "#" followed by + * the id name (one or more words and hyphens). + * + * [3] - class names (including dots) + * (?:\.([\w\.-]+))? + * Optionally matches a list of classs, consisting of an "." + * followed by the class name (one or more words and hyphens) + * where multiple classes can be chained (i.e. ".foo.bar.baz") + * + * [4] - attributes + * ((?:\[@?(?:!?[\w:-]+)(?:(?:[!*^$|~]?=)[\"']?(?:.*?)[\"']?)?(?:\s*?(?:[iIsS])?)?\])+)? + * Optionally matches the attributes list + * + * [5] - separator + * ([\/, >+~]+) + * Matches the selector list separator + */ + // phpcs:ignore Generic.Files.LineLength + $pattern = "/([\w:\*-]*)(?:\#([\w-]+))?(?:|\.([\w\.-]+))?((?:\[@?(?:!?[\w:-]+)(?:(?:[!*^$|~]?=)[\"']?(?:.*?)[\"']?)?(?:\s*?(?:[iIsS])?)?\])+)?([\/, >+~]+)/is"; + + preg_match_all( + $pattern, + trim($selector_string) . ' ', // Add final ' ' as pseudo separator + $matches, + PREG_SET_ORDER + ); + + if (is_object($debug_object)) { + $debug_object->debug_log(2, 'Matches Array: ', $matches); + } + + $selectors = []; + $result = []; + + foreach ($matches as $m) { + $m[0] = trim($m[0]); + + // Skip NoOps + if ($m[0] === '' || $m[0] === '/' || $m[0] === '//') { + continue; + } + + // Convert to lowercase + if ($this->dom->lowercase) { + $m[1] = strtolower($m[1]); + } + + // Extract classes + if ($m[3] !== '') { + $m[3] = explode('.', $m[3]); + } + + /* Extract attributes (pattern based on the pattern above!) + + * [0] - full match + * [1] - attribute name + * [2] - attribute expression + * [3] - attribute value + * [4] - case sensitivity + * + * Note: Attributes can be negated with a "!" prefix to their name + */ + if ($m[4] !== '') { + preg_match_all( + "/\[@?(!?[\w:-]+)(?:([!*^$|~]?=)[\"']?(.*?)[\"']?)?(?:\s+?([iIsS])?)?\]/is", + trim($m[4]), + $attributes, + PREG_SET_ORDER + ); + + // Replace element by array + $m[4] = []; + + foreach ($attributes as $att) { + // Skip empty matches + if (trim($att[0]) === '') { + continue; + } + + $inverted = (isset($att[1][0]) && $att[1][0] === '!'); + $m[4][] = [ + $inverted ? substr($att[1], 1) : $att[1], // Name + (isset($att[2])) ? $att[2] : '', // Expression + (isset($att[3])) ? $att[3] : '', // Value + $inverted, // Inverted Flag + (isset($att[4])) ? strtolower($att[4]) : '', // Case-Sensitivity + ]; + } + } + + // Sanitize Separator + if ($m[5] !== '' && trim($m[5]) === '') { // Descendant Separator + $m[5] = ' '; + } else { // Other Separator + $m[5] = trim($m[5]); + } + + // Clear Separator if it's a Selector List + if ($is_list = ($m[5] === ',')) { + $m[5] = ''; + } + + // Remove full match before adding to results + array_shift($m); + $result[] = $m; + + if ($is_list) { // Selector List + $selectors[] = $result; + $result = []; + } + } + + if (count($result) > 0) { + $selectors[] = $result; + } + + return $selectors; + } + + public function __get($name) + { + if (isset($this->attr[$name])) { + return $this->convert_text($this->attr[$name]); + } + switch ($name) { + case 'outertext': return $this->outertext(); + case 'innertext': return $this->innertext(); + case 'plaintext': return $this->text(); + case 'xmltext': return $this->xmltext(); + default: return array_key_exists($name, $this->attr); + } + } + + public function __set($name, $value) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + switch ($name) { + case 'outertext': return $this->_[HDOM_INFO_OUTER] = $value; + case 'innertext': + if (isset($this->_[HDOM_INFO_TEXT])) { + return $this->_[HDOM_INFO_TEXT] = $value; + } + + return $this->_[HDOM_INFO_INNER] = $value; + } + + if (! isset($this->attr[$name])) { + $this->_[HDOM_INFO_SPACE][] = [' ', '', '']; + $this->_[HDOM_INFO_QUOTE][] = HDOM_QUOTE_DOUBLE; + } + + $this->attr[$name] = $value; + } + + public function __isset($name) + { + switch ($name) { + case 'outertext': return true; + case 'innertext': return true; + case 'plaintext': return true; + } + //no value attr: nowrap, checked selected... + return (array_key_exists($name, $this->attr)) ? true : isset($this->attr[$name]); + } + + public function __unset($name) + { + if (isset($this->attr[$name])) { + unset($this->attr[$name]); + } + } + + public function convert_text($text) + { + global $debug_object; + if (is_object($debug_object)) { + $debug_object->debug_log_entry(1); + } + + $converted_text = $text; + + $sourceCharset = ''; + $targetCharset = ''; + + if ($this->dom) { + $sourceCharset = strtoupper($this->dom->_charset); + $targetCharset = strtoupper($this->dom->_target_charset); + } + + if (is_object($debug_object)) { + $debug_object->debug_log( + 3, + 'source charset: ' + . $sourceCharset + . ' target charaset: ' + . $targetCharset + ); + } + + if (! empty($sourceCharset) + && ! empty($targetCharset) + && (strcasecmp($sourceCharset, $targetCharset) != 0)) { + // Check if the reported encoding could have been incorrect and the text is actually already UTF-8 + if ((strcasecmp($targetCharset, 'UTF-8') == 0) + && ($this->is_utf8($text))) { + $converted_text = $text; + } else { + $converted_text = iconv($sourceCharset, $targetCharset, $text); + } + } + + // Lets make sure that we don't have that silly BOM issue with any of the utf-8 text we output. + if ($targetCharset === 'UTF-8') { + if (substr($converted_text, 0, 3) === "\xef\xbb\xbf") { + $converted_text = substr($converted_text, 3); + } + + if (substr($converted_text, -3) === "\xef\xbb\xbf") { + $converted_text = substr($converted_text, 0, -3); + } + } + + return $converted_text; + } + + public static function is_utf8($str) + { + $c = 0; + $b = 0; + $bits = 0; + $len = strlen($str); + for ($i = 0; $i < $len; $i++) { + $c = ord($str[$i]); + if ($c > 128) { + if (($c >= 254)) { + return false; + } elseif ($c >= 252) { + $bits = 6; + } elseif ($c >= 248) { + $bits = 5; + } elseif ($c >= 240) { + $bits = 4; + } elseif ($c >= 224) { + $bits = 3; + } elseif ($c >= 192) { + $bits = 2; + } else { + return false; + } + if (($i + $bits) > $len) { + return false; + } + while ($bits > 1) { + $i++; + $b = ord($str[$i]); + if ($b < 128 || $b > 191) { + return false; + } + $bits--; + } + } + } + + return true; + } + + public function get_display_size() + { + global $debug_object; + + $width = -1; + $height = -1; + + if ($this->tag !== 'img') { + return false; + } + + // See if there is aheight or width attribute in the tag itself. + if (isset($this->attr['width'])) { + $width = $this->attr['width']; + } + + if (isset($this->attr['height'])) { + $height = $this->attr['height']; + } + + // Now look for an inline style. + if (isset($this->attr['style'])) { + // Thanks to user gnarf from stackoverflow for this regular expression. + $attributes = []; + + preg_match_all( + '/([\w-]+)\s*:\s*([^;]+)\s*;?/', + $this->attr['style'], + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $match) { + $attributes[$match[1]] = $match[2]; + } + + // If there is a width in the style attributes: + if (isset($attributes['width']) && $width == -1) { + // check that the last two characters are px (pixels) + if (strtolower(substr($attributes['width'], -2)) === 'px') { + $proposed_width = substr($attributes['width'], 0, -2); + // Now make sure that it's an integer and not something stupid. + if (filter_var($proposed_width, FILTER_VALIDATE_INT)) { + $width = $proposed_width; + } + } + } + + // If there is a width in the style attributes: + if (isset($attributes['height']) && $height == -1) { + // check that the last two characters are px (pixels) + if (strtolower(substr($attributes['height'], -2)) == 'px') { + $proposed_height = substr($attributes['height'], 0, -2); + // Now make sure that it's an integer and not something stupid. + if (filter_var($proposed_height, FILTER_VALIDATE_INT)) { + $height = $proposed_height; + } + } + } + } + + // Future enhancement: + // Look in the tag to see if there is a class or id specified that has + // a height or width attribute to it. + + // Far future enhancement + // Look at all the parent tags of this image to see if they specify a + // class or id that has an img selector that specifies a height or width + // Note that in this case, the class or id will have the img subselector + // for it to apply to the image. + + // ridiculously far future development + // If the class or id is specified in a SEPARATE css file thats not on + // the page, go get it and do what we were just doing for the ones on + // the page. + + $result = [ + 'height' => $height, + 'width' => $width, + ]; + + return $result; + } + + public function save($filepath = '') + { + $ret = $this->outertext(); + + if ($filepath !== '') { + file_put_contents($filepath, $ret, LOCK_EX); + } + + return $ret; + } + + public function addClass($class) + { + if (is_string($class)) { + $class = explode(' ', $class); + } + + if (is_array($class)) { + foreach ($class as $c) { + if (isset($this->class)) { + if ($this->hasClass($c)) { + continue; + } else { + $this->class .= ' ' . $c; + } + } else { + $this->class = $c; + } + } + } else { + if (is_object($debug_object)) { + $debug_object->debug_log(2, 'Invalid type: ', gettype($class)); + } + } + } + + public function hasClass($class) + { + if (is_string($class)) { + if (isset($this->class)) { + return in_array($class, explode(' ', $this->class), true); + } + } else { + if (is_object($debug_object)) { + $debug_object->debug_log(2, 'Invalid type: ', gettype($class)); + } + } + + return false; + } + + public function removeClass($class = null) + { + if (! isset($this->class)) { + return; + } + + if (is_null($class)) { + $this->removeAttribute('class'); + + return; + } + + if (is_string($class)) { + $class = explode(' ', $class); + } + + if (is_array($class)) { + $class = array_diff(explode(' ', $this->class), $class); + if (empty($class)) { + $this->removeAttribute('class'); + } else { + $this->class = implode(' ', $class); + } + } + } + + public function getAllAttributes() + { + return $this->attr; + } + + public function getAttribute($name) + { + return $this->__get($name); + } + + public function setAttribute($name, $value) + { + $this->__set($name, $value); + } + + public function hasAttribute($name) + { + return $this->__isset($name); + } + + public function removeAttribute($name) + { + $this->__set($name, null); + } + + public function remove() + { + if ($this->parent) { + $this->parent->removeChild($this); + } + } + + public function removeChild($node) + { + $nidx = array_search($node, $this->nodes, true); + $cidx = array_search($node, $this->children, true); + $didx = array_search($node, $this->dom->nodes, true); + + if ($nidx !== false && $cidx !== false && $didx !== false) { + foreach ($node->children as $child) { + $node->removeChild($child); + } + + foreach ($node->nodes as $entity) { + $enidx = array_search($entity, $node->nodes, true); + $edidx = array_search($entity, $node->dom->nodes, true); + + if ($enidx !== false && $edidx !== false) { + unset($node->nodes[$enidx]); + unset($node->dom->nodes[$edidx]); + } + } + + unset($this->nodes[$nidx]); + unset($this->children[$cidx]); + unset($this->dom->nodes[$didx]); + + $node->clear(); + } + } + + public function getElementById($id) + { + return $this->find("#$id", 0); + } + + public function getElementsById($id, $idx = null) + { + return $this->find("#$id", $idx); + } + + public function getElementByTagName($name) + { + return $this->find($name, 0); + } + + public function getElementsByTagName($name, $idx = null) + { + return $this->find($name, $idx); + } + + public function parentNode() + { + return $this->parent(); + } + + public function childNodes($idx = -1) + { + return $this->children($idx); + } + + public function firstChild() + { + return $this->first_child(); + } + + public function lastChild() + { + return $this->last_child(); + } + + public function nextSibling() + { + return $this->next_sibling(); + } + + public function previousSibling() + { + return $this->prev_sibling(); + } + + public function hasChildNodes() + { + return $this->has_child(); + } + + public function nodeName() + { + return $this->tag; + } + + public function appendChild($node) + { + $node->parent($this); + + return $node; + } + + // fbt implementation: + // -- extended internal methods + + public function isSelfClosing(): bool + { + return strstr($this->_[HDOM_INFO_ENDSPACE], '/'); + } + + public function isNamespacedElement(): bool + { + return strstr($this->tag, ':'); + } + + public function isElement(): bool + { + return $this->nodetype === HDOM_TYPE_ELEMENT; + } + + public function isComment(): bool + { + return $this->nodetype === HDOM_TYPE_COMMENT; + } + + public function isText(): bool + { + return $this->nodetype === HDOM_TYPE_TEXT; + } + + // fbt implementation: + // -- extended internal methods + + public function getDOM(): DOM + { + return $this->dom; + } +} + +class DOM +{ + public $root = null; + public $nodes = []; + public $callback = null; + public $lowercase = false; + public $original_size; + public $size; + + protected $pos; + protected $doc; + protected $char; + + protected $cursor; + protected $parent; + protected $noise = []; + protected $token_blank = " \t\r\n"; + protected $token_equal = ' =/>'; + protected $token_slash = " />\r\n\t"; + protected $token_attr = ' >'; + + public $_charset = ''; + public $_target_charset = ''; + + protected $default_br_text = ''; + + public $default_span_text = ''; + + protected $self_closing_tags = [ + 'area' => 1, + 'base' => 1, + 'br' => 1, + 'col' => 1, + 'embed' => 1, + 'hr' => 1, + 'img' => 1, + 'input' => 1, + 'link' => 1, + 'meta' => 1, + 'param' => 1, + 'source' => 1, + 'track' => 1, + 'wbr' => 1, + // fbt implementation: + 'fbt:enum' => 1, + 'fbt:pronoun' => 1, + 'fbt:sameParam' => 1, + 'fbt:same-param' => 1, + ]; + protected $block_tags = [ + 'body' => 1, + 'div' => 1, + 'form' => 1, + 'root' => 1, + 'span' => 1, + 'table' => 1, + ]; + protected $optional_closing_tags = [ + // Not optional, see + // https://www.w3.org/TR/html/textlevel-semantics.html#the-b-element + 'b' => ['b' => 1], + 'dd' => ['dd' => 1, 'dt' => 1], + // Not optional, see + // https://www.w3.org/TR/html/grouping-content.html#the-dl-element + 'dl' => ['dd' => 1, 'dt' => 1], + 'dt' => ['dd' => 1, 'dt' => 1], + 'li' => ['li' => 1], + 'optgroup' => ['optgroup' => 1, 'option' => 1], + 'option' => ['optgroup' => 1, 'option' => 1], + 'p' => ['p' => 1], + 'rp' => ['rp' => 1, 'rt' => 1], + 'rt' => ['rp' => 1, 'rt' => 1], + 'td' => ['td' => 1, 'th' => 1], + 'th' => ['td' => 1, 'th' => 1], + 'tr' => ['td' => 1, 'th' => 1, 'tr' => 1], + ]; + + public function __construct( + $str = null, + $lowercase = true, + $forceTagsClosed = true, + $target_charset = DEFAULT_TARGET_CHARSET, + $stripRN = true, + $defaultBRText = DEFAULT_BR_TEXT, + $defaultSpanText = DEFAULT_SPAN_TEXT, + $options = 0 + ) { + if ($str) { + if (preg_match('/^http:\/\//i', $str) || is_file($str)) { + $this->load_file($str); + } else { + $this->load( + $str, + $lowercase, + $stripRN, + $defaultBRText, + $defaultSpanText, + $options + ); + } + } + // Forcing tags to be closed implies that we don't trust the html, but + // it can lead to parsing errors if we SHOULD trust the html. + if (! $forceTagsClosed) { + $this->optional_closing_array = []; + } + + $this->_target_charset = $target_charset; + } + + public function __destruct() + { + $this->clear(); + } + + public function load( + $str, + $lowercase = true, + $stripRN = true, + $defaultBRText = DEFAULT_BR_TEXT, + $defaultSpanText = DEFAULT_SPAN_TEXT, + $options = 0 + ) { + global $debug_object; + + // prepare + $this->prepare($str, $lowercase, $defaultBRText, $defaultSpanText); + + // Per sourceforge http://sourceforge.net/tracker/?func=detail&aid=2949097&group_id=218559&atid=1044037 + // Script tags removal now preceeds style tag removal. + // strip out